diff --git a/locales/en/common.json b/locales/en/common.json index ff1c2a05..026d45fc 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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", diff --git a/src/api/email.ts b/src/api/email.ts index fa40d9a3..8a04d7a3 100644 --- a/src/api/email.ts +++ b/src/api/email.ts @@ -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 = {}; 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 = { + 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) { diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 62ec7309..2f36f67c 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -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; diff --git a/src/components/SidebarDrawer.tsx b/src/components/SidebarDrawer.tsx index e8c99f8d..e8ba8301 100644 --- a/src/components/SidebarDrawer.tsx +++ b/src/components/SidebarDrawer.tsx @@ -49,17 +49,17 @@ function iconFor( return Folder; } -const ROLE_COLOR: Record = { +const ROLE_COLOR_FIXED: Record = { 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 ( } + icon={} label={node.name} depth={node.depth} isSelected={isSelected} diff --git a/src/components/SwipeableRow.tsx b/src/components/SwipeableRow.tsx index e3c9fc47..295c50a4 100644 --- a/src/components/SwipeableRow.tsx +++ b/src/components/SwipeableRow.tsx @@ -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' }, }); } diff --git a/src/components/UpdateBanner.tsx b/src/components/UpdateBanner.tsx index 238628a7..d2a969d5 100644 --- a/src/components/UpdateBanner.tsx +++ b/src/components/UpdateBanner.tsx @@ -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 ( - + Update available diff --git a/src/components/calendar/EventDetailSheet.tsx b/src/components/calendar/EventDetailSheet.tsx index fea92d1e..f0a92cf2 100644 --- a/src/components/calendar/EventDetailSheet.tsx +++ b/src/components/calendar/EventDetailSheet.tsx @@ -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; diff --git a/src/components/settings/FilesSettings.tsx b/src/components/settings/FilesSettings.tsx index 9c6b25c3..fe534d65 100644 --- a/src/components/settings/FilesSettings.tsx +++ b/src/components/settings/FilesSettings.tsx @@ -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 ; const ext = file.name.split('.').pop()?.toLowerCase(); switch (ext) { @@ -96,7 +96,7 @@ function FilesPreview({ prefs }: { prefs: FilesPrefs }) { {files.map((f) => ( - {prefs.showIcons ? getIcon(f, prefs.coloredIcons, 24) : } + {prefs.showIcons ? getIcon(c, f, prefs.coloredIcons, 24) : } {f.name} ))} @@ -114,7 +114,7 @@ function FilesPreview({ prefs }: { prefs: FilesPrefs }) { {files.map((f) => ( - {prefs.showIcons && getIcon(f, prefs.coloredIcons, 14)} + {prefs.showIcons && getIcon(c, f, prefs.coloredIcons, 14)} = { - 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() { {plugins.map((plugin) => { const expanded = expandedId === plugin.id; - const status = STATUS_STYLE[plugin.status]; + const status = statusStyle(c, plugin.status); return ( 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 }, + }); +} diff --git a/src/components/settings/SmimeSettings.tsx b/src/components/settings/SmimeSettings.tsx index ca68a6ed..9e865431 100644 --- a/src/components/settings/SmimeSettings.tsx +++ b/src/components/settings/SmimeSettings.tsx @@ -119,18 +119,18 @@ export function SmimeSettings() { description="Public certificates used to encrypt outgoing mail." > - {certs.map((c) => { - const expired = isExpired(c.notAfter); + {certs.map((cert) => { + const expired = isExpired(cert.notAfter); return ( - + - {c.email || c.subject} + {cert.email || cert.subject} - {c.issuer} · {c.source} + {cert.issuer} · {cert.source} {expired && (Expired)} @@ -139,7 +139,7 @@ export function SmimeSettings() { - deleteCert(c.id)}> + deleteCert(cert.id)}> diff --git a/src/components/settings/UpdatesSettings.tsx b/src/components/settings/UpdatesSettings.tsx index 293cbde3..7766fc8b 100644 --- a/src/components/settings/UpdatesSettings.tsx +++ b/src/components/settings/UpdatesSettings.tsx @@ -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() { - {updateAvailable ? ( + {apkPending ? ( + + ) : updateAvailable ? ( ) : ( + + ) : null} + {installing && installProgress ? ( diff --git a/src/lib/__tests__/compose-format.test.ts b/src/lib/__tests__/compose-format.test.ts new file mode 100644 index 00000000..bc88bb09 --- /dev/null +++ b/src/lib/__tests__/compose-format.test.ts @@ -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 ')).toBe( + '

hi <script>alert(1)</script>

', + ); + }); + + it('renders bold and italic', () => { + expect(markdownToHtml('see **this** and _that_')).toContain('this'); + expect(markdownToHtml('see **this** and _that_')).toContain('that'); + }); + + it('renders bullet list', () => { + const html = markdownToHtml('- one\n- two\n- three'); + expect(html).toBe('
  • one
  • two
  • three
'); + }); + + it('renders ordered list', () => { + const html = markdownToHtml('1. one\n2. two'); + expect(html).toBe('
  1. one
  2. two
'); + }); + + it('renders blockquotes', () => { + expect(markdownToHtml('> quoted\n> more')).toBe( + '
quoted
more
', + ); + }); + + it('preserves multiple paragraphs separated by blank lines', () => { + const html = markdownToHtml('first line\n\nsecond para'); + expect(html).toBe('

first line

\n

second para

