feat: enhance email composition with markdown support and attachment handling

- Implement markdown formatting in the email composer, allowing users to use bold, italic, lists, and blockquotes.
- Add functionality to upload and manage attachments, including inline images.
- Introduce a new compose-format utility for converting markdown to HTML.
- Update the ComposeScreen to handle attachments and formatting actions.
- Refactor ContactDetailScreen and EmailListScreen for improved code clarity and performance.
- Add tests for markdown formatting and selection wrapping functionalities.
This commit is contained in:
Linus Rath
2026-04-27 13:55:16 +02:00
parent 9a278f1be3
commit 346a71de84
20 changed files with 905 additions and 153 deletions
+3
View File
@@ -454,6 +454,9 @@
"discard_draft_title": "Discard draft?",
"discard_draft_confirm": "You have unsaved changes. Do you want to discard this draft?",
"saving": "Saving...",
"sending": "Sending...",
"add_link": "Add link",
"link_url_prompt": "Enter the URL",
"draft_saved": "Draft saved",
"save_failed": "Failed to save",
"to_placeholder": "Recipient email addresses",
+28 -11
View File
@@ -164,6 +164,15 @@ export async function queryEmailsByFilter(
return res.methodResponses[0][1].ids;
}
export interface OutgoingAttachment {
blobId: string;
type: string;
name: string;
size?: number;
disposition?: 'attachment' | 'inline';
cid?: string;
}
export async function sendEmail(
email: {
from: EmailAddress[];
@@ -173,7 +182,7 @@ export async function sendEmail(
subject: string;
htmlBody?: string;
textBody?: string;
attachments?: Array<{ blobId: string; type: string; name: string }>;
attachments?: OutgoingAttachment[];
inReplyTo?: string;
references?: string;
},
@@ -191,21 +200,29 @@ export async function sendEmail(
keywords: { $seen: true },
};
const bodyValues: Record<string, { value: string }> = {};
if (email.htmlBody) {
emailCreate.htmlBody = [{ partId: 'html', type: 'text/html' }];
emailCreate.bodyValues = { html: { value: email.htmlBody } };
} else {
emailCreate.textBody = [{ partId: 'text', type: 'text/plain' }];
emailCreate.bodyValues = { text: { value: email.textBody ?? '' } };
bodyValues.html = { value: email.htmlBody };
}
if (email.textBody || !email.htmlBody) {
emailCreate.textBody = [{ partId: 'text', type: 'text/plain' }];
bodyValues.text = { value: email.textBody ?? '' };
}
emailCreate.bodyValues = bodyValues;
if (email.attachments?.length) {
emailCreate.attachments = email.attachments.map((a) => ({
blobId: a.blobId,
type: a.type,
name: a.name,
disposition: 'attachment',
}));
emailCreate.attachments = email.attachments.map((a) => {
const part: Record<string, unknown> = {
blobId: a.blobId,
type: a.type,
name: a.name,
disposition: a.disposition ?? 'attachment',
};
if (a.size != null) part.size = a.size;
if (a.cid) part.cid = a.cid;
return part;
});
}
if (email.inReplyTo) {
+1 -1
View File
@@ -9,7 +9,7 @@ type ButtonSize = 'sm' | 'md' | 'lg' | 'icon';
interface ButtonProps {
variant?: ButtonVariant;
size?: ButtonSize;
children: React.ReactNode;
children?: React.ReactNode;
onPress?: () => void;
disabled?: boolean;
loading?: boolean;
+5 -5
View File
@@ -49,17 +49,17 @@ function iconFor(
return Folder;
}
const ROLE_COLOR: Record<string, string> = {
const ROLE_COLOR_FIXED: Record<string, string> = {
inbox: '#60a5fa',
sent: '#4ade80',
drafts: '#a78bfa',
trash: c.textMuted,
junk: '#f87171',
archive: '#fbbf24',
};
function iconColor(role: string | null | undefined, isSelected: boolean): string {
if (role && ROLE_COLOR[role]) return ROLE_COLOR[role];
function iconColor(c: ThemePalette, role: string | null | undefined, isSelected: boolean): string {
if (role === 'trash') return c.textMuted;
if (role && ROLE_COLOR_FIXED[role]) return ROLE_COLOR_FIXED[role];
return isSelected ? c.text : c.textSecondary;
}
@@ -450,7 +450,7 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
return (
<SidebarRow
key={node.id}
icon={<Icon size={16} color={iconColor(node.role, isSelected)} />}
icon={<Icon size={16} color={iconColor(c, node.role, isSelected)} />}
label={node.name}
depth={node.depth}
isSelected={isSelected}
+1 -1
View File
@@ -174,6 +174,6 @@ function makeStyles(c: ThemePalette) {
paddingHorizontal: 16,
},
bandInner: { alignItems: 'center', gap: 4 },
bandLabel: { color: '#fff', fontWeight: '600', ...typography.caption },
bandLabel: { ...typography.caption, color: '#fff', fontWeight: '600' },
});
}
+3 -1
View File
@@ -1,5 +1,6 @@
import React from 'react';
import { View, Text, StyleSheet, Pressable } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Download, X } from 'lucide-react-native';
import { useUpdatesStore } from '../stores/updates-store';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
@@ -8,6 +9,7 @@ import { useColors } from '../theme/colors';
export function UpdateBanner(): React.ReactElement | null {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const insets = useSafeAreaInsets();
const cachedLatest = useUpdatesStore((s) => s.cachedLatest);
const dismissedTag = useUpdatesStore((s) => s.dismissedTag);
const installing = useUpdatesStore((s) => s.installing);
@@ -20,7 +22,7 @@ export function UpdateBanner(): React.ReactElement | null {
if (!cachedLatest?.apkAsset) return null;
return (
<View style={styles.banner}>
<View style={[styles.banner, { paddingTop: spacing.md + insets.top }]}>
<Download size={16} color={c.primaryForeground} />
<View style={{ flex: 1 }}>
<Text style={styles.title}>Update available</Text>
+2 -2
View File
@@ -238,7 +238,7 @@ export function EventDetailSheet({
style={[
styles.participantDot,
{
backgroundColor: statusColor(p.participationStatus),
backgroundColor: statusColor(c, p.participationStatus),
},
]}
/>
@@ -334,7 +334,7 @@ function ActionButton({
);
}
function statusColor(status?: string): string {
function statusColor(c: ThemePalette, status?: string): string {
switch (status) {
case 'accepted':
return c.success;
+3 -3
View File
@@ -58,7 +58,7 @@ function formatSize(b: number) {
return `${(b / (1024 * 1024)).toFixed(1)} MB`;
}
function getIcon(file: SampleFile, colored: boolean, sz: number) {
function getIcon(c: ThemePalette, file: SampleFile, colored: boolean, sz: number) {
if (file.isFolder) return <Folder size={sz} color={colored ? '#60a5fa' : c.mutedForeground} />;
const ext = file.name.split('.').pop()?.toLowerCase();
switch (ext) {
@@ -96,7 +96,7 @@ function FilesPreview({ prefs }: { prefs: FilesPrefs }) {
<View style={styles.gridWrap}>
{files.map((f) => (
<View key={f.name} style={[styles.gridItem, f.hidden && { opacity: 0.5 }]}>
{prefs.showIcons ? getIcon(f, prefs.coloredIcons, 24) : <View style={{ width: 24, height: 24 }} />}
{prefs.showIcons ? getIcon(c, f, prefs.coloredIcons, 24) : <View style={{ width: 24, height: 24 }} />}
<Text style={styles.gridName} numberOfLines={1}>{f.name}</Text>
</View>
))}
@@ -114,7 +114,7 @@ function FilesPreview({ prefs }: { prefs: FilesPrefs }) {
</View>
{files.map((f) => (
<View key={f.name} style={[styles.listRow, f.hidden && { opacity: 0.5 }]}>
{prefs.showIcons && getIcon(f, prefs.coloredIcons, 14)}
{prefs.showIcons && getIcon(c, f, prefs.coloredIcons, 14)}
<Text
style={[styles.listName, f.isFolder && { fontWeight: '500' }]}
numberOfLines={1}
+10 -8
View File
@@ -20,13 +20,15 @@ interface Plugin {
forceEnabled?: boolean;
}
const STATUS_STYLE: Record<Plugin['status'], { bg: string; fg: string }> = {
installed: { bg: c.muted, fg: c.mutedForeground },
enabled: { bg: 'rgba(59, 130, 246, 0.15)', fg: '#60a5fa' },
running: { bg: 'rgba(34, 197, 94, 0.15)', fg: '#4ade80' },
disabled: { bg: c.muted, fg: c.mutedForeground },
error: { bg: 'rgba(239, 68, 68, 0.15)', fg: '#f87171' },
};
function statusStyle(c: ThemePalette, status: Plugin['status']): { bg: string; fg: string } {
switch (status) {
case 'installed': return { bg: c.muted, fg: c.mutedForeground };
case 'enabled': return { bg: 'rgba(59, 130, 246, 0.15)', fg: '#60a5fa' };
case 'running': return { bg: 'rgba(34, 197, 94, 0.15)', fg: '#4ade80' };
case 'disabled': return { bg: c.muted, fg: c.mutedForeground };
case 'error': return { bg: 'rgba(239, 68, 68, 0.15)', fg: '#f87171' };
}
}
export function PluginsSettings() {
const c = useColors();
@@ -63,7 +65,7 @@ export function PluginsSettings() {
<View style={{ gap: spacing.sm }}>
{plugins.map((plugin) => {
const expanded = expandedId === plugin.id;
const status = STATUS_STYLE[plugin.status];
const status = statusStyle(c, plugin.status);
return (
<View
key={plugin.id}
+41 -37
View File
@@ -121,6 +121,8 @@ interface AppFormProps {
}
function AppForm({ initial, onSave, onCancel }: AppFormProps) {
const c = useColors();
const formStyles = React.useMemo(() => makeFormStyles(c), [c]);
const [name, setName] = useState(initial?.name ?? '');
const [url, setUrl] = useState(initial?.url ?? '');
const [openMode, setOpenMode] = useState<'tab' | 'inline'>(initial?.openMode ?? 'tab');
@@ -254,40 +256,42 @@ function makeStyles(c: ThemePalette) {
});
}
const formStyles = StyleSheet.create({
form: {
gap: spacing.md,
padding: spacing.lg,
borderRadius: radius.md,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.muted,
},
label: { ...typography.captionMedium, color: c.text },
input: {
marginTop: 4,
paddingHorizontal: spacing.md,
paddingVertical: 8,
borderRadius: radius.sm,
backgroundColor: c.background,
borderWidth: 1,
borderColor: c.border,
color: c.text,
...typography.body,
},
modeBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: spacing.md,
paddingVertical: 8,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
},
modeBtnActive: {
borderColor: c.primary,
backgroundColor: 'rgba(59, 130, 246, 0.1)',
},
modeBtnText: { ...typography.caption, color: c.text },
});
function makeFormStyles(c: ThemePalette) {
return StyleSheet.create({
form: {
gap: spacing.md,
padding: spacing.lg,
borderRadius: radius.md,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.muted,
},
label: { ...typography.captionMedium, color: c.text },
input: {
marginTop: 4,
paddingHorizontal: spacing.md,
paddingVertical: 8,
borderRadius: radius.sm,
backgroundColor: c.background,
borderWidth: 1,
borderColor: c.border,
color: c.text,
...typography.body,
},
modeBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingHorizontal: spacing.md,
paddingVertical: 8,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
},
modeBtnActive: {
borderColor: c.primary,
backgroundColor: 'rgba(59, 130, 246, 0.1)',
},
modeBtnText: { ...typography.caption, color: c.text },
});
}
+6 -6
View File
@@ -119,18 +119,18 @@ export function SmimeSettings() {
description="Public certificates used to encrypt outgoing mail."
>
<View style={{ gap: spacing.sm }}>
{certs.map((c) => {
const expired = isExpired(c.notAfter);
{certs.map((cert) => {
const expired = isExpired(cert.notAfter);
return (
<View key={c.id} style={styles.certRow}>
<View key={cert.id} style={styles.certRow}>
<View style={styles.certLeft}>
<View style={[styles.certIcon, styles.certIconMuted]}>
<Users size={16} color={c.mutedForeground} />
</View>
<View style={{ flex: 1 }}>
<Text style={styles.certName} numberOfLines={1}>{c.email || c.subject}</Text>
<Text style={styles.certName} numberOfLines={1}>{cert.email || cert.subject}</Text>
<Text style={styles.certMeta}>
{c.issuer} · {c.source}
{cert.issuer} · {cert.source}
{expired && <Text style={styles.expiredText}> (Expired)</Text>}
</Text>
</View>
@@ -139,7 +139,7 @@ export function SmimeSettings() {
<Pressable style={styles.iconBtn}>
<Eye size={16} color={c.text} />
</Pressable>
<Pressable style={styles.iconBtn} onPress={() => deleteCert(c.id)}>
<Pressable style={styles.iconBtn} onPress={() => deleteCert(cert.id)}>
<Trash2 size={16} color={c.error} />
</Pressable>
</View>
+51 -15
View File
@@ -1,5 +1,5 @@
import React, { useEffect } from 'react';
import { View, Text, StyleSheet, Linking, Alert } from 'react-native';
import { View, Text, StyleSheet, Linking } from 'react-native';
import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
@@ -30,9 +30,8 @@ function formatProgressLabel(p: InstallProgress): string {
function installButtonLabel(
installing: boolean,
progress: InstallProgress | null,
apkAsset: unknown,
): string {
if (!installing) return apkAsset ? 'Install' : 'Open release';
if (!installing) return 'Install';
if (progress?.phase === 'installing') return 'Installing…';
if (progress?.progress != null) return `Downloading ${Math.round(progress.progress * 100)}%`;
return 'Downloading…';
@@ -62,14 +61,17 @@ export function UpdatesSettings() {
const updateAvailable = hasUpdate();
const apkAsset = cachedLatest?.apkAsset ?? null;
const apkPending = updateAvailable && !apkAsset;
useEffect(() => {
if (!apkPending) return;
const id = setInterval(() => {
void checkNow({ force: true });
}, 30_000);
return () => clearInterval(id);
}, [apkPending, checkNow]);
const onInstall = () => {
if (!apkAsset) {
if (cachedLatest?.htmlUrl) {
void Linking.openURL(cachedLatest.htmlUrl);
}
return;
}
void installLatest();
};
@@ -99,19 +101,38 @@ export function UpdatesSettings() {
</SettingItem>
<SettingItem
label={updateAvailable ? 'Update available' : 'Check for updates'}
description={updateAvailable
? `Tap install to download v${cachedLatest?.tag} and open it with Android's package installer.`
: undefined}
label={
apkPending
? 'Update building'
: updateAvailable
? 'Update available'
: 'Check for updates'
}
description={
apkPending
? `v${cachedLatest?.tag} was published, but the APK is still being built. This page will check again every 30 seconds.`
: updateAvailable
? `Tap install to download v${cachedLatest?.tag} and open it with Android's package installer.`
: undefined
}
>
{updateAvailable ? (
{apkPending ? (
<Button
variant="outline"
size="sm"
onPress={() => void checkNow({ force: true })}
disabled={checking}
>
{checking ? 'Checking…' : 'Check again'}
</Button>
) : updateAvailable ? (
<Button
variant="default"
size="sm"
onPress={onInstall}
disabled={installing}
>
{installButtonLabel(installing, installProgress, apkAsset)}
{installButtonLabel(installing, installProgress)}
</Button>
) : (
<Button
@@ -125,6 +146,21 @@ export function UpdatesSettings() {
)}
</SettingItem>
{apkPending && cachedLatest?.htmlUrl ? (
<SettingItem
label="Release page"
description="Watch the build progress or download the APK manually."
>
<Button
variant="ghost"
size="sm"
onPress={() => void Linking.openURL(cachedLatest.htmlUrl)}
>
Open on GitHub
</Button>
</SettingItem>
) : null}
{installing && installProgress ? (
<View style={styles.progressBox}>
<View style={styles.progressTrack}>
+114
View File
@@ -0,0 +1,114 @@
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:');
});
});
+175
View File
@@ -0,0 +1,175 @@
// 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}`;
}
+1 -1
View File
@@ -6,7 +6,7 @@ import { getDownloadUrl } from '../api/blob';
const RFC822 = 'message/rfc822';
function authHeader(): string {
const h = (jmapClient as { authHeader?: string }).authHeader;
const h = (jmapClient as unknown as { authHeader?: string }).authHeader;
if (!h) throw new Error('Not connected');
return h;
}
+432 -47
View File
@@ -2,18 +2,21 @@ import React from 'react';
import {
View, Text, StyleSheet, TextInput, Pressable, ScrollView,
KeyboardAvoidingView, Platform, ActivityIndicator, Alert,
type NativeSyntheticEvent, type TextInputSelectionChangeEventData,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import {
X, Send, Paperclip, ChevronDown, Bold, Italic, List,
Link2, Image as ImageIcon, MoreHorizontal,
X, Send, Paperclip, ChevronDown, Bold, Italic, List, ListOrdered,
Link2, Image as ImageIcon, Quote, FileText,
} from 'lucide-react-native';
import * as ImagePicker from 'expo-image-picker';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { Button } from '../components';
import { useEmailStore } from '../stores/email-store';
import { useContactsStore } from '../stores/contacts-store';
import { useLocaleStore } from '../stores/locale-store';
import { isGroup } from '../lib/contact-utils';
import {
getContactDisplayName,
@@ -21,7 +24,14 @@ import {
matchesContactSearch,
} from '../lib/contact-utils';
import { getIdentities } from '../api/identity';
import { sendEmail } from '../api/email';
import { sendEmail, type OutgoingAttachment } from '../api/email';
import { uploadBlob } from '../api/blob';
import {
markdownToHtml,
wrapSelection,
toggleLinePrefix,
formatReplyQuote,
} from '../lib/compose-format';
import type { EmailAddress, Identity, ContactCard } from '../api/types';
import type { RootStackParamList } from '../navigation/types';
@@ -32,7 +42,21 @@ interface Recipient {
email: string;
}
type AttachmentEntry = {
localId: string;
name: string;
type: string;
size: number;
uri: string;
inline: boolean;
cid?: string;
blobId?: string;
uploading: boolean;
error?: string;
};
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const URL_RE = /^https?:\/\/.+/i;
function parseRecipients(input: string): Recipient[] {
return input
@@ -42,6 +66,20 @@ function parseRecipients(input: string): Recipient[] {
.map((email) => ({ name: '', email }));
}
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
function genCid(): string {
return `${Date.now().toString(36)}.${Math.random().toString(36).slice(2, 10)}@bulwark.local`;
}
function genLocalId(): string {
return `${Date.now().toString(36)}.${Math.random().toString(36).slice(2, 8)}`;
}
function RecipientChip({ recipient, onRemove }: { recipient: Recipient; onRemove: () => void }) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -93,9 +131,48 @@ function SuggestionList({
);
}
function AttachmentChip({
attachment, onRemove,
}: {
attachment: AttachmentEntry;
onRemove: () => void;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<View style={styles.attachmentChip}>
{attachment.uploading ? (
<ActivityIndicator size="small" color={c.primary} />
) : attachment.error ? (
<X size={14} color={c.error} />
) : attachment.type.startsWith('image/') ? (
<ImageIcon size={14} color={c.textSecondary} />
) : (
<FileText size={14} color={c.textSecondary} />
)}
<View style={styles.attachmentMeta}>
<Text style={styles.attachmentName} numberOfLines={1}>
{attachment.name}
</Text>
<Text style={styles.attachmentSize}>
{attachment.error
? attachment.error
: attachment.uploading
? 'Uploading...'
: formatBytes(attachment.size)}
</Text>
</View>
<Pressable onPress={onRemove} hitSlop={8} style={styles.attachmentRemove}>
<X size={14} color={c.textMuted} />
</Pressable>
</View>
);
}
export default function ComposeScreen({ route, navigation }: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const replyTo = route.params?.replyTo;
const mode = route.params?.mode;
const prefillTo = route.params?.prefillTo;
@@ -119,7 +196,6 @@ export default function ComposeScreen({ route, navigation }: Props) {
return [];
}
if (mode === 'forward') return [];
// reply + replyAll both include the original sender
const base: Recipient[] = replyTo.from.email
? [{ name: replyTo.from.name ?? '', email: replyTo.from.email }]
: [];
@@ -156,7 +232,15 @@ export default function ComposeScreen({ route, navigation }: Props) {
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 [activeField, setActiveField] = React.useState<'to' | 'cc' | null>(null);
const [attachments, setAttachments] = React.useState<AttachmentEntry[]>([]);
const bodyInputRef = React.useRef<TextInput>(null);
const allContacts = useContactsStore((s) => s.contacts);
const individuals = React.useMemo(() => allContacts.filter((c) => !isGroup(c)), [allContacts]);
@@ -235,24 +319,244 @@ export default function ComposeScreen({ route, navigation }: Props) {
};
const { finalTo, finalCc } = commitTyped();
const hasUploadInFlight = attachments.some((a) => a.uploading);
const hasUploadError = attachments.some((a) => !!a.error);
const hasValidRecipients = finalTo.every((r) => EMAIL_RE.test(r.email)) && finalTo.length > 0;
const canSend =
!sending &&
!hasUploadInFlight &&
!hasUploadError &&
hasValidRecipients &&
subject.trim().length > 0 &&
!!primaryIdentity &&
!!sentMailbox;
const onClose = () => navigation.goBack();
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) {
Alert.alert(
t('email_composer.close_draft_title', 'Discard draft?'),
t('email_composer.discard_draft_confirm', 'You have unsaved changes. Do you want to discard this draft?'),
[
{ text: t('email_composer.cancel', 'Cancel'), style: 'cancel' },
{
text: t('email_composer.discard', 'Discard'),
style: 'destructive',
onPress: () => navigation.goBack(),
},
],
);
return;
}
navigation.goBack();
};
const addAttachmentEntry = (entry: AttachmentEntry) => {
setAttachments((prev) => [...prev, entry]);
};
const updateAttachment = (localId: string, patch: Partial<AttachmentEntry>) => {
setAttachments((prev) => prev.map((a) => (a.localId === localId ? { ...a, ...patch } : a)));
};
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'));
}
return prev.filter((a) => a.localId !== localId);
});
};
const startUpload = async (asset: {
name: string;
type: string;
size: number;
uri: string;
inline: boolean;
}) => {
const localId = genLocalId();
const cid = asset.inline ? genCid() : undefined;
const entry: AttachmentEntry = {
localId,
name: asset.name,
type: asset.type,
size: asset.size,
uri: asset.uri,
inline: asset.inline,
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);
}
try {
const { blobId, size, type } = await uploadBlob(asset.uri, asset.type);
updateAttachment(localId, {
blobId,
type: type || asset.type,
size: size || asset.size,
uploading: false,
});
} catch (e) {
const message = e instanceof Error ? e.message : 'Upload failed';
updateAttachment(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),
);
}
};
const pickAttachment = async (inline: boolean) => {
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!perm.granted) {
Alert.alert(
t('email_composer.attach', 'Attach'),
'Photo library permission is required to attach images.',
);
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: inline ? ['images'] : ['images', 'videos'],
allowsMultipleSelection: !inline,
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({
name: fallbackName,
type: asset.mimeType ?? 'application/octet-stream',
size: asset.fileSize ?? 0,
uri: asset.uri,
inline,
});
}
};
// ── Format toolbar handlers ────────────────────────────────────────────
const formatBold = () => {
applyEdit(wrapSelection(body, bodySelection, '**', '**', 'bold'));
bodyInputRef.current?.focus();
};
const formatItalic = () => {
applyEdit(wrapSelection(body, bodySelection, '_', '_', 'italic'));
bodyInputRef.current?.focus();
};
const formatBullet = () => {
applyEdit(toggleLinePrefix(body, bodySelection, '- '));
bodyInputRef.current?.focus();
};
const formatNumbered = () => {
applyEdit(toggleLinePrefix(body, bodySelection, '1. '));
bodyInputRef.current?.focus();
};
const formatQuote = () => {
applyEdit(toggleLinePrefix(body, bodySelection, '> '));
bodyInputRef.current?.focus();
};
const insertLink = (url: string) => {
const target = URL_RE.test(url) ? url : `https://${url}`;
const start = Math.min(bodySelection.start, bodySelection.end);
const end = Math.max(bodySelection.start, bodySelection.end);
const selected = body.slice(start, end);
const label = selected || target;
const insertion = `[${label}](${target})`;
const next = `${body.slice(0, start)}${insertion}${body.slice(end)}`;
const cursor = start + insertion.length;
applyEdit({ text: next, selection: { start: cursor, end: cursor } });
bodyInputRef.current?.focus();
};
const formatLink = () => {
if (Platform.OS === 'ios' && typeof Alert.prompt === 'function') {
Alert.prompt(
t('email_composer.add_link', 'Add link'),
t('email_composer.link_url_prompt', 'Enter the URL'),
(url) => { if (url) insertLink(url); },
'plain-text',
'',
'url',
);
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 onSend = async () => {
if (!canSend || !primaryIdentity || !sentMailbox) return;
setSending(true);
try {
const from: EmailAddress[] = [{ name: primaryIdentity.name, email: primaryIdentity.email }];
const bodyWithQuote = replyTo?.body
? `${body}\n\n---\nOn ${new Date().toLocaleDateString()}, ${replyTo.from.name || replyTo.from.email} wrote:\n${replyTo.body.split('\n').map((l) => `> ${l}`).join('\n')}`
: body;
const quote = replyTo?.body
? formatReplyQuote(replyTo.body, {
senderName: replyTo.from.name || replyTo.from.email,
date: new Date(),
})
: '';
const sourceText = body + quote;
const htmlBody = markdownToHtml(sourceText);
// 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) => ({
blobId: a.blobId!,
type: a.type,
name: a.name,
size: a.size,
disposition: a.inline ? 'inline' : 'attachment',
cid: a.cid,
}));
await sendEmail(
{
@@ -262,7 +566,9 @@ export default function ComposeScreen({ route, navigation }: Props) {
? finalCc.map((r) => ({ name: r.name || undefined, email: r.email }))
: undefined,
subject,
textBody: bodyWithQuote,
htmlBody,
textBody: plainText,
attachments: outgoing.length ? outgoing : undefined,
inReplyTo: replyTo?.inReplyTo,
references: replyTo?.references,
},
@@ -271,12 +577,21 @@ export default function ComposeScreen({ route, navigation }: Props) {
);
navigation.goBack();
} catch (e) {
Alert.alert('Send failed', e instanceof Error ? e.message : 'Failed to send email');
Alert.alert(
t('email_composer.send_failed', 'Send failed'),
e instanceof Error ? e.message : 'Failed to send email',
);
} finally {
setSending(false);
}
};
const titleKey =
mode === 'forward' ? 'email_composer.forward'
: mode === 'replyAll' ? 'email_composer.reply_all'
: replyTo ? 'email_composer.reply'
: 'email_composer.new_message';
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<View style={styles.header}>
@@ -284,10 +599,14 @@ export default function ComposeScreen({ route, navigation }: Props) {
<X size={22} color={c.text} />
</Pressable>
<Text style={styles.headerTitle}>
{mode === 'forward' ? 'Forward' : mode === 'replyAll' ? 'Reply All' : replyTo ? 'Reply' : 'New Message'}
{t(titleKey, mode === 'forward' ? 'Forward' : mode === 'replyAll' ? 'Reply All' : replyTo ? 'Reply' : 'New Message')}
</Text>
<View style={styles.headerRight}>
<Pressable style={styles.headerBtn}>
<Pressable
onPress={() => { void pickAttachment(false); }}
style={styles.headerBtn}
hitSlop={8}
>
<Paperclip size={20} color={c.text} />
</Pressable>
<Button
@@ -307,7 +626,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
}
style={!canSend ? styles.sendButtonDisabled : undefined}
>
{sending ? 'Sending...' : 'Send'}
{sending ? t('email_composer.sending', 'Sending...') : t('email_composer.send', 'Send')}
</Button>
</View>
</View>
@@ -318,7 +637,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
>
<ScrollView style={styles.flex} keyboardShouldPersistTaps="handled">
<View style={styles.fieldRow}>
<Text style={styles.fieldLabel}>From</Text>
<Text style={styles.fieldLabel}>{t('email_composer.from', 'From')}</Text>
<View style={styles.fieldContent}>
<Text style={styles.fromText} numberOfLines={1}>
{primaryIdentity?.email ?? (identityError ? 'identity unavailable' : 'Loading...')}
@@ -328,7 +647,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
</View>
<View style={styles.fieldRow}>
<Text style={styles.fieldLabel}>To</Text>
<Text style={styles.fieldLabel}>{t('email_composer.to', 'To')}</Text>
<View style={styles.recipientField}>
{toRecipients.map((r, i) => (
<RecipientChip
@@ -339,7 +658,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
))}
<TextInput
style={styles.recipientInput}
placeholder="Add recipient..."
placeholder={t('email_composer.to_placeholder', 'Recipient email addresses')}
placeholderTextColor={c.textMuted}
value={toInput}
onChangeText={setToInput}
@@ -359,14 +678,14 @@ export default function ComposeScreen({ route, navigation }: Props) {
)}
{!ccVisible && (
<Pressable onPress={() => setCcVisible(true)} style={styles.ccToggle}>
<Text style={styles.ccToggleText}>Cc</Text>
<Text style={styles.ccToggleText}>{t('email_composer.cc', 'Cc')}</Text>
</Pressable>
)}
</View>
{ccVisible && (
<View style={styles.fieldRow}>
<Text style={styles.fieldLabel}>Cc</Text>
<Text style={styles.fieldLabel}>{t('email_composer.cc', 'Cc')}</Text>
<View style={styles.recipientField}>
{ccRecipients.map((r, i) => (
<RecipientChip
@@ -377,7 +696,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
))}
<TextInput
style={styles.recipientInput}
placeholder="Add Cc..."
placeholder={t('email_composer.cc_placeholder', 'Cc recipients')}
placeholderTextColor={c.textMuted}
value={ccInput}
onChangeText={setCcInput}
@@ -399,24 +718,39 @@ export default function ComposeScreen({ route, navigation }: Props) {
)}
<View style={styles.fieldRow}>
<Text style={styles.fieldLabel}>Subject</Text>
<Text style={styles.fieldLabel}>{t('email_composer.subject', 'Subject')}</Text>
<TextInput
style={styles.subjectInput}
placeholder="Email subject"
placeholder={t('email_composer.subject_placeholder', 'Subject')}
placeholderTextColor={c.textMuted}
value={subject}
onChangeText={setSubject}
/>
</View>
{attachments.length > 0 && (
<View style={styles.attachmentList}>
{attachments.map((a) => (
<AttachmentChip
key={a.localId}
attachment={a}
onRemove={() => removeAttachment(a.localId)}
/>
))}
</View>
)}
<TextInput
ref={bodyInputRef}
style={styles.bodyInput}
placeholder="Write your message..."
placeholder={t('email_composer.body_placeholder', 'Write your message...')}
placeholderTextColor={c.textMuted}
value={body}
onChangeText={setBody}
onSelectionChange={onBodySelectionChange}
multiline
textAlignVertical="top"
autoCapitalize="sentences"
/>
{replyTo?.body && (
@@ -424,7 +758,9 @@ export default function ComposeScreen({ route, navigation }: Props) {
<View style={styles.quoteBorder} />
<View style={styles.quoteContent}>
<Text style={styles.quoteMeta}>
On {new Date().toLocaleDateString()}, {replyTo.from.name || replyTo.from.email} wrote:
{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}
@@ -434,28 +770,48 @@ export default function ComposeScreen({ route, navigation }: Props) {
)}
</ScrollView>
<View style={styles.formatBar}>
<View style={styles.formatActions}>
<Pressable style={styles.formatBtn}>
<Bold size={18} color={c.textSecondary} />
</Pressable>
<Pressable style={styles.formatBtn}>
<Italic size={18} color={c.textSecondary} />
</Pressable>
<Pressable style={styles.formatBtn}>
<List size={18} color={c.textSecondary} />
</Pressable>
<Pressable style={styles.formatBtn}>
<Link2 size={18} color={c.textSecondary} />
</Pressable>
<Pressable style={styles.formatBtn}>
<ImageIcon size={18} color={c.textSecondary} />
</Pressable>
<Pressable style={styles.formatBtn}>
<MoreHorizontal size={18} color={c.textSecondary} />
</Pressable>
</View>
</View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.formatBar}
contentContainerStyle={styles.formatActions}
keyboardShouldPersistTaps="handled"
>
<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>
<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>
<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>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
@@ -576,6 +932,27 @@ function makeStyles(c: ThemePalette) {
paddingVertical: 10,
},
attachmentList: {
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
gap: spacing.xs,
borderBottomWidth: 1,
borderBottomColor: c.borderLight,
},
attachmentChip: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderRadius: radius.sm,
backgroundColor: c.surfaceActive,
},
attachmentMeta: { flex: 1, minWidth: 0 },
attachmentName: { ...typography.bodyMedium, color: c.text },
attachmentSize: { ...typography.caption, color: c.textMuted },
attachmentRemove: { padding: 4 },
bodyInput: {
...typography.body,
color: c.text,
@@ -600,12 +977,14 @@ function makeStyles(c: ThemePalette) {
borderTopWidth: 1,
borderTopColor: c.border,
backgroundColor: c.surface,
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
flexGrow: 0,
},
formatActions: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
},
formatBtn: {
width: componentSizes.buttonMd,
@@ -614,5 +993,11 @@ function makeStyles(c: ThemePalette) {
justifyContent: 'center',
borderRadius: radius.sm,
},
formatSep: {
width: 1,
height: 20,
backgroundColor: c.borderLight,
marginHorizontal: 2,
},
});
}
+14 -10
View File
@@ -30,26 +30,30 @@ import { useColors } from '../theme/colors';
type Nav = NativeStackNavigationProp<RootStackParamList, 'ContactDetail'>;
type Route = RouteProp<RootStackParamList, 'ContactDetail'>;
const CATEGORY_COLORS: Record<string, string> = {
contact: c.primary,
work: c.calendar.orange,
location: c.calendar.green,
personal: c.calendar.pink,
digital: c.calendar.teal,
notes: c.calendar.purple,
};
type Category = 'contact' | 'work' | 'location' | 'personal' | 'digital' | 'notes';
function categoryColor(c: ThemePalette, category: Category): string {
switch (category) {
case 'contact': return c.primary;
case 'work': return c.calendar.orange;
case 'location': return c.calendar.green;
case 'personal': return c.calendar.pink;
case 'digital': return c.calendar.teal;
case 'notes': return c.calendar.purple;
}
}
function Section({
icon, title, category = 'contact', children,
}: {
icon: React.ReactNode;
title: string;
category?: keyof typeof CATEGORY_COLORS;
category?: Category;
children: React.ReactNode;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const accent = CATEGORY_COLORS[category] || c.primary;
const accent = categoryColor(c, category);
return (
<View style={[styles.section, { borderLeftColor: accent }]}>
<View style={styles.sectionHeader}>
+8 -2
View File
@@ -129,7 +129,11 @@ const EmailRow = React.memo(function EmailRow({
);
});
const EmailRowSeparator = () => <View style={styles.separator} />;
function EmailRowSeparator() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return <View style={styles.separator} />;
}
const emailKeyExtractor = (item: Email) => item.id;
@@ -239,7 +243,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
else void markUnread(id);
break;
case 'star':
void toggleStar(id);
void toggleStar(id, !isStarred(email));
break;
}
}, [
@@ -820,6 +824,8 @@ function TriToggle({
value: boolean | undefined;
onPress: () => void;
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const state =
value === true ? 'on' : value === false ? 'off' : 'unset';
return (
+2 -2
View File
@@ -78,11 +78,11 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
const currentIndex = emails.findIndex((e) => e.id === emailId);
const prevEmail = currentIndex > 0 ? emails[currentIndex - 1] : null;
const nextEmail = currentIndex >= 0 && currentIndex < emails.length - 1 ? emails[currentIndex + 1] : null;
const goToEmail = (target: { id: string; threadId: string; subject: string }) => {
const goToEmail = (target: { id: string; threadId: string; subject?: string }) => {
navigation.replace('EmailThread', {
emailId: target.id,
threadId: target.threadId,
subject: target.subject,
subject: target.subject ?? '',
});
};
+5 -1
View File
@@ -7,6 +7,7 @@ import { downloadAndInstallApk, type InstallProgress } from '../lib/install-upda
const STORAGE_KEY = 'webmail:updates:v1';
const MIN_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
const PENDING_APK_INTERVAL_MS = 2 * 60 * 1000;
interface PersistedUpdates {
autoCheck: boolean;
@@ -82,7 +83,10 @@ export const useUpdatesStore = create<UpdatesState>((set, get) => ({
const s = get();
if (s.checking) return;
const now = Date.now();
if (!opts?.force && now - s.lastCheckedAt < MIN_CHECK_INTERVAL_MS) return;
const waitingForApk =
s.cachedLatest != null && !s.cachedLatest.apkAsset && get().hasUpdate();
const interval = waitingForApk ? PENDING_APK_INTERVAL_MS : MIN_CHECK_INTERVAL_MS;
if (!opts?.force && now - s.lastCheckedAt < interval) return;
set({ checking: true, error: null });
try {
const latest = await fetchLatestRelease();