mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
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:
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 <script>alert(1)</script></p>',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders bold and italic', () => {
|
||||
expect(markdownToHtml('see **this** and _that_')).toContain('<b>this</b>');
|
||||
expect(markdownToHtml('see **this** and _that_')).toContain('<i>that</i>');
|
||||
});
|
||||
|
||||
it('renders bullet list', () => {
|
||||
const html = markdownToHtml('- one\n- two\n- three');
|
||||
expect(html).toBe('<ul><li>one</li><li>two</li><li>three</li></ul>');
|
||||
});
|
||||
|
||||
it('renders ordered list', () => {
|
||||
const html = markdownToHtml('1. one\n2. two');
|
||||
expect(html).toBe('<ol><li>one</li><li>two</li></ol>');
|
||||
});
|
||||
|
||||
it('renders blockquotes', () => {
|
||||
expect(markdownToHtml('> quoted\n> more')).toBe(
|
||||
'<blockquote>quoted<br />more</blockquote>',
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves multiple paragraphs separated by blank lines', () => {
|
||||
const html = markdownToHtml('first line\n\nsecond para');
|
||||
expect(html).toBe('<p>first line</p>\n<p>second para</p>');
|
||||
});
|
||||
|
||||
it('preserves single line breaks within a paragraph as <br>', () => {
|
||||
const html = markdownToHtml('one\ntwo');
|
||||
expect(html).toBe('<p>one<br />two</p>');
|
||||
});
|
||||
|
||||
it('renders markdown links', () => {
|
||||
const html = markdownToHtml('Click [here](https://example.com) please');
|
||||
expect(html).toContain('<a href="https://example.com" rel="noopener noreferrer">here</a>');
|
||||
});
|
||||
|
||||
it('rejects javascript: links', () => {
|
||||
const html = markdownToHtml('[evil](javascript:alert(1))');
|
||||
expect(html).not.toContain('href="javascript:');
|
||||
});
|
||||
|
||||
it('auto-links bare URLs', () => {
|
||||
const html = markdownToHtml('go to https://x.test now');
|
||||
expect(html).toContain('<a href="https://x.test"');
|
||||
});
|
||||
|
||||
it('renders inline cid images', () => {
|
||||
const html = markdownToHtml('');
|
||||
expect(html).toContain('<img src="cid:abc@x" alt="pic.png" />');
|
||||
});
|
||||
});
|
||||
|
||||
describe('wrapSelection', () => {
|
||||
it('wraps the selected range with markers', () => {
|
||||
const r = wrapSelection('hello world', { start: 6, end: 11 }, '**', '**');
|
||||
expect(r.text).toBe('hello **world**');
|
||||
expect(r.selection).toEqual({ start: 8, end: 13 });
|
||||
});
|
||||
|
||||
it('inserts placeholder when nothing is selected', () => {
|
||||
const r = wrapSelection('hi ', { start: 3, end: 3 }, '**', '**', 'bold');
|
||||
expect(r.text).toBe('hi **bold**');
|
||||
expect(r.selection).toEqual({ start: 5, end: 9 });
|
||||
});
|
||||
|
||||
it('handles reversed selection (end before start)', () => {
|
||||
const r = wrapSelection('abc', { start: 3, end: 0 }, '_', '_');
|
||||
expect(r.text).toBe('_abc_');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleLinePrefix', () => {
|
||||
it('adds prefix to a single line', () => {
|
||||
const r = toggleLinePrefix('hello', { start: 2, end: 2 }, '- ');
|
||||
expect(r.text).toBe('- hello');
|
||||
});
|
||||
|
||||
it('adds prefix to every line in selection', () => {
|
||||
const r = toggleLinePrefix('one\ntwo\nthree', { start: 0, end: 13 }, '- ');
|
||||
expect(r.text).toBe('- one\n- two\n- three');
|
||||
});
|
||||
|
||||
it('removes prefix when every line already has it (toggle)', () => {
|
||||
const r = toggleLinePrefix('- one\n- two', { start: 0, end: 11 }, '- ');
|
||||
expect(r.text).toBe('one\ntwo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatReplyQuote', () => {
|
||||
it('prefixes original body lines with "> "', () => {
|
||||
const out = formatReplyQuote('hello\nworld', {
|
||||
senderName: 'Alice',
|
||||
date: new Date('2026-04-27'),
|
||||
});
|
||||
expect(out).toContain('> hello');
|
||||
expect(out).toContain('> world');
|
||||
expect(out).toContain('Alice wrote:');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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}`;
|
||||
}
|
||||
@@ -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
@@ -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 = ``;
|
||||
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\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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 ?? '',
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user