'); + }); + + it('preserves single line breaks within a paragraph as
', () => { + const html = markdownToHtml('one\ntwo'); + expect(html).toBe('

one
two

'); + }); + + it('renders markdown links', () => { + const html = markdownToHtml('Click [here](https://example.com) please'); + expect(html).toContain('here'); + }); + + 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(' { + const html = markdownToHtml('![pic.png](cid:abc@x)'); + expect(html).toContain('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:'); + }); +}); diff --git a/src/lib/compose-format.ts b/src/lib/compose-format.ts new file mode 100644 index 00000000..02b0d02e --- /dev/null +++ b/src/lib/compose-format.ts @@ -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) => + `${alt}`, + ); + out = out.replace(LINK_OR_URL_RE, (full, label, mdUrl, bareUrl) => { + if (label !== undefined && mdUrl) { + return `${label}`; + } + if (bareUrl) { + return `${bareUrl}`; + } + return full; + }); + out = out.replace(BOLD_RE, '$1'); + out = out.replace(ITALIC_RE, '$1$2'); + 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(`

${b.lines.map(inlineFormat).join('
')}

`); + } else if (b.kind === 'ul') { + out.push(`
    ${b.items.map((i) => `
  • ${inlineFormat(i)}
  • `).join('')}
`); + } else if (b.kind === 'ol') { + out.push(`
    ${b.items.map((i) => `
  1. ${inlineFormat(i)}
  2. `).join('')}
`); + } else { + out.push(`
${b.lines.map(inlineFormat).join('
')}
`); + } + } + 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}`; +} diff --git a/src/lib/email-export.ts b/src/lib/email-export.ts index 87364b33..f8f28453 100644 --- a/src/lib/email-export.ts +++ b/src/lib/email-export.ts @@ -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; } diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index 62b2dcfe..93c4b7c6 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -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 ( + + {attachment.uploading ? ( + + ) : attachment.error ? ( + + ) : attachment.type.startsWith('image/') ? ( + + ) : ( + + )} + + + {attachment.name} + + + {attachment.error + ? attachment.error + : attachment.uploading + ? 'Uploading...' + : formatBytes(attachment.size)} + + + + + + + ); +} + 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([]); + const bodyInputRef = React.useRef(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) => { + 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) => { + 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 ( @@ -284,10 +599,14 @@ export default function ComposeScreen({ route, navigation }: Props) { - {mode === 'forward' ? 'Forward' : mode === 'replyAll' ? 'Reply All' : replyTo ? 'Reply' : 'New Message'} + {t(titleKey, mode === 'forward' ? 'Forward' : mode === 'replyAll' ? 'Reply All' : replyTo ? 'Reply' : 'New Message')} - + { void pickAttachment(false); }} + style={styles.headerBtn} + hitSlop={8} + > @@ -318,7 +637,7 @@ export default function ComposeScreen({ route, navigation }: Props) { > - From + {t('email_composer.from', 'From')} {primaryIdentity?.email ?? (identityError ? 'identity unavailable' : 'Loading...')} @@ -328,7 +647,7 @@ export default function ComposeScreen({ route, navigation }: Props) { - To + {t('email_composer.to', 'To')} {toRecipients.map((r, i) => ( setCcVisible(true)} style={styles.ccToggle}> - Cc + {t('email_composer.cc', 'Cc')} )} {ccVisible && ( - Cc + {t('email_composer.cc', 'Cc')} {ccRecipients.map((r, i) => ( - Subject + {t('email_composer.subject', 'Subject')} + {attachments.length > 0 && ( + + {attachments.map((a) => ( + removeAttachment(a.localId)} + /> + ))} + + )} + {replyTo?.body && ( @@ -424,7 +758,9 @@ export default function ComposeScreen({ route, navigation }: Props) { - 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)} {replyTo.body} @@ -434,28 +770,48 @@ export default function ComposeScreen({ route, navigation }: Props) { )} - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + { void pickAttachment(true); }} + hitSlop={4} + > + + + { void pickAttachment(false); }} + hitSlop={4} + > + + + ); @@ -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, + }, }); } diff --git a/src/screens/ContactDetailScreen.tsx b/src/screens/ContactDetailScreen.tsx index b3696e26..3f911b28 100644 --- a/src/screens/ContactDetailScreen.tsx +++ b/src/screens/ContactDetailScreen.tsx @@ -30,26 +30,30 @@ import { useColors } from '../theme/colors'; type Nav = NativeStackNavigationProp; type Route = RouteProp; -const CATEGORY_COLORS: Record = { - 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 ( diff --git a/src/screens/EmailListScreen.tsx b/src/screens/EmailListScreen.tsx index e2c73abc..b6722540 100644 --- a/src/screens/EmailListScreen.tsx +++ b/src/screens/EmailListScreen.tsx @@ -129,7 +129,11 @@ const EmailRow = React.memo(function EmailRow({ ); }); -const EmailRowSeparator = () => ; +function EmailRowSeparator() { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + return ; +} 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 ( diff --git a/src/screens/EmailThreadScreen.tsx b/src/screens/EmailThreadScreen.tsx index dec84b70..e1741cda 100644 --- a/src/screens/EmailThreadScreen.tsx +++ b/src/screens/EmailThreadScreen.tsx @@ -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 ?? '', }); }; diff --git a/src/stores/updates-store.ts b/src/stores/updates-store.ts index b10f51ae..f7d613a5 100644 --- a/src/stores/updates-store.ts +++ b/src/stores/updates-store.ts @@ -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((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();