Compare commits

...
11 Commits
Author SHA1 Message Date
Linus Rath 8fa8597e9f chore: update version number to 0.1.15 2026-05-02 21:12:12 +02:00
Linus Rath 57f15f6f2e feat: improve keyboard handling in ComposeScreen for better visibility of format bar 2026-05-02 21:12:03 +02:00
Linus Rath cd3fdfa217 chore: update version number to 0.1.14 2026-05-02 17:22:14 +02:00
Linus Rath f8064d4544 feat: add swipe mode settings and enhance swipeable row functionality 2026-05-02 17:22:05 +02:00
Linus Rath a8bb83ffe0 chore: update version number to 0.1.13 2026-05-02 14:44:00 +02:00
Linus Rath c37256c54e feat: enhance attachment handling in ComposeScreen with file picker and updated UI options 2026-05-02 14:43:45 +02:00
Linus Rath e1d318ebc1 feat: add restoreEmailMailboxes mock to email store tests 2026-05-02 14:32:15 +02:00
Linus Rath 2090480bf4 feat: implement undo functionality for email actions with snackbar notification 2026-05-02 14:31:50 +02:00
Linus Rath 329c8a2f0b feat: add swipe actions for pinning and moving emails; enhance archive functionality 2026-05-02 14:27:15 +02:00
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
30 changed files with 4233 additions and 1517 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.11
0.1.15
+2
View File
@@ -450,6 +450,8 @@
"send": "Send",
"cancel": "Cancel",
"attach": "Attach",
"attach_photos": "Photos & Videos",
"attach_files": "Files",
"discard": "Discard",
"discard_draft_title": "Discard draft?",
"discard_draft_confirm": "You have unsaved changes. Do you want to discard this draft?",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-mobile",
"version": "0.1.11",
"version": "0.1.15",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "0.1.11",
"version": "0.1.15",
"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.11",
"version": "0.1.15",
"license": "AGPL-3.0-only",
"main": "index.ts",
"scripts": {
+7
View File
@@ -1,5 +1,12 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('expo-file-system', () => ({
File: class {
constructor(public uri: string) {}
async bytes() { return new Uint8Array(); }
},
}));
vi.mock('../jmap-client', () => ({
jmapClient: {
accountId: 'acc-1',
+11 -6
View File
@@ -1,3 +1,4 @@
import { File } from 'expo-file-system';
import { jmapClient } from './jmap-client';
export async function uploadBlob(
@@ -7,11 +8,14 @@ export async function uploadBlob(
const session = jmapClient.currentSession;
if (!session) throw new Error('Not connected');
const uploadUrl = session.uploadUrl.replace('{accountId}', jmapClient.accountId);
const uploadUrl = session.uploadUrl.replace(
'{accountId}',
encodeURIComponent(jmapClient.accountId),
);
// Fetch local file as blob
const fileResponse = await fetch(uri);
const blob = await fileResponse.blob();
// Read the source via the file-system API so this works for both `file://`
// (image picker, iOS document picker) and `content://` (Android SAF) URIs.
const bytes = await new File(uri).bytes();
const response = await fetch(uploadUrl, {
method: 'POST',
@@ -19,11 +23,12 @@ export async function uploadBlob(
'Content-Type': type,
Authorization: (jmapClient as any).authHeader,
},
body: blob,
body: bytes as unknown as BodyInit,
});
if (!response.ok) {
throw new Error(`Upload failed: ${response.status}`);
const detail = await response.text().catch(() => '');
throw new Error(`Upload failed: ${response.status}${detail ? ` ${detail}` : ''}`);
}
return response.json();
+139 -1
View File
@@ -1,6 +1,6 @@
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
import type { Email, EmailAddress, Mailbox, Thread } from './types';
import type { Email, EmailAddress, JMAPMethodCall, Mailbox, Thread } from './types';
import { toWildcardQuery } from '../lib/search-utils';
const EMAIL_LIST_PROPERTIES = [
@@ -111,6 +111,144 @@ export async function moveEmail(
]);
}
// Restore each email's mailboxIds to the snapshot supplied. Used by undo to
// reverse a move/archive/spam in one round-trip. JMAP "mailboxIds" replaces
// the entire map, so we don't need to compute a diff against the current state.
export async function restoreEmailMailboxes(
items: Array<{ id: string; mailboxIds: Record<string, boolean> }>,
): Promise<void> {
if (items.length === 0) return;
const accountId = jmapClient.accountId;
const update: Record<string, { mailboxIds: Record<string, true> }> = {};
for (const item of items) {
const onlyTrue: Record<string, true> = {};
for (const [id, present] of Object.entries(item.mailboxIds)) {
if (present) onlyTrue[id] = true;
}
update[item.id] = { mailboxIds: onlyTrue };
}
await jmapClient.request([
['Email/set', { accountId, update }, '0'],
]);
}
// Archive one or more emails into the archive mailbox, optionally auto-sorting
// into year or year/month subfolders. Mirrors the webmail implementation in
// lib/jmap/client.ts so behavior stays in sync between platforms.
export async function archiveEmails(
emails: Array<{ id: string; receivedAt: string }>,
archiveMailboxId: string,
mode: 'single' | 'year' | 'month',
existingMailboxes: Mailbox[],
): Promise<void> {
if (emails.length === 0) return;
const accountId = jmapClient.accountId;
if (mode === 'single') {
const updates = Object.fromEntries(
emails.map((e) => [e.id, { mailboxIds: { [archiveMailboxId]: true } }]),
);
await jmapClient.request([
['Email/set', { accountId, update: updates }, '0'],
]);
return;
}
type Dest = { year: string; month?: string };
const destFor = new Map<string, Dest>();
for (const e of emails) {
const d = new Date(e.receivedAt);
const year = d.getFullYear().toString();
const month = (d.getMonth() + 1).toString().padStart(2, '0');
destFor.set(e.id, mode === 'year' ? { year } : { year, month });
}
// Resolve each destination folder to either an existing id or a creation-id reference ("#<cid>").
const yearIdFor = new Map<string, string>();
const monthIdFor = new Map<string, string>();
const createEntries: Record<string, Record<string, unknown>> = {};
const findExisting = (name: string, parentId: string) =>
existingMailboxes.find(
(m) => m.name === name && m.parentId === parentId,
);
for (const dest of destFor.values()) {
if (!yearIdFor.has(dest.year)) {
const existing = findExisting(dest.year, archiveMailboxId);
if (existing) {
yearIdFor.set(dest.year, existing.id);
} else {
const cid = `year-${dest.year}`;
createEntries[cid] = { name: dest.year, parentId: archiveMailboxId };
yearIdFor.set(dest.year, `#${cid}`);
}
}
if (mode === 'month' && dest.month) {
const monthKey = `${dest.year}/${dest.month}`;
if (!monthIdFor.has(monthKey)) {
const yearRef = yearIdFor.get(dest.year)!;
const existingMonth = yearRef.startsWith('#')
? undefined
: findExisting(dest.month, yearRef);
if (existingMonth) {
monthIdFor.set(monthKey, existingMonth.id);
} else {
const cid = `month-${dest.year}-${dest.month}`;
createEntries[cid] = { name: dest.month, parentId: yearRef };
monthIdFor.set(monthKey, `#${cid}`);
}
}
}
}
const updates: Record<string, { mailboxIds: Record<string, true> }> = {};
for (const [emailId, dest] of destFor.entries()) {
const destId = mode === 'month' && dest.month
? monthIdFor.get(`${dest.year}/${dest.month}`)!
: yearIdFor.get(dest.year)!;
updates[emailId] = { mailboxIds: { [destId]: true } };
}
const methodCalls: JMAPMethodCall[] = [];
const hasCreates = Object.keys(createEntries).length > 0;
if (hasCreates) {
methodCalls.push(['Mailbox/set', { accountId, create: createEntries }, '0']);
}
methodCalls.push(['Email/set', { accountId, update: updates }, String(methodCalls.length)]);
const response = await jmapClient.request(methodCalls);
if (hasCreates) {
const mailboxResult = response.methodResponses?.[0]?.[1];
const notCreated = mailboxResult?.notCreated as
| Record<string, { type?: string; properties?: string[]; description?: string }>
| undefined;
const failures = notCreated ? Object.entries(notCreated) : [];
if (failures.length > 0) {
const [cid, err] = failures[0];
const parts = [err.type || 'unknown'];
if (err.properties?.length) parts.push(`properties=[${err.properties.join(', ')}]`);
if (err.description) parts.push(err.description);
throw new Error(`Failed to create archive folder '${cid}': ${parts.join(' – ')}`);
}
}
const emailIdx = hasCreates ? 1 : 0;
const emailResult = response.methodResponses?.[emailIdx]?.[1];
const notUpdated = emailResult?.notUpdated as
| Record<string, { type?: string; description?: string }>
| undefined;
const emailFailures = notUpdated ? Object.entries(notUpdated) : [];
if (emailFailures.length > 0) {
const [id, err] = emailFailures[0];
throw new Error(
`Failed to archive ${emailFailures.length} email(s), first: ${id} – ${err.type || 'unknown'}${err.description ? ` (${err.description})` : ''}`,
);
}
}
export async function deleteEmail(
emailId: string,
trashMailboxId: string,
+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;
}
+199
View File
@@ -0,0 +1,199 @@
import React from 'react';
import {
View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import {
Inbox, Send, File as FileIcon, Trash2, Ban, Archive, Folder,
X, Check, type LucideIcon,
} from 'lucide-react-native';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { buildMailboxTree, flattenVisible, type MailboxNode } from '../lib/mailbox-tree';
import { useSheetDrag } from '../lib/use-sheet-drag';
import type { Mailbox } from '../api/types';
function moveTargetIcon(role: string | null | undefined, name: string): LucideIcon {
const lower = name.toLowerCase();
if (role === 'inbox' || lower.includes('inbox')) return Inbox;
if (role === 'sent' || lower.includes('sent')) return Send;
if (role === 'drafts' || lower.includes('draft')) return FileIcon;
if (role === 'trash' || lower.includes('trash') || lower.includes('deleted')) return Trash2;
if (role === 'junk' || role === 'spam' || lower.includes('junk') || lower.includes('spam')) return Ban;
if (role === 'archive' || lower.includes('archive')) return Archive;
return Folder;
}
interface MoveSheetProps {
visible: boolean;
onClose: () => void;
mailboxes: Mailbox[];
/** Folder the email is currently in - shown with a check, not selectable. */
currentMailboxId: string | null;
onPick: (id: string) => void;
}
export function MoveSheet({
visible, onClose, mailboxes, currentMailboxId, onPick,
}: MoveSheetProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
const slideY = React.useRef(new Animated.Value(500)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose });
React.useEffect(() => {
if (visible) {
Animated.parallel([
Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 1, duration: 220, useNativeDriver: true }),
]).start();
} else {
Animated.parallel([
Animated.timing(slideY, { toValue: 500, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 0, duration: 180, useNativeDriver: true }),
]).start();
}
}, [visible, slideY, overlayOpacity]);
const visibleNodes = React.useMemo(() => {
const tree = buildMailboxTree(mailboxes);
const expanded = new Set<string>();
const collect = (nodes: MailboxNode[]) => {
for (const n of nodes) {
if (n.children.length > 0) {
expanded.add(n.id);
collect(n.children);
}
}
};
collect(tree);
return flattenVisible(tree, expanded);
}, [mailboxes]);
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
<Pressable style={styles.sheetOverlayPress} onPress={onClose} />
</Animated.View>
<Animated.View
style={[
styles.sheet,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
]}
>
<View {...dragHandlers}>
<View style={styles.sheetHandleHit}>
<View style={styles.sheetHandle} />
</View>
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>Move to folder</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}>
<X size={18} color={c.textSecondary} />
</Pressable>
</View>
</View>
<ScrollView>
{visibleNodes.map((node) => {
const Icon = moveTargetIcon(node.role, node.name);
const isCurrent = node.id === currentMailboxId;
const canTarget = node.myRights?.mayAddItems !== false && !isCurrent;
return (
<Pressable
key={node.id}
onPress={canTarget ? () => onPick(node.id) : undefined}
disabled={!canTarget}
style={({ pressed }) => [
styles.moveRow,
pressed && canTarget && styles.moveRowPressed,
{ paddingLeft: spacing.lg + node.depth * 16 },
]}
>
<Icon size={16} color={canTarget ? c.textSecondary : c.textMuted} />
<Text
style={[styles.moveRowLabel, !canTarget && styles.moveRowLabelDisabled]}
numberOfLines={1}
>
{node.name}
</Text>
{isCurrent && <Check size={14} color={c.textMuted} />}
</Pressable>
);
})}
</ScrollView>
</Animated.View>
</Modal>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
sheetOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.5)',
},
sheetOverlayPress: { flex: 1 },
sheet: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
maxHeight: '75%',
backgroundColor: c.popover,
borderTopLeftRadius: radius.lg,
borderTopRightRadius: radius.lg,
borderTopWidth: 1,
borderColor: c.border,
paddingTop: spacing.sm,
},
sheetHandleHit: {
alignItems: 'center',
paddingTop: spacing.xs,
paddingBottom: spacing.sm,
},
sheetHandle: {
width: 36,
height: 4,
borderRadius: 2,
backgroundColor: c.border,
},
sheetHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingBottom: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: c.border,
},
sheetTitle: {
...typography.bodySemibold,
color: c.text,
},
sheetClose: {
width: 28,
height: 28,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radius.xs,
},
moveRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingRight: spacing.lg,
paddingVertical: spacing.sm + 2,
minHeight: 44,
},
moveRowPressed: { backgroundColor: c.surfaceHover },
moveRowLabel: {
...typography.body,
color: c.text,
flex: 1,
},
moveRowLabelDisabled: {
color: c.textMuted,
},
});
}
+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',
},
});
}
+216 -79
View File
@@ -3,12 +3,12 @@ import {
View, Text, StyleSheet, Animated, PanResponder, Pressable, Dimensions,
} from 'react-native';
import {
Archive, Trash2, ShieldAlert, MailOpen, Mail, Star,
Archive, Trash2, ShieldAlert, MailOpen, Star, Pin, FolderInput,
type LucideIcon,
} from 'lucide-react-native';
import { typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import type { SwipeAction } from '../stores/settings-store';
import type { SwipeAction, SwipeMode } from '../stores/settings-store';
interface SwipeableRowProps {
children: React.ReactNode;
@@ -17,15 +17,28 @@ interface SwipeableRowProps {
/** Left-to-right swipe action (revealed under a rightward drag, sits at left edge). */
rightAction: SwipeAction;
/** Pass the row state used to compute action labels (e.g. unread/starred toggling). */
context: { unread: boolean; starred: boolean };
context: { unread: boolean; starred: boolean; pinned: boolean };
onAction: (action: SwipeAction) => void;
/**
* 'instant' (default): swipe past COMMIT_THRESHOLD and release fires the action.
* 'reveal': swipe past ACTIVATION_THRESHOLD reveals an action band that must be tapped.
*/
mode?: SwipeMode;
}
const REVEAL_WIDTH = 88; // px shown when fully revealed
const ACTIVATION_THRESHOLD = 32; // px past which release snaps open instead of closing
// Instant mode: distance the user must drag past on release to fire the action.
const COMMIT_THRESHOLD = 96;
// Reveal mode: drag past this on release to snap to the open (revealed) state.
const ACTIVATION_THRESHOLD = 32;
// Reveal mode: width of the action band shown when revealed.
const REVEAL_WIDTH = 88;
const DIRECTION_BIAS = 1.5; // dx must dominate dy by this factor
const MIN_DX_TO_CLAIM = 6;
const MAX_DRAG_OVERSHOOT = REVEAL_WIDTH * 1.4;
const MAX_DRAG_OVERSHOOT_INSTANT = 240;
const MAX_DRAG_OVERSHOOT_REVEAL = REVEAL_WIDTH * 1.4;
// Distance the row flies off-screen by before the action callback fires.
const EXIT_DISTANCE = 600;
const ACTION_META: Record<Exclude<SwipeAction, 'none'>, { icon: LucideIcon; bg: string; defaultLabel: string }> = {
archive: { icon: Archive, bg: '#1d4ed8', defaultLabel: 'Archive' },
@@ -33,23 +46,34 @@ const ACTION_META: Record<Exclude<SwipeAction, 'none'>, { icon: LucideIcon; bg:
spam: { icon: ShieldAlert, bg: '#a16207', defaultLabel: 'Spam' },
read: { icon: MailOpen, bg: '#0f766e', defaultLabel: 'Read' },
star: { icon: Star, bg: '#a16207', defaultLabel: 'Star' },
pin: { icon: Pin, bg: '#7c3aed', defaultLabel: 'Pin' },
move: { icon: FolderInput, bg: '#475569', defaultLabel: 'Move' },
};
function actionLabel(action: SwipeAction, context: { unread: boolean; starred: boolean }): string {
function actionLabel(action: SwipeAction, context: { unread: boolean; starred: boolean; pinned: boolean }): string {
if (action === 'read') return context.unread ? 'Read' : 'Unread';
if (action === 'star') return context.starred ? 'Unstar' : 'Star';
if (action === 'pin') return context.pinned ? 'Unpin' : 'Pin';
if (action === 'none') return '';
return ACTION_META[action].defaultLabel;
}
// Actions that visibly remove the row from the list (so the row should fly off
// instead of snapping back). Toggle-style actions stay in place.
function exitsRow(action: SwipeAction): boolean {
return action === 'archive' || action === 'delete' || action === 'spam' || action === 'move';
}
export function SwipeableRow({
children, leftAction, rightAction, context, onAction,
children, leftAction, rightAction, context, onAction, mode = 'instant',
}: SwipeableRowProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const dx = useRef(new Animated.Value(0)).current;
const claimed = useRef(false);
const widthRef = useRef(Dimensions.get('window').width);
// Reveal-mode-only state: which side (if any) is currently sitting open.
const openSideRef = useRef<'left' | 'right' | null>(null);
const [openSide, setOpenSide] = useState<'left' | 'right' | null>(null);
@@ -59,16 +83,45 @@ export function SwipeableRow({
setOpenSide(null);
};
const openRight = () => {
Animated.spring(dx, { toValue: REVEAL_WIDTH, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
openSideRef.current = 'right';
setOpenSide('right');
const openTo = (side: 'left' | 'right') => {
const target = side === 'right' ? REVEAL_WIDTH : -REVEAL_WIDTH;
Animated.spring(dx, { toValue: target, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
openSideRef.current = side;
setOpenSide(side);
};
const openLeft = () => {
Animated.spring(dx, { toValue: -REVEAL_WIDTH, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
openSideRef.current = 'left';
setOpenSide('left');
const fly = (toValue: number, action: SwipeAction) => {
// For destructive/move actions: race the row off-screen and fire the
// action - the parent will remove the row from the list. For toggle
// actions: fire immediately and snap back so the same row can update in
// place.
if (exitsRow(action)) {
Animated.timing(dx, {
toValue,
duration: 180,
useNativeDriver: true,
}).start(() => {
onAction(action);
// The row is about to be removed; reset translation in case it isn't
// (e.g. the action failed silently) so we don't leave it off-screen.
dx.setValue(0);
});
} else {
onAction(action);
Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
}
};
const fireFromBandTap = (action: SwipeAction) => {
close();
if (action === 'none') return;
if (exitsRow(action)) {
// Let the row collapse a frame, then fire so the parent's list update
// has a clean starting point.
requestAnimationFrame(() => onAction(action));
} else {
onAction(action);
}
};
const responder = useRef(
@@ -77,8 +130,12 @@ export function SwipeableRow({
onMoveShouldSetPanResponder: (_, g) => {
if (Math.abs(g.dx) < MIN_DX_TO_CLAIM) return false;
if (Math.abs(g.dx) < Math.abs(g.dy) * DIRECTION_BIAS) return false;
// If a side is already open, allow the gesture so the user can drag it closed.
if (openSideRef.current === null) {
// Reveal mode: if a side is already open, allow the gesture so the
// user can drag it closed.
if (mode === 'reveal' && openSideRef.current === null) {
if (g.dx > 0 && rightAction === 'none') return false;
if (g.dx < 0 && leftAction === 'none') return false;
} else if (mode !== 'reveal') {
if (g.dx > 0 && rightAction === 'none') return false;
if (g.dx < 0 && leftAction === 'none') return false;
}
@@ -86,77 +143,151 @@ export function SwipeableRow({
return true;
},
onPanResponderMove: (_, g) => {
const base = openSideRef.current === 'right' ? REVEAL_WIDTH : openSideRef.current === 'left' ? -REVEAL_WIDTH : 0;
const overshoot = mode === 'reveal' ? MAX_DRAG_OVERSHOOT_REVEAL : MAX_DRAG_OVERSHOOT_INSTANT;
const base =
mode === 'reveal'
? openSideRef.current === 'right' ? REVEAL_WIDTH
: openSideRef.current === 'left' ? -REVEAL_WIDTH
: 0
: 0;
const next = base + g.dx;
const clamped = Math.max(-MAX_DRAG_OVERSHOOT, Math.min(MAX_DRAG_OVERSHOOT, next));
const clamped = Math.max(-overshoot, Math.min(overshoot, next));
dx.setValue(clamped);
},
onPanResponderRelease: (_, g) => {
const base = openSideRef.current === 'right' ? REVEAL_WIDTH : openSideRef.current === 'left' ? -REVEAL_WIDTH : 0;
const finalDx = base + g.dx;
if (finalDx > ACTIVATION_THRESHOLD && rightAction !== 'none') {
openRight();
} else if (finalDx < -ACTIVATION_THRESHOLD && leftAction !== 'none') {
openLeft();
} else {
close();
}
claimed.current = false;
if (mode === 'reveal') {
const base =
openSideRef.current === 'right' ? REVEAL_WIDTH
: openSideRef.current === 'left' ? -REVEAL_WIDTH
: 0;
const finalDx = base + g.dx;
if (finalDx > ACTIVATION_THRESHOLD && rightAction !== 'none') {
openTo('right');
} else if (finalDx < -ACTIVATION_THRESHOLD && leftAction !== 'none') {
openTo('left');
} else {
close();
}
return;
}
// Instant mode: fire on release-past-threshold, no second tap.
if (g.dx >= COMMIT_THRESHOLD && rightAction !== 'none') {
fly(widthRef.current || EXIT_DISTANCE, rightAction);
} else if (g.dx <= -COMMIT_THRESHOLD && leftAction !== 'none') {
fly(-(widthRef.current || EXIT_DISTANCE), leftAction);
} else {
Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
}
},
onPanResponderTerminate: () => {
close();
if (mode === 'reveal') close();
else Animated.spring(dx, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start();
claimed.current = false;
},
onPanResponderTerminationRequest: () => !claimed.current,
}),
).current;
const fireAction = (action: SwipeAction) => {
close();
if (action !== 'none') onAction(action);
};
const renderBand = (action: SwipeAction, side: 'left' | 'right') => {
if (action === 'none') return null;
const meta = ACTION_META[action];
const Icon = meta.icon;
const label = actionLabel(action, context);
return (
<Pressable
onPress={() => fireAction(action)}
style={[
styles.band,
side === 'left' ? { left: 0, alignItems: 'flex-start' } : { right: 0, alignItems: 'flex-end' },
{ backgroundColor: meta.bg, width: REVEAL_WIDTH },
]}
>
<View style={styles.bandInner}>
<Icon size={20} color="#fff" />
<Text style={styles.bandLabel}>{label}</Text>
</View>
</Pressable>
);
};
const onLayout = (e: { nativeEvent: { layout: { width: number } } }) => {
widthRef.current = e.nativeEvent.layout.width;
};
if (mode === 'reveal') {
// Reveal mode: bands sit at the row edges with a fixed REVEAL_WIDTH and
// are tap targets. The row slides over them; releasing past the activation
// threshold snaps the row to the open position so the band is fully
// visible and tappable.
const renderRevealBand = (action: SwipeAction, side: 'left' | 'right') => {
if (action === 'none') return null;
const meta = ACTION_META[action];
const Icon = meta.icon;
const label = actionLabel(action, context);
return (
<Pressable
onPress={() => fireFromBandTap(action)}
style={[
styles.bandReveal,
side === 'left' ? { left: 0, alignItems: 'flex-start' } : { right: 0, alignItems: 'flex-end' },
{ backgroundColor: meta.bg, width: REVEAL_WIDTH },
]}
>
<View style={styles.bandInner}>
<Icon size={20} color="#fff" />
<Text style={styles.bandLabel}>{label}</Text>
</View>
</Pressable>
);
};
return (
<View style={styles.wrap} onLayout={onLayout} {...responder.panHandlers}>
{renderRevealBand(rightAction, 'left')}
{renderRevealBand(leftAction, 'right')}
<Animated.View style={[styles.content, { transform: [{ translateX: dx }] }]}>
{children}
{/* When an action is revealed, an overlay absorbs taps on the row so
a tap closes the swipe instead of opening the email. */}
{openSide ? (
<Pressable
style={StyleSheet.absoluteFill}
onPress={close}
/>
) : null}
</Animated.View>
</View>
);
}
// Instant mode: bands fill the gap behind the row as it's dragged.
const renderInstantBand = (action: SwipeAction, side: 'left' | 'right') => {
if (action === 'none') return null;
const meta = ACTION_META[action];
const Icon = meta.icon;
const label = actionLabel(action, context);
const inputRange = side === 'left' ? [0, COMMIT_THRESHOLD] : [-COMMIT_THRESHOLD, 0];
const iconScale = dx.interpolate({
inputRange,
outputRange: side === 'left' ? [0.85, 1.15] : [1.15, 0.85],
extrapolate: 'clamp',
});
return (
<View
style={[
styles.bandInstant,
{ backgroundColor: meta.bg },
side === 'left' ? { justifyContent: 'flex-start' } : { justifyContent: 'flex-end' },
]}
>
<Animated.View style={[styles.bandInner, { transform: [{ scale: iconScale }] }]}>
<Icon size={22} color="#fff" />
<Text style={styles.bandLabel}>{label}</Text>
</Animated.View>
</View>
);
};
const rightBandOpacity = dx.interpolate({
inputRange: [-1, 0, 1],
outputRange: [0, 0, 1],
extrapolate: 'clamp',
});
const leftBandOpacity = dx.interpolate({
inputRange: [-1, 0, 1],
outputRange: [1, 0, 0],
extrapolate: 'clamp',
});
return (
<View style={styles.wrap} onLayout={onLayout} {...responder.panHandlers}>
{renderBand(rightAction, 'left')}
{renderBand(leftAction, 'right')}
<Animated.View style={[StyleSheet.absoluteFill, { opacity: rightBandOpacity }]} pointerEvents="none">
{renderInstantBand(rightAction, 'left')}
</Animated.View>
<Animated.View style={[StyleSheet.absoluteFill, { opacity: leftBandOpacity }]} pointerEvents="none">
{renderInstantBand(leftAction, 'right')}
</Animated.View>
<Animated.View style={[styles.content, { transform: [{ translateX: dx }] }]}>
{children}
{/* When an action is revealed, an overlay absorbs taps on the row so a
tap closes the swipe instead of opening the email. */}
{openSide ? (
<Pressable
style={StyleSheet.absoluteFill}
onPress={close}
/>
) : null}
</Animated.View>
</View>
);
@@ -164,16 +295,22 @@ export function SwipeableRow({
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
wrap: { position: 'relative', backgroundColor: c.background },
content: { backgroundColor: c.background },
band: {
position: 'absolute',
top: 0,
bottom: 0,
justifyContent: 'center',
paddingHorizontal: 16,
},
bandInner: { alignItems: 'center', gap: 4 },
bandLabel: { ...typography.caption, color: '#fff', fontWeight: '600' },
wrap: { position: 'relative', backgroundColor: c.background, overflow: 'hidden' },
content: { backgroundColor: c.background },
bandInstant: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 24,
},
bandReveal: {
position: 'absolute',
top: 0,
bottom: 0,
justifyContent: 'center',
paddingHorizontal: 16,
},
bandInner: { alignItems: 'center', gap: 4 },
bandLabel: { ...typography.caption, color: '#fff', fontWeight: '600' },
});
}
+120
View File
@@ -0,0 +1,120 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable, Animated, Easing } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useEmailStore } from '../stores/email-store';
const VISIBLE_MS = 5000;
export function UndoSnackbar() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
const entry = useEmailStore((s) => s.pendingUndo);
const undoLast = useEmailStore((s) => s.undoLast);
const clearUndo = useEmailStore((s) => s.clearUndo);
const slideY = React.useRef(new Animated.Value(120)).current;
const opacity = React.useRef(new Animated.Value(0)).current;
// Remember the last non-null entry so the bar's text/handlers stay valid
// while it animates out after pendingUndo flips to null.
const [shown, setShown] = React.useState(entry);
React.useEffect(() => {
if (entry) setShown(entry);
}, [entry]);
// Drive the auto-dismiss off the entry's createdAt so re-renders during
// animation don't reset the timer, and so consecutive actions reset it.
React.useEffect(() => {
if (!entry) {
Animated.parallel([
Animated.timing(slideY, { toValue: 120, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
Animated.timing(opacity, { toValue: 0, duration: 180, useNativeDriver: true }),
]).start(({ finished }) => {
if (finished) setShown(null);
});
return;
}
Animated.parallel([
Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
Animated.timing(opacity, { toValue: 1, duration: 220, useNativeDriver: true }),
]).start();
const elapsed = Date.now() - entry.createdAt;
const remaining = Math.max(0, VISIBLE_MS - elapsed);
const id = setTimeout(() => {
// Only clear if the entry is still the same one we scheduled for.
if (useEmailStore.getState().pendingUndo === entry) {
clearUndo();
}
}, remaining);
return () => clearTimeout(id);
}, [entry, slideY, opacity, clearUndo]);
if (!shown) return null;
return (
<Animated.View
pointerEvents="box-none"
style={[
styles.wrap,
{ bottom: insets.bottom + spacing.md, opacity, transform: [{ translateY: slideY }] },
]}
>
<View style={styles.bar}>
<Text style={styles.label} numberOfLines={1}>{shown.label}</Text>
<Pressable
onPress={() => { void undoLast(); }}
hitSlop={8}
style={({ pressed }) => [styles.undoBtn, pressed && styles.undoBtnPressed]}
>
<Text style={styles.undoText}>UNDO</Text>
</Pressable>
</View>
</Animated.View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
wrap: {
position: 'absolute',
left: spacing.md,
right: spacing.md,
},
bar: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.sm + 2,
paddingLeft: spacing.lg,
paddingRight: spacing.sm,
borderRadius: radius.md,
backgroundColor: c.text,
shadowColor: '#000',
shadowOpacity: 0.3,
shadowRadius: 12,
shadowOffset: { width: 0, height: 6 },
elevation: 8,
},
label: {
...typography.body,
color: c.background,
flex: 1,
},
undoBtn: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.sm,
},
undoBtnPressed: {
backgroundColor: 'rgba(255,255,255,0.1)',
},
undoText: {
...typography.bodySemibold,
color: c.primary,
letterSpacing: 0.5,
},
});
}
+24 -6
View File
@@ -1,10 +1,10 @@
import React, { useEffect } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { ArrowRight, ArrowLeft } from 'lucide-react-native';
import { SettingsSection, SettingItem, RadioGroup } from './settings-section';
import { SettingsSection, RadioGroup } from './settings-section';
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useSettingsStore, type SwipeAction } from '../../stores/settings-store';
import { useSettingsStore, type SwipeAction, type SwipeMode } from '../../stores/settings-store';
const SWIPE_OPTIONS: { value: SwipeAction; label: string }[] = [
{ value: 'none', label: 'None' },
@@ -13,6 +13,13 @@ const SWIPE_OPTIONS: { value: SwipeAction; label: string }[] = [
{ value: 'spam', label: 'Spam' },
{ value: 'read', label: 'Read/Unread' },
{ value: 'star', label: 'Star' },
{ value: 'pin', label: 'Pin' },
{ value: 'move', label: 'Move to folder' },
];
const SWIPE_MODE_OPTIONS: { value: SwipeMode; label: string }[] = [
{ value: 'instant', label: 'Instant (swipe = action)' },
{ value: 'reveal', label: 'Reveal (swipe, then tap)' },
];
export function LayoutSettings() {
@@ -22,6 +29,8 @@ export function LayoutSettings() {
const setSwipeLeftAction = useSettingsStore((s) => s.setSwipeLeftAction);
const swipeRightAction = useSettingsStore((s) => s.swipeRightAction);
const setSwipeRightAction = useSettingsStore((s) => s.setSwipeRightAction);
const swipeMode = useSettingsStore((s) => s.swipeMode);
const setSwipeMode = useSettingsStore((s) => s.setSwipeMode);
const hydrated = useSettingsStore((s) => s.hydrated);
const hydrate = useSettingsStore((s) => s.hydrate);
@@ -30,6 +39,19 @@ export function LayoutSettings() {
return (
<SettingsSection title="Layout" description="Tune the email list interactions for mobile.">
<View style={{ gap: spacing.sm }}>
<Text style={styles.rowLabel}>Swipe behavior</Text>
<Text style={styles.rowDescription}>
Pick instant (swipe past the threshold to fire the action) or reveal
(swipe to expose an action band, then tap to confirm).
</Text>
<RadioGroup
value={swipeMode}
onChange={(v) => setSwipeMode(v as SwipeMode)}
options={SWIPE_MODE_OPTIONS}
/>
</View>
<View style={{ gap: spacing.sm, marginTop: spacing.lg }}>
<View style={styles.row}>
<ArrowRight size={14} color={c.mutedForeground} />
<Text style={styles.rowLabel}>Swipe right (left → right)</Text>
@@ -59,10 +81,6 @@ export function LayoutSettings() {
/>
</View>
<SettingItem
label=""
description="Drag past about half a row to commit; lift earlier to cancel."
/>
</SettingsSection>
);
}
+90 -8
View File
@@ -1,14 +1,15 @@
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, Pressable, TextInput } from 'react-native';
import { ActivityIndicator, View, Text, StyleSheet, Pressable, TextInput } from 'react-native';
import { AlertTriangle, FolderSync, Mail, X } from 'lucide-react-native';
import { SettingsSection, SettingItem, Select, RadioGroup, ToggleSwitch } from './settings-section';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useSettingsStore, type ExternalContentPolicy } from '../../stores/settings-store';
import { useSettingsStore, type ArchiveMode, type ExternalContentPolicy } from '../../stores/settings-store';
import { useEmailStore } from '../../stores/email-store';
import { archiveEmails, queryEmails, getEmails } from '../../api/email';
type MailLayout = 'split' | 'focus';
type DeleteAction = 'trash' | 'permanent';
type ArchiveMode = 'single' | 'year' | 'month';
type AttachmentAction = 'preview' | 'download';
type AttachmentPosition = 'beside-sender' | 'below-header';
@@ -77,6 +78,8 @@ export function ReadingSettings() {
const removeTrustedSender = useSettingsStore((s) => s.removeTrustedSender);
const hydrated = useSettingsStore((s) => s.hydrated);
const hydrate = useSettingsStore((s) => s.hydrate);
const archiveMode = useSettingsStore((s) => s.archiveMode);
const setArchiveMode = useSettingsStore((s) => s.setArchiveMode);
const [markAsReadDelay, setMarkAsReadDelay] = useState('0');
const [deleteAction, setDeleteAction] = useState<DeleteAction>('trash');
@@ -90,7 +93,8 @@ export function ReadingSettings() {
const [mailAttachmentAction, setMailAttachmentAction] = useState<AttachmentAction>('preview');
const [attachmentPosition, setAttachmentPosition] = useState<AttachmentPosition>('beside-sender');
const [emailAlwaysLightMode, setEmailAlwaysLightMode] = useState(false);
const [archiveMode, setArchiveMode] = useState<ArchiveMode>('single');
const [reorganizing, setReorganizing] = useState(false);
const [reorganizeResult, setReorganizeResult] = useState<string | null>(null);
const [attachmentReminderEnabled, setAttachmentReminderEnabled] = useState(true);
const [attachmentReminderKeywords, setAttachmentReminderKeywords] = useState<string[]>(DEFAULT_KEYWORDS);
const [newKeyword, setNewKeyword] = useState('');
@@ -102,6 +106,61 @@ export function ReadingSettings() {
if (!hydrated) void hydrate();
}, [hydrated, hydrate]);
const handleReorganizeArchive = async () => {
const { mailboxes, fetchMailboxes } = useEmailStore.getState();
const archiveMailbox = mailboxes.find(
(m) => m.role === 'archive' || m.name.toLowerCase() === 'archive',
);
if (!archiveMailbox) {
setReorganizeResult('No archive folder found.');
return;
}
setReorganizing(true);
setReorganizeResult(null);
try {
// Drain the archive in pages so we don't OOM on huge mailboxes.
const PAGE = 100;
let position = 0;
let total = 0;
let moved = 0;
while (true) {
const { ids, total: pageTotal } = await queryEmails(archiveMailbox.id, {
position,
limit: PAGE,
});
if (position === 0) total = pageTotal;
if (ids.length === 0) break;
const list = await getEmails(ids);
const refreshed = useEmailStore.getState().mailboxes;
await archiveEmails(
list.map((e) => ({ id: e.id, receivedAt: e.receivedAt })),
archiveMailbox.id,
archiveMode,
refreshed,
);
moved += list.length;
// Newly-created year/month folders need to be visible to the next batch
// so we don't try to create the same folder twice.
await fetchMailboxes();
// Items just got moved out of the root archive view; the next page
// starts again at position 0 of the now-shorter list.
if (ids.length < PAGE) break;
}
setReorganizeResult(`Moved ${moved} of ${total} emails.`);
} catch (err) {
setReorganizeResult(err instanceof Error ? err.message : 'Reorganize failed.');
} finally {
setReorganizing(false);
}
};
return (
<SettingsSection title="Email Behavior" description="How your inbox behaves day to day.">
<SettingItem label="Mark as Read" description="When to flag an email as read.">
@@ -148,10 +207,25 @@ export function ReadingSettings() {
]}
/>
{archiveMode !== 'single' && (
<Pressable style={styles.inlineBtn}>
<FolderSync size={14} color={c.text} />
<Text style={styles.inlineBtnText}>Reorganize existing archive</Text>
</Pressable>
<>
<Pressable
style={[styles.inlineBtn, reorganizing && styles.inlineBtnDisabled]}
onPress={reorganizing ? undefined : handleReorganizeArchive}
disabled={reorganizing}
>
{reorganizing ? (
<ActivityIndicator size="small" color={c.text} />
) : (
<FolderSync size={14} color={c.text} />
)}
<Text style={styles.inlineBtnText}>
{reorganizing ? 'Reorganizing…' : 'Reorganize existing archive'}
</Text>
</Pressable>
{reorganizeResult && (
<Text style={styles.reorganizeResult}>{reorganizeResult}</Text>
)}
</>
)}
<View style={styles.divider} />
</View>
@@ -327,6 +401,14 @@ function makeStyles(c: ThemePalette) {
...typography.body,
color: c.text,
},
inlineBtnDisabled: {
opacity: 0.6,
},
reorganizeResult: {
...typography.caption,
color: c.mutedForeground,
marginTop: spacing.xs,
},
subLabel: {
...typography.bodyMedium,
color: c.text,
-114
View File
@@ -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 &lt;script&gt;alert(1)&lt;/script&gt;</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('![pic.png](cid:abc@x)');
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:');
});
});
+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']);
});
});
-175
View File
@@ -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}`;
}
+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;
+41 -2
View File
@@ -1,3 +1,4 @@
import { Platform } from 'react-native';
import { File, Paths } from 'expo-file-system';
import * as Sharing from 'expo-sharing';
import { jmapClient } from '../api/jmap-client';
@@ -11,6 +12,44 @@ 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}`;
}
// On Android, sharing a `file://` URI from the cache directory throws a
// FileUriExposedException on modern OS versions; the system requires a
// FileProvider-backed `content://` URI instead. expo-file-system exposes that
// via `file.contentUri`, which is the only field iOS doesn't expose.
function shareableUri(file: { uri: string; contentUri?: string }): string {
if (Platform.OS === 'android' && file.contentUri) return file.contentUri;
return file.uri;
}
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);
const url = getDownloadUrl(blobId, filename, mimeType);
const downloaded = await File.downloadFileAsync(url, dest, {
headers: { Authorization: authHeader() },
idempotent: true,
});
if (!(await Sharing.isAvailableAsync())) {
throw new Error('Sharing is not available on this device');
}
await Sharing.shareAsync(shareableUri(downloaded), {
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() } });
@@ -25,15 +64,15 @@ function safeFilename(subject: string | undefined): string {
export async function shareEmailEml(blobId: string, subject?: string): Promise<void> {
const dest = new File(Paths.cache, safeFilename(subject));
if (dest.exists) dest.delete();
const url = getDownloadUrl(blobId, dest.name, RFC822);
const downloaded = await File.downloadFileAsync(url, dest, {
headers: { Authorization: authHeader() },
idempotent: true,
});
if (!(await Sharing.isAvailableAsync())) {
throw new Error('Sharing is not available on this device');
}
await Sharing.shareAsync(downloaded.uri, {
await Sharing.shareAsync(shareableUri(downloaded), {
mimeType: RFC822,
dialogTitle: 'Share email',
UTI: 'public.email-message',
+68 -5
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>;
@@ -129,7 +144,11 @@ async function pollVerificationCode(
relayBaseUrl: string,
subscriptionId: string,
): Promise<string> {
const timeoutAt = Date.now() + 20_000;
// Stalwart per-account rate-limits PushVerification posts (default 60s).
// If there are leftover unverified subscriptions on the account, our new
// one queues up behind them - so we wait long enough to clear at least one
// verify window even in the unlucky case.
const timeoutAt = Date.now() + 75_000;
let delay = 400;
while (Date.now() < timeoutAt) {
const res = await fetch(
@@ -186,20 +205,41 @@ 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 existingSubs = await listPushSubscriptions().catch(() => []);
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 = existingSubs.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);
}
// Reap any leftover Stalwart subscriptions still bound to this device.
// These pile up when a previous enable attempt failed mid-flow (verify
// race, network blip, app killed). Stalwart per-account rate-limits
// PushVerification posts, so leaving stragglers around blocks the new
// one's verification - the symptom is a confusing "code does not match".
const stragglers = existingSubs.filter(
(s) => s.deviceClientId === deviceClientId && s.id !== storedServerId,
);
for (const s of stragglers) {
await destroyPushSubscription(s.id).catch(() => undefined);
}
const serverAssignedId = await createPushSubscription({
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 +250,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
+461 -238
View File
@@ -1,19 +1,26 @@
import React from 'react';
import {
View, Text, StyleSheet, TextInput, Pressable, ScrollView,
KeyboardAvoidingView, Platform, ActivityIndicator, Alert,
type NativeSyntheticEvent, type TextInputSelectionChangeEventData,
Keyboard, Dimensions, Platform, ActivityIndicator, Alert, Modal,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { SafeAreaView, useSafeAreaInsets } 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 { File as FsFile } from 'expo-file-system';
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 +33,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 +83,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 +183,67 @@ 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 insets = useSafeAreaInsets();
// Track the visible keyboard obstruction so the format bar stays above it.
// On Android edge-to-edge, the IME-inset reported by `keyboardDidShow` is
// measured from the top of the gesture-nav bar rather than from the true
// screen bottom, so we derive obstruction height from `Dimensions.screen`
// instead of trusting `endCoordinates.height` directly. The same trick
// works on iOS for the QuickType / autofill / dictation strip.
const [kbObstruction, setKbObstruction] = React.useState(0);
React.useEffect(() => {
const recompute = (endY: number) => {
const screenH = Dimensions.get('screen').height;
setKbObstruction(Math.max(0, screenH - endY));
};
const subs =
Platform.OS === 'ios'
? [
Keyboard.addListener('keyboardWillChangeFrame', (e) => {
recompute(e.endCoordinates?.screenY ?? Number.MAX_SAFE_INTEGER);
}),
]
: [
Keyboard.addListener('keyboardDidShow', (e) => {
recompute(e.endCoordinates?.screenY ?? Number.MAX_SAFE_INTEGER);
}),
Keyboard.addListener('keyboardDidHide', () => setKbObstruction(0)),
];
return () => {
for (const s of subs) s.remove();
};
}, []);
// When the keyboard is up it covers the bottom safe area, so we don't
// need to add it on top — pad by whichever is larger.
const bottomPad = Math.max(kbObstruction, insets.bottom);
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 +294,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 +386,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 +409,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 +447,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 = `![${removed.name}](cid:${removed.cid})`;
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 +493,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![${asset.name}](cid:${cid})\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 pickPhotoAttachments = async () => {
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!perm.granted) {
Alert.alert(
@@ -447,117 +530,177 @@ 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 pickFileAttachment = async () => {
// pickFileAsync's return type is the lower-level FileSystemFile, not the
// public `File` wrapper. Both expose the fields we need (uri/name/size/type).
type PickedFile = { uri: string; name?: string; size?: number; type?: string };
let picked: PickedFile | undefined;
try {
const result = await FsFile.pickFileAsync();
picked = (Array.isArray(result) ? result[0] : result) as PickedFile;
} catch (e) {
// The picker rejects with PickerCancelledException when the user backs
// out — treat that as a no-op rather than surfacing an error.
const message = e instanceof Error ? e.message : String(e);
if (/cancel/i.test(message)) return;
Alert.alert(t('email_composer.attach', 'Attach'), message);
return;
}
if (!picked) return;
const type = picked.type || 'application/octet-stream';
const name = picked.name || `attachment-${Date.now()}`;
const entry = addUploadEntry({
name,
type,
size: picked.size ?? 0,
uri: picked.uri,
inline: false,
});
void startUpload(entry);
};
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 pickAttachment = () => {
Alert.alert(
t('email_composer.attach', 'Attach'),
undefined,
[
{
text: t('email_composer.attach_photos', 'Photos & Videos'),
onPress: () => { void pickPhotoAttachments(); },
},
{
text: t('email_composer.attach_files', 'Files'),
onPress: () => { void pickFileAttachment(); },
},
{ text: t('email_composer.cancel', 'Cancel'), style: 'cancel' },
],
);
};
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 +709,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,
@@ -593,7 +736,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
: 'email_composer.new_message';
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
<Pressable onPress={onClose} style={styles.headerBtn}>
<X size={22} color={c.text} />
@@ -603,7 +746,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}
>
@@ -631,11 +774,12 @@ export default function ComposeScreen({ route, navigation }: Props) {
</View>
</View>
<KeyboardAvoidingView
style={styles.flex}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView style={styles.flex} keyboardShouldPersistTaps="handled">
<View style={[styles.flex, { paddingBottom: bottomPad }]}>
<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 +884,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 +898,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>
</View>
<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 +1011,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 +1144,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 +1164,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
+40 -4
View File
@@ -11,6 +11,8 @@ import { useColors } from '../theme/colors';
import SidebarDrawer from '../components/SidebarDrawer';
import SenderAvatar from '../components/SenderAvatar';
import { SwipeableRow } from '../components/SwipeableRow';
import { MoveSheet } from '../components/MoveSheet';
import { UndoSnackbar } from '../components/UndoSnackbar';
import { OfflineBanner } from '../components/OfflineBanner';
import { useNetworkStore } from '../stores/network-store';
import { useEmailStore, type EmailFilters } from '../stores/email-store';
@@ -33,6 +35,10 @@ function isStarred(email: Email): boolean {
return !!email.keywords?.$flagged;
}
function isPinned(email: Email): boolean {
return !!email.keywords?.$important;
}
function formatRelativeTime(date: Date): string {
const now = Date.now();
const diffMs = now - date.getTime();
@@ -164,11 +170,14 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
const markRead = useEmailStore((s) => s.markRead);
const markUnread = useEmailStore((s) => s.markUnread);
const toggleStar = useEmailStore((s) => s.toggleStar);
const togglePin = useEmailStore((s) => s.togglePin);
const deleteEmailAction = useEmailStore((s) => s.deleteEmail);
const moveToMailboxAction = useEmailStore((s) => s.moveToMailbox);
const archiveEmailAction = useEmailStore((s) => s.archiveEmail);
const swipeLeftAction = useSettingsStore((s) => s.swipeLeftAction);
const swipeRightAction = useSettingsStore((s) => s.swipeRightAction);
const swipeMode = useSettingsStore((s) => s.swipeMode);
const networkOnline = useNetworkStore((s) => s.online);
const archiveMailboxId = React.useMemo(
@@ -184,6 +193,9 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
[mailboxes],
);
// Move-to-folder picker triggered by the swipe action.
const [pendingMoveId, setPendingMoveId] = React.useState<string | null>(null);
// Selection state
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(new Set());
const selectionMode = selectedIds.size > 0;
@@ -225,7 +237,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
switch (action) {
case 'archive':
if (archiveMailboxId && currentMailboxId !== archiveMailboxId) {
void moveToMailboxAction(id, currentMailboxId, archiveMailboxId);
void archiveEmailAction(id);
}
break;
case 'delete':
@@ -245,10 +257,17 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
case 'star':
void toggleStar(id, !isStarred(email));
break;
case 'pin':
void togglePin(id, !isPinned(email));
break;
case 'move':
setPendingMoveId(id);
break;
}
}, [
currentMailboxId, archiveMailboxId, trashMailboxId, junkMailboxId,
moveToMailboxAction, deleteEmailAction, markRead, markUnread, toggleStar,
moveToMailboxAction, archiveEmailAction, deleteEmailAction,
markRead, markUnread, toggleStar, togglePin,
]);
const renderEmailRow = React.useCallback(
@@ -256,7 +275,8 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
<SwipeableRow
leftAction={selectionMode ? 'none' : swipeLeftAction}
rightAction={selectionMode ? 'none' : swipeRightAction}
context={{ unread: isUnread(item), starred: isStarred(item) }}
mode={swipeMode}
context={{ unread: isUnread(item), starred: isStarred(item), pinned: isPinned(item) }}
onAction={(action) => handleSwipeAction(item.id, action)}
>
<EmailRow
@@ -268,7 +288,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
/>
</SwipeableRow>
),
[selectedIds, selectionMode, handleRowPress, toggleSelect, swipeLeftAction, swipeRightAction, handleSwipeAction],
[selectedIds, selectionMode, handleRowPress, toggleSelect, swipeLeftAction, swipeRightAction, swipeMode, handleSwipeAction],
);
const clearSelection = React.useCallback(() => {
@@ -809,6 +829,22 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
/>
);
})()}
<MoveSheet
visible={pendingMoveId !== null}
onClose={() => setPendingMoveId(null)}
mailboxes={mailboxes}
currentMailboxId={currentMailboxId}
onPick={(toId) => {
const id = pendingMoveId;
setPendingMoveId(null);
if (id && currentMailboxId && toId !== currentMailboxId) {
void moveToMailboxAction(id, currentMailboxId, toId);
}
}}
/>
<UndoSnackbar />
</SafeAreaView>
);
}
+85 -148
View File
@@ -9,33 +9,21 @@ import {
ArrowLeft, Star, Trash2, MoreVertical, Reply, ReplyAll, Forward,
ChevronLeft, ChevronRight, Paperclip, Archive, Mail, MailOpen,
FolderInput, ShieldAlert, ShieldCheck, X, Check,
Inbox, Send, File as FileIcon, Ban, Folder, Code, Download, Tag,
Code, Download, Tag,
} from 'lucide-react-native';
import type { LucideIcon } from 'lucide-react-native';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import EmailBodyView from '../components/EmailBodyView';
import SenderAvatar from '../components/SenderAvatar';
import { MoveSheet } from '../components/MoveSheet';
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';
import type { Email, Mailbox } from '../api/types';
import type { RootStackParamList } from '../navigation/types';
function moveTargetIcon(role: string | null | undefined, name: string): LucideIcon {
const lower = name.toLowerCase();
if (role === 'inbox' || lower.includes('inbox')) return Inbox;
if (role === 'sent' || lower.includes('sent')) return Send;
if (role === 'drafts' || lower.includes('draft')) return FileIcon;
if (role === 'trash' || lower.includes('trash') || lower.includes('deleted')) return Trash2;
if (role === 'junk' || role === 'spam' || lower.includes('junk') || lower.includes('spam')) return Ban;
if (role === 'archive' || lower.includes('archive')) return Archive;
return Folder;
}
type Props = NativeStackScreenProps<RootStackParamList, 'EmailThread'>;
function formatHeaderDate(iso: string): string {
@@ -71,6 +59,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
const markRead = useEmailStore((s) => s.markRead);
const deleteEmail = useEmailStore((s) => s.deleteEmail);
const moveToMailbox = useEmailStore((s) => s.moveToMailbox);
const archiveEmailAction = useEmailStore((s) => s.archiveEmail);
const mailboxes = useEmailStore((s) => s.mailboxes);
const currentMailboxId = useEmailStore((s) => s.currentMailboxId);
const emails = useEmailStore((s) => s.emails);
@@ -92,6 +81,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);
@@ -183,7 +189,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
const onArchive = () => {
if (!email || !currentMailboxId || !archiveMailbox) return;
if (currentMailboxId === archiveMailbox.id) return;
void moveToMailbox(email.id, currentMailboxId, archiveMailbox.id);
void archiveEmailAction(email.id);
navigation.goBack();
};
@@ -350,16 +356,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>
)}
@@ -440,7 +481,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
}}
/>
<MoveMenuSheet
<MoveSheet
visible={moveMenuOpen}
onClose={() => setMoveMenuOpen(false)}
mailboxes={mailboxes}
@@ -673,107 +714,6 @@ function TagMenuSheet({ visible, onClose, keywords, activeKeywords, onToggle }:
);
}
interface MoveMenuSheetProps {
visible: boolean;
onClose: () => void;
mailboxes: Mailbox[];
currentMailboxId: string | null;
onPick: (id: string) => void;
}
function MoveMenuSheet({ visible, onClose, mailboxes, currentMailboxId, onPick }: MoveMenuSheetProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
const slideY = React.useRef(new Animated.Value(500)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose });
React.useEffect(() => {
if (visible) {
Animated.parallel([
Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 1, duration: 220, useNativeDriver: true }),
]).start();
} else {
Animated.parallel([
Animated.timing(slideY, { toValue: 500, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 0, duration: 180, useNativeDriver: true }),
]).start();
}
}, [visible, slideY, overlayOpacity]);
const visibleNodes = React.useMemo(() => {
const tree = buildMailboxTree(mailboxes);
const expanded = new Set<string>();
const collect = (nodes: MailboxNode[]) => {
for (const n of nodes) {
if (n.children.length > 0) {
expanded.add(n.id);
collect(n.children);
}
}
};
collect(tree);
return flattenVisible(tree, expanded);
}, [mailboxes]);
return (
<Modal visible={visible} transparent animationType="none" onRequestClose={onClose} statusBarTranslucent>
<Animated.View style={[styles.sheetOverlay, { opacity: overlayOpacity }]}>
<Pressable style={styles.sheetOverlayPress} onPress={onClose} />
</Animated.View>
<Animated.View
style={[
styles.sheet,
styles.sheetTall,
{ paddingBottom: Math.max(insets.bottom, spacing.md), transform: [{ translateY: slideY }] },
]}
>
<View {...dragHandlers}>
<View style={styles.sheetHandleHit}>
<View style={styles.sheetHandle} />
</View>
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>Move to folder</Text>
<Pressable onPress={onClose} hitSlop={8} style={styles.sheetClose}>
<X size={18} color={c.textSecondary} />
</Pressable>
</View>
</View>
<ScrollView>
{visibleNodes.map((node) => {
const Icon = moveTargetIcon(node.role, node.name);
const isCurrent = node.id === currentMailboxId;
const canTarget = node.myRights?.mayAddItems !== false && !isCurrent;
return (
<Pressable
key={node.id}
onPress={canTarget ? () => onPick(node.id) : undefined}
disabled={!canTarget}
style={({ pressed }) => [
styles.moveRow,
pressed && canTarget && styles.moveRowPressed,
{ paddingLeft: spacing.lg + node.depth * 16 },
]}
>
<Icon size={16} color={canTarget ? c.textSecondary : c.textMuted} />
<Text
style={[styles.moveRowLabel, !canTarget && styles.moveRowLabelDisabled]}
numberOfLines={1}
>
{node.name}
</Text>
{isCurrent && <Check size={14} color={c.textMuted} />}
</Pressable>
);
})}
</ScrollView>
</Animated.View>
</Modal>
);
}
function MoreMenuItem({
icon, label, onPress, trailing,
}: { icon: React.ReactNode; label: string; onPress?: () => void; trailing?: React.ReactNode }) {
@@ -972,6 +912,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 +925,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: {
@@ -1036,9 +994,6 @@ function makeStyles(c: ThemePalette) {
borderColor: c.border,
paddingTop: spacing.sm,
},
sheetTall: {
maxHeight: '75%',
},
sheetHandleHit: {
alignItems: 'center',
paddingTop: spacing.xs,
@@ -1092,23 +1047,5 @@ function makeStyles(c: ThemePalette) {
flex: 1,
},
// Move folder row
moveRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingRight: spacing.lg,
paddingVertical: spacing.sm + 2,
minHeight: 44,
},
moveRowPressed: { backgroundColor: c.surfaceHover },
moveRowLabel: {
...typography.body,
color: c.text,
flex: 1,
},
moveRowLabelDisabled: {
color: c.textMuted,
},
});
}
+9
View File
@@ -7,10 +7,19 @@ vi.mock('../../api/email', () => ({
getFullEmail: vi.fn(),
setEmailKeywords: vi.fn(),
moveEmail: vi.fn(),
archiveEmails: vi.fn(),
restoreEmailMailboxes: vi.fn(),
deleteEmail: vi.fn(),
searchEmails: vi.fn(),
}));
// settings-store transitively pulls in jmap-client / expo-secure-store, which
// trip on react-native's Flow-typed entrypoint under vitest. The store only
// reads archiveMode in archiveEmail, so a minimal stub is enough.
vi.mock('../settings-store', () => ({
useSettingsStore: { getState: () => ({ archiveMode: 'single' }) },
}));
import * as emailApi from '../../api/email';
import { useEmailStore } from '../email-store';
+145
View File
@@ -9,10 +9,13 @@ import {
getFullEmail,
setEmailKeywords,
moveEmail,
archiveEmails as apiArchiveEmails,
deleteEmail as apiDeleteEmail,
restoreEmailMailboxes,
searchEmails as apiSearchEmails,
} from '../api/email';
import { toWildcardQuery } from '../lib/search-utils';
import { useSettingsStore } from './settings-store';
export interface EmailFilters {
from?: string;
@@ -25,6 +28,19 @@ export interface EmailFilters {
isUnread?: boolean;
}
// Snapshot of an action that can still be reversed via the undo snackbar.
// We store the full email object so undo can re-insert it into the visible list
// optimistically without waiting for a refetch.
export interface UndoEntry {
kind: 'archive' | 'delete' | 'move' | 'spam';
/** Human-readable label shown in the snackbar (e.g. "Email archived"). */
label: string;
/** Time the entry was created - the snackbar uses this to drive its timer. */
createdAt: number;
/** Each item is one email's pre-action mailboxIds, used to restore it. */
items: Array<{ email: Email; originalMailboxIds: Record<string, boolean> }>;
}
export interface EmailState {
mailboxes: Mailbox[];
currentMailboxId: string | null;
@@ -34,6 +50,7 @@ export interface EmailState {
error: string | null;
searchQuery: string;
filters: EmailFilters;
pendingUndo: UndoEntry | null;
fetchMailboxes: () => Promise<void>;
selectMailbox: (mailboxId: string) => Promise<void>;
@@ -43,8 +60,12 @@ export interface EmailState {
markRead: (emailId: string) => Promise<void>;
markUnread: (emailId: string) => Promise<void>;
toggleStar: (emailId: string, starred: boolean) => Promise<void>;
togglePin: (emailId: string, pinned: boolean) => Promise<void>;
moveToMailbox: (emailId: string, fromMailboxId: string, toMailboxId: string) => Promise<void>;
archiveEmail: (emailId: string) => Promise<void>;
deleteEmail: (emailId: string, trashMailboxId: string, currentMailboxId: string) => Promise<void>;
undoLast: () => Promise<void>;
clearUndo: () => void;
searchEmails: (query: string) => Promise<Email[]>;
setSearchQuery: (query: string) => void;
setFilters: (filters: EmailFilters) => void;
@@ -102,6 +123,7 @@ export const useEmailStore = create<EmailState>()(
error: null,
searchQuery: '',
filters: {},
pendingUndo: null,
fetchMailboxes: async () => {
try {
@@ -115,6 +137,8 @@ export const useEmailStore = create<EmailState>()(
selectMailbox: async (mailboxId) => {
// Reset search/filters when switching mailbox - matches webmail behavior.
// Switching mailboxes invalidates any pending undo: the snackbar would be
// stale and the restored email would re-appear in a different view.
set({
currentMailboxId: mailboxId,
loading: true,
@@ -123,6 +147,7 @@ export const useEmailStore = create<EmailState>()(
totalEmails: 0,
searchQuery: '',
filters: {},
pendingUndo: null,
});
try {
const filter = buildJmapFilter(mailboxId, '', {});
@@ -232,16 +257,136 @@ export const useEmailStore = create<EmailState>()(
});
},
togglePin: async (emailId, pinned) => {
const email = get().emails.find((e) => e.id === emailId);
if (!email) return;
const keywords = { ...email.keywords };
if (pinned) {
keywords.$important = true;
} else {
delete keywords.$important;
}
await setEmailKeywords(emailId, keywords);
set({
emails: get().emails.map((e) =>
e.id === emailId ? { ...e, keywords } : e,
),
});
},
moveToMailbox: async (emailId, fromMailboxId, toMailboxId) => {
const email = get().emails.find((e) => e.id === emailId);
const original = email ? { ...email.mailboxIds } : null;
await moveEmail(emailId, fromMailboxId, toMailboxId);
set({ emails: get().emails.filter((e) => e.id !== emailId) });
if (email && original) {
const targetName = get().mailboxes.find((m) => m.id === toMailboxId)?.name;
set({
pendingUndo: {
kind: 'move',
label: targetName ? `Email moved to ${targetName}` : 'Email moved',
createdAt: Date.now(),
items: [{ email, originalMailboxIds: original }],
},
});
}
},
archiveEmail: async (emailId) => {
const { emails, mailboxes } = get();
const email = emails.find((e) => e.id === emailId);
if (!email) return;
const archiveMailbox = mailboxes.find(
(m) => m.role === 'archive' || m.name.toLowerCase() === 'archive',
);
if (!archiveMailbox) return;
if (email.mailboxIds?.[archiveMailbox.id]) return;
const mode = useSettingsStore.getState().archiveMode;
const original = { ...email.mailboxIds };
await apiArchiveEmails(
[{ id: email.id, receivedAt: email.receivedAt }],
archiveMailbox.id,
mode,
mailboxes,
);
set({
emails: get().emails.filter((e) => e.id !== emailId),
pendingUndo: {
kind: 'archive',
label: 'Email archived',
createdAt: Date.now(),
items: [{ email, originalMailboxIds: original }],
},
});
// Auto-sort modes may have created new year/month folders - refresh the
// mailbox list so the sidebar picks them up on the next render.
if (mode !== 'single') {
void get().fetchMailboxes();
}
},
deleteEmail: async (emailId, trashMailboxId, currentMailboxId) => {
const email = get().emails.find((e) => e.id === emailId);
const original = email ? { ...email.mailboxIds } : null;
const isPermanent = currentMailboxId === trashMailboxId;
await apiDeleteEmail(emailId, trashMailboxId, currentMailboxId);
set({ emails: get().emails.filter((e) => e.id !== emailId) });
// Permanent destroy can't be undone - skip the snackbar so we don't
// promise an undo we can't deliver.
if (email && original && !isPermanent) {
set({
pendingUndo: {
kind: 'delete',
label: 'Email moved to Trash',
createdAt: Date.now(),
items: [{ email, originalMailboxIds: original }],
},
});
}
},
undoLast: async () => {
const entry = get().pendingUndo;
if (!entry) return;
set({ pendingUndo: null });
try {
await restoreEmailMailboxes(
entry.items.map((it) => ({ id: it.email.id, mailboxIds: it.originalMailboxIds })),
);
} catch (err) {
set({ error: err instanceof Error ? err.message : 'Undo failed' });
return;
}
// Re-insert each restored email into the visible list if its original
// mailboxIds include the current view. Server is the source of truth for
// ordering, but local re-insertion gives the user instant feedback.
const { currentMailboxId, emails } = get();
if (currentMailboxId) {
const restored = entry.items
.filter((it) => it.originalMailboxIds[currentMailboxId])
.map((it) => ({ ...it.email, mailboxIds: it.originalMailboxIds }));
if (restored.length > 0) {
const merged = [...restored, ...emails].sort(
(a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime(),
);
set({ emails: merged });
}
}
},
clearUndo: () => set({ pendingUndo: null }),
searchEmails: async (query) => {
const ids = await apiSearchEmails(query);
if (ids.length === 0) return [];
+24 -1
View File
@@ -10,7 +10,17 @@ export type Density = 'extra-compact' | 'compact' | 'regular' | 'comfortable';
const STORAGE_KEY = 'webmail:settings:v1';
export type SwipeAction = 'none' | 'archive' | 'delete' | 'spam' | 'read' | 'star';
export type SwipeAction =
| 'none'
| 'archive'
| 'delete'
| 'spam'
| 'read'
| 'star'
| 'pin'
| 'move';
export type SwipeMode = 'instant' | 'reveal';
export type ArchiveMode = 'single' | 'year' | 'month';
interface PersistedSettings {
externalContentPolicy: ExternalContentPolicy;
@@ -31,6 +41,9 @@ interface PersistedSettings {
// Layout / list interactions
swipeLeftAction: SwipeAction;
swipeRightAction: SwipeAction;
swipeMode: SwipeMode;
// Archive
archiveMode: ArchiveMode;
}
const DEFAULT_PERSISTED: PersistedSettings = {
@@ -49,6 +62,8 @@ const DEFAULT_PERSISTED: PersistedSettings = {
attachmentReminderKeywords: ['attached', 'attachment', 'attaching', 'enclosed'],
swipeLeftAction: 'archive',
swipeRightAction: 'read',
swipeMode: 'instant',
archiveMode: 'single',
};
export interface SettingsState extends PersistedSettings {
@@ -73,6 +88,8 @@ export interface SettingsState extends PersistedSettings {
setAttachmentReminderKeywords: (keywords: string[]) => void;
setSwipeLeftAction: (action: SwipeAction) => void;
setSwipeRightAction: (action: SwipeAction) => void;
setSwipeMode: (mode: SwipeMode) => void;
setArchiveMode: (mode: ArchiveMode) => void;
addTrustedSender: (email: string) => void;
removeTrustedSender: (email: string) => void;
isSenderTrusted: (email: string) => boolean;
@@ -96,6 +113,8 @@ function snapshot(state: SettingsState): PersistedSettings {
attachmentReminderKeywords: state.attachmentReminderKeywords,
swipeLeftAction: state.swipeLeftAction,
swipeRightAction: state.swipeRightAction,
swipeMode: state.swipeMode,
archiveMode: state.archiveMode,
};
}
@@ -144,6 +163,8 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
attachmentReminderKeywords: Array.isArray(parsed.attachmentReminderKeywords) ? parsed.attachmentReminderKeywords : DEFAULT_PERSISTED.attachmentReminderKeywords,
swipeLeftAction: parsed.swipeLeftAction ?? DEFAULT_PERSISTED.swipeLeftAction,
swipeRightAction: parsed.swipeRightAction ?? DEFAULT_PERSISTED.swipeRightAction,
swipeMode: parsed.swipeMode ?? DEFAULT_PERSISTED.swipeMode,
archiveMode: parsed.archiveMode ?? DEFAULT_PERSISTED.archiveMode,
hydrated: true,
});
return;
@@ -168,6 +189,8 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
setAttachmentReminderKeywords: (keywords) => { set({ attachmentReminderKeywords: keywords }); persist(snapshot(get())); },
setSwipeLeftAction: (action) => { set({ swipeLeftAction: action }); persist(snapshot(get())); },
setSwipeRightAction: (action) => { set({ swipeRightAction: action }); persist(snapshot(get())); },
setSwipeMode: (mode) => { set({ swipeMode: mode }); persist(snapshot(get())); },
setArchiveMode: (mode) => { set({ archiveMode: mode }); persist(snapshot(get())); },
addTrustedSender: (email) => {
const normalized = email.toLowerCase().trim();