Compare commits

...
7 Commits
26 changed files with 1737 additions and 358 deletions
+12 -2
View File
@@ -15,6 +15,7 @@ import {
getInitialNotificationTap,
getStoredRelayBaseUrl,
setupPushNotifications,
teardownPushNotifications,
type NotificationTapPayload,
} from './src/lib/push-notifications';
import type { MainTabsParamList, RootStackParamList } from './src/navigation/types';
@@ -240,12 +241,21 @@ export default function App() {
}, [isAuthenticated]);
// Re-register the device with the configured relay once authenticated,
// and whenever the FCM token rotates.
// and whenever the FCM token rotates. Honours the user's notification
// preference - flipping it off tears down the active subscription so the
// FirebaseMessagingService stops receiving pings for this device.
const emailNotificationsEnabled = useSettingsStore(
(s) => s.emailNotificationsEnabled,
);
React.useEffect(() => {
if (!isAuthenticated || !client) return;
let cancelled = false;
const doSetup = async () => {
if (!emailNotificationsEnabled) {
await teardownPushNotifications().catch(() => undefined);
return;
}
const relayBaseUrl = await getStoredRelayBaseUrl();
if (!relayBaseUrl) return;
try {
@@ -271,7 +281,7 @@ export default function App() {
cancelled = true;
unsubscribe();
};
}, [client, isAuthenticated]);
}, [client, isAuthenticated, emailNotificationsEnabled]);
React.useEffect(() => {
if (!isAuthenticated || !client) {
+1 -1
View File
@@ -1 +1 @@
0.1.16
0.1.19
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-mobile",
"version": "0.1.16",
"version": "0.1.19",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "0.1.16",
"version": "0.1.19",
"license": "AGPL-3.0-only",
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-mobile",
"version": "0.1.16",
"version": "0.1.19",
"license": "AGPL-3.0-only",
"main": "index.ts",
"scripts": {
+34 -8
View File
@@ -13,17 +13,20 @@ export async function uploadBlob(
encodeURIComponent(jmapClient.accountId),
);
// Read the source via the file-system API so this works for both `file://`
// (image picker, iOS document picker) and `content://` (Android SAF) URIs.
// Read via the file-system API so this works for both `file://` (image
// picker, iOS document picker) and `content://` (Android SAF) URIs. We pass
// the underlying ArrayBuffer rather than the Uint8Array view because some
// RN transports stringify typed-array bodies, which silently produces an
// empty/garbage upload.
const bytes = await new File(uri).bytes();
const response = await fetch(uploadUrl, {
method: 'POST',
headers: {
'Content-Type': type,
Authorization: (jmapClient as any).authHeader,
'Content-Type': type || 'application/octet-stream',
Authorization: jmapClient.authHeader,
},
body: bytes as unknown as BodyInit,
body: bytes.buffer as ArrayBuffer,
});
if (!response.ok) {
@@ -31,7 +34,30 @@ export async function uploadBlob(
throw new Error(`Upload failed: ${response.status}${detail ? ` ${detail}` : ''}`);
}
return response.json();
// JMAP servers return either the direct shape `{blobId, type, size}` or
// the per-account nested shape `{[accountId]: {blobId, type, size}}`.
// Stalwart has shipped both depending on version; mirror the webmail's
// tolerant parsing so uploads don't silently lose the blobId.
const raw = (await response.json()) as Record<string, unknown>;
const direct = raw as { blobId?: string; type?: string; size?: number };
if (typeof direct.blobId === 'string') {
return {
blobId: direct.blobId,
size: typeof direct.size === 'number' ? direct.size : bytes.byteLength,
type: typeof direct.type === 'string' && direct.type ? direct.type : type,
};
}
const nested = raw[jmapClient.accountId] as
| { blobId?: string; type?: string; size?: number }
| undefined;
if (nested?.blobId) {
return {
blobId: nested.blobId,
size: typeof nested.size === 'number' ? nested.size : bytes.byteLength,
type: typeof nested.type === 'string' && nested.type ? nested.type : type,
};
}
throw new Error('Upload succeeded but response did not include a blobId');
}
export function getDownloadUrl(
@@ -43,8 +69,8 @@ export function getDownloadUrl(
if (!session) throw new Error('Not connected');
return session.downloadUrl
.replace('{accountId}', jmapClient.accountId)
.replace('{blobId}', blobId)
.replace('{accountId}', encodeURIComponent(jmapClient.accountId))
.replace('{blobId}', encodeURIComponent(blobId))
.replace('{name}', encodeURIComponent(name ?? 'download'))
.replace('{type}', encodeURIComponent(type ?? 'application/octet-stream'));
}
+61
View File
@@ -22,6 +22,67 @@ export async function getMailboxes(): Promise<Mailbox[]> {
return res.methodResponses[0][1].list;
}
export async function createMailbox(
data: { name: string; parentId?: string | null },
): Promise<string> {
const accountId = jmapClient.accountId;
const cid = 'new-mailbox';
const res = await jmapClient.request([
['Mailbox/set', {
accountId,
create: {
[cid]: {
name: data.name,
parentId: data.parentId ?? null,
},
},
}, '0'],
]);
const result = res.methodResponses[0][1];
if (result.created?.[cid]?.id) return result.created[cid].id as string;
const failure = result.notCreated?.[cid] as
| { type?: string; description?: string; properties?: string[] }
| undefined;
throw new Error(
failure
? `${failure.type ?? 'create failed'}${failure.description ? `: ${failure.description}` : ''}`
: 'Mailbox create returned no id',
);
}
export async function updateMailbox(
id: string,
changes: { name?: string; parentId?: string | null },
): Promise<void> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request([
['Mailbox/set', { accountId, update: { [id]: changes } }, '0'],
]);
const failure = res.methodResponses[0][1].notUpdated?.[id] as
| { type?: string; description?: string }
| undefined;
if (failure) {
throw new Error(
`${failure.type ?? 'update failed'}${failure.description ? `: ${failure.description}` : ''}`,
);
}
}
export async function deleteMailbox(id: string): Promise<void> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request([
['Mailbox/set', { accountId, destroy: [id] }, '0'],
]);
const failure = res.methodResponses[0][1].notDestroyed?.[id] as
| { type?: string; description?: string }
| undefined;
if (failure) {
throw new Error(
`${failure.type ?? 'delete failed'}${failure.description ? `: ${failure.description}` : ''}`,
);
}
}
export async function queryEmails(
mailboxId: string,
options?: {
+18 -9
View File
@@ -53,7 +53,10 @@ export class JMAPClient {
// ── Authentication ────────────────────────────────────
private get authHeader(): string {
// Public so blob upload/download helpers (which can't go through the JMAP
// request body) can fetch the same Authorization header the rest of the
// client uses. The value is recomputed each access and never cached.
get authHeader(): string {
if (!this.credentials) throw new Error('No credentials');
if (this.credentials.accessToken) {
return `Bearer ${this.credentials.accessToken}`;
@@ -161,17 +164,18 @@ export class JMAPClient {
// JMAP servers often self-report localhost/container-internal hostnames in
// apiUrl/downloadUrl/etc. that are unreachable from mobile clients (e.g.
// the Android emulator can't resolve the host's "localhost").
//
// Splits via plain string indexing rather than `new URL()` because RN's
// URL polyfill mutates inputs (appends trailing slashes, normalises
// characters) and would corrupt the RFC 6570 templates {accountId}/{blobId}
// before the caller has a chance to substitute values into them.
private rewriteSessionUrls(session: JMAPSession, serverUrl: string): JMAPSession {
const serverOrigin = this.extractOrigin(serverUrl);
const rewrite = (url: string | undefined): string | undefined => {
if (!url) return url;
try {
const parsed = new URL(url);
const server = new URL(serverUrl);
if (parsed.origin === server.origin) return url;
return server.origin + parsed.pathname + parsed.search + parsed.hash;
} catch {
return url;
}
const origin = this.extractOrigin(url);
if (!origin || !serverOrigin || origin === serverOrigin) return url;
return serverOrigin + url.slice(origin.length);
};
return {
...session,
@@ -182,6 +186,11 @@ export class JMAPClient {
};
}
private extractOrigin(url: string): string | null {
const m = url.match(/^(https?:\/\/[^/?#]+)/i);
return m ? m[1] : null;
}
// Reset in-memory state without touching persisted credentials (used on
// account switch so the active account's stored creds remain available).
reset(): void {
+1 -1
View File
@@ -134,7 +134,7 @@ export function connectEventSource(
: require('react-native-sse').default;
const es = new EventSourceImpl(url, {
headers: { Authorization: (jmapClient as any).authHeader },
headers: { Authorization: jmapClient.authHeader },
});
const handler = (event: any) => {
+36 -5
View File
@@ -6,8 +6,9 @@ import type { Email } from '../api/types';
import { wrapEmailHtml, wrapPlainTextEmail, plainTextToSafeHtml, extractCidRefs, hasRemoteContent, hasMeaningfulHtmlBody } from '../lib/email-html';
import { jmapClient } from '../api/jmap-client';
import { useSettingsStore } from '../stores/settings-store';
import { useContactsStore } from '../stores/contacts-store';
import { spacing, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useColors, useResolvedTheme } from '../theme/colors';
// Largest inline image we'll pull inline as a data: URI. Anything bigger gets
// skipped so we don't freeze the JS thread base64-encoding megabytes.
@@ -135,6 +136,28 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
const externalContentPolicy = useSettingsStore((s) => s.externalContentPolicy);
const isSenderTrusted = useSettingsStore((s) => s.isSenderTrusted);
const addTrustedSender = useSettingsStore((s) => s.addTrustedSender);
const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook);
const emailAlwaysLightMode = useSettingsStore((s) => s.emailAlwaysLightMode);
const hideInlineImageAttachments = useSettingsStore((s) => s.hideInlineImageAttachments);
const contacts = useContactsStore((s) => s.contacts);
const resolvedTheme = useResolvedTheme();
// Index the address book once per render so the trusted-sender check below
// is O(1) per email. Only used when the matching setting is on.
const addressBookEmails = React.useMemo(() => {
if (!trustedSendersAddressBook) return null;
const out = new Set<string>();
for (const c of contacts) {
if (!c.emails) continue;
for (const e of Object.values(c.emails)) {
if (e?.address) out.add(e.address.toLowerCase());
}
}
return out;
}, [contacts, trustedSendersAddressBook]);
// Most marketing email is authored against a white background, so dark-mode
// inversion can wreck logos/banners. With this flag the user opts to render
// emails on a light surface even while the rest of the app is dark.
const renderAsDark = !emailAlwaysLightMode && resolvedTheme === 'dark';
const html = React.useMemo(() => extractHtmlBody(email), [email]);
const text = React.useMemo(() => extractTextBody(email), [email]);
@@ -145,7 +168,10 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
[html, text],
);
const hasRemote = rawHtml ? hasRemoteContent(rawHtml) : false;
const trusted = senderEmail ? isSenderTrusted(senderEmail) : false;
const trusted = senderEmail
? isSenderTrusted(senderEmail)
|| (addressBookEmails?.has(senderEmail.toLowerCase()) ?? false)
: false;
// One-time override: user tapped "Load images" for this email only.
const [allowOnce, setAllowOnce] = React.useState(false);
@@ -218,17 +244,22 @@ export default function EmailBodyView({ email, senderEmail }: EmailBodyViewProps
html: wrapEmailHtml(rawHtml, {
blockRemoteImages: shouldBlock,
cidMap,
isDark: renderAsDark,
}),
};
}
const fallbackText = text ?? email.preview ?? '';
if (!fallbackText) {
return {
html: wrapEmailHtml('<em style="color:#71717a">(empty message)</em>'),
html: wrapEmailHtml('<em style="color:#71717a">(empty message)</em>', {
isDark: renderAsDark,
}),
};
}
return { html: wrapPlainTextEmail(plainTextToSafeHtml(fallbackText)) };
}, [rawHtml, text, email.preview, shouldBlock, cidMap]);
return {
html: wrapPlainTextEmail(plainTextToSafeHtml(fallbackText), { isDark: renderAsDark }),
};
}, [rawHtml, text, email.preview, shouldBlock, cidMap, renderAsDark]);
const onMessage = (e: WebViewMessageEvent) => {
const parsed = parseInt(e.nativeEvent.data, 10);
+8 -5
View File
@@ -12,6 +12,7 @@ import {
import type { LucideIcon } from 'lucide-react-native';
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useAnimDuration } from '../theme/dynamic';
import { useEmailStore } from '../stores/email-store';
import { useAuthStore } from '../stores/auth-store';
import { useAccountStore } from '../stores/account-store';
@@ -223,20 +224,22 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
const slideX = React.useRef(new Animated.Value(-Dimensions.get('window').width)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const openDuration = useAnimDuration(240);
const closeDuration = useAnimDuration(200);
React.useEffect(() => {
if (visible) {
Animated.parallel([
Animated.timing(slideX, { toValue: 0, duration: 240, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 1, duration: 240, useNativeDriver: true }),
Animated.timing(slideX, { toValue: 0, duration: openDuration, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 1, duration: openDuration, useNativeDriver: true }),
]).start();
} else {
Animated.parallel([
Animated.timing(slideX, { toValue: -Dimensions.get('window').width, duration: 200, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 0, duration: 200, useNativeDriver: true }),
Animated.timing(slideX, { toValue: -Dimensions.get('window').width, duration: closeDuration, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
Animated.timing(overlayOpacity, { toValue: 0, duration: closeDuration, useNativeDriver: true }),
]).start();
}
}, [visible, slideX, overlayOpacity]);
}, [visible, slideX, overlayOpacity, openDuration, closeDuration]);
const accountEmail = username || '';
const initials = React.useMemo(
@@ -15,6 +15,7 @@ import { Check, X } from 'lucide-react-native';
import type { Calendar } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useAnimDuration } from '../../theme/dynamic';
import { getCalendarColor } from '../../lib/calendar-utils';
interface CalendarSidebarDrawerProps {
@@ -36,19 +37,21 @@ export function CalendarSidebarDrawer({
const styles = React.useMemo(() => makeStyles(c), [c]);
const slideX = React.useRef(new Animated.Value(-Dimensions.get('window').width)).current;
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
const openDuration = useAnimDuration(240);
const closeDuration = useAnimDuration(200);
React.useEffect(() => {
if (visible) {
Animated.parallel([
Animated.timing(slideX, {
toValue: 0,
duration: 240,
duration: openDuration,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}),
Animated.timing(overlayOpacity, {
toValue: 1,
duration: 240,
duration: openDuration,
useNativeDriver: true,
}),
]).start();
@@ -56,18 +59,18 @@ export function CalendarSidebarDrawer({
Animated.parallel([
Animated.timing(slideX, {
toValue: -Dimensions.get('window').width,
duration: 200,
duration: closeDuration,
easing: Easing.in(Easing.cubic),
useNativeDriver: true,
}),
Animated.timing(overlayOpacity, {
toValue: 0,
duration: 200,
duration: closeDuration,
useNativeDriver: true,
}),
]).start();
}
}, [visible, slideX, overlayOpacity]);
}, [visible, slideX, overlayOpacity, openDuration, closeDuration]);
const hiddenSet = React.useMemo(() => new Set(hiddenCalendarIds), [hiddenCalendarIds]);
+94 -53
View File
@@ -5,6 +5,7 @@ import {
endOfMonth,
endOfWeek,
format,
getWeek,
isSameDay,
isSameMonth,
isToday,
@@ -21,7 +22,8 @@ import {
type EventDayIndex,
} from '../../lib/calendar-utils';
const WEEKDAYS = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
const SUN_FIRST = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
const MON_FIRST = ['M', 'T', 'W', 'T', 'F', 'S', 'S'];
interface MonthViewProps {
currentDate: Date;
@@ -29,6 +31,8 @@ interface MonthViewProps {
events: CalendarEvent[];
calendars: Calendar[];
eventsByDay?: EventDayIndex;
weekStartsOn?: 0 | 1;
showWeekNumbers?: boolean;
onSelectDate: (date: Date) => void;
onLongPressDate?: (date: Date) => void;
}
@@ -39,11 +43,14 @@ function MonthViewInner({
events,
calendars,
eventsByDay,
weekStartsOn = 0,
showWeekNumbers = false,
onSelectDate,
onLongPressDate,
}: MonthViewProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const weekdayLabels = weekStartsOn === 1 ? MON_FIRST : SUN_FIRST;
const index = React.useMemo(
() => eventsByDay ?? buildEventDayIndex(events),
[eventsByDay, events],
@@ -51,8 +58,8 @@ function MonthViewInner({
const days = React.useMemo(() => {
const monthStart = startOfMonth(currentDate);
const monthEnd = endOfMonth(currentDate);
const calStart = startOfWeek(monthStart);
const calEnd = endOfWeek(monthEnd);
const calStart = startOfWeek(monthStart, { weekStartsOn });
const calEnd = endOfWeek(monthEnd, { weekStartsOn });
const result: Date[] = [];
let day = calStart;
while (day <= calEnd) {
@@ -60,63 +67,82 @@ function MonthViewInner({
day = addDays(day, 1);
}
return result;
}, [currentDate]);
}, [currentDate, weekStartsOn]);
// Group days into rows of 7 so we can prepend a week-number column.
const rows = React.useMemo(() => {
const out: Date[][] = [];
for (let i = 0; i < days.length; i += 7) out.push(days.slice(i, i + 7));
return out;
}, [days]);
const renderDay = (d: Date, key: React.Key, flexLayout = false) => {
const sameMonth = isSameMonth(d, currentDate);
const today = isToday(d);
const selected = isSameDay(d, selectedDate);
const dayEvents = eventsOnDayFromIndex(index, d);
const MAX_DOTS = dayEvents.length > 3 ? 2 : 3;
const visibleDots = dayEvents.slice(0, MAX_DOTS);
const overflow = Math.max(0, dayEvents.length - MAX_DOTS);
return (
<Pressable
key={key}
style={[styles.dayCell, flexLayout && styles.dayCellFlex]}
onPress={() => onSelectDate(d)}
onLongPress={onLongPressDate ? () => onLongPressDate(d) : undefined}
>
<View style={[
styles.dayNumber,
today && styles.todayCircle,
selected && !today && styles.selectedCircle,
]}>
<Text style={[
styles.dayText,
!sameMonth && styles.dayTextMuted,
today && styles.todayText,
selected && !today && styles.selectedText,
]}>
{format(d, 'd')}
</Text>
</View>
{dayEvents.length > 0 && (
<View style={styles.dotsRow}>
{visibleDots.map((event, idx) => (
<View
key={`${event.id}-${idx}`}
style={[styles.dot, { backgroundColor: getEventColor(event, calendars) }]}
/>
))}
{overflow > 0 && (
<Text style={styles.overflowText}>+{overflow}</Text>
)}
</View>
)}
</Pressable>
);
};
return (
<View style={styles.grid}>
<View style={styles.weekdayRow}>
{WEEKDAYS.map((wd, i) => (
{showWeekNumbers && <Text style={styles.weekNumberLabel}>#</Text>}
{weekdayLabels.map((wd, i) => (
<Text key={i} style={styles.weekdayLabel}>{wd}</Text>
))}
</View>
<View style={styles.daysGrid}>
{days.map((d, i) => {
const sameMonth = isSameMonth(d, currentDate);
const today = isToday(d);
const selected = isSameDay(d, selectedDate);
const dayEvents = eventsOnDayFromIndex(index, d);
const MAX_DOTS = dayEvents.length > 3 ? 2 : 3;
const visibleDots = dayEvents.slice(0, MAX_DOTS);
const overflow = Math.max(0, dayEvents.length - MAX_DOTS);
return (
<Pressable
key={i}
style={styles.dayCell}
onPress={() => onSelectDate(d)}
onLongPress={onLongPressDate ? () => onLongPressDate(d) : undefined}
>
<View style={[
styles.dayNumber,
today && styles.todayCircle,
selected && !today && styles.selectedCircle,
]}>
<Text style={[
styles.dayText,
!sameMonth && styles.dayTextMuted,
today && styles.todayText,
selected && !today && styles.selectedText,
]}>
{format(d, 'd')}
</Text>
</View>
{dayEvents.length > 0 && (
<View style={styles.dotsRow}>
{visibleDots.map((event, idx) => (
<View
key={`${event.id}-${idx}`}
style={[styles.dot, { backgroundColor: getEventColor(event, calendars) }]}
/>
))}
{overflow > 0 && (
<Text style={styles.overflowText}>+{overflow}</Text>
)}
</View>
)}
</Pressable>
);
})}
</View>
{showWeekNumbers ? (
rows.map((row, rowIdx) => (
<View key={rowIdx} style={styles.weekRow}>
<Text style={styles.weekNumberCell}>{getWeek(row[0], { weekStartsOn })}</Text>
{row.map((d, i) => renderDay(d, `${rowIdx}-${i}`, true))}
</View>
))
) : (
<View style={styles.daysGrid}>
{days.map((d, i) => renderDay(d, i))}
</View>
)}
</View>
);
}
@@ -134,7 +160,22 @@ function makeStyles(c: ThemePalette) {
color: c.textMuted,
},
daysGrid: { flexDirection: 'row', flexWrap: 'wrap' },
weekRow: { flexDirection: 'row', alignItems: 'center' },
weekNumberLabel: {
width: 24,
textAlign: 'center',
...typography.small,
color: c.textMuted,
},
weekNumberCell: {
width: 24,
textAlign: 'center',
...typography.caption,
color: c.textMuted,
paddingVertical: 4,
},
dayCell: { width: '14.28%', alignItems: 'center', paddingVertical: 4 },
dayCellFlex: { width: undefined, flex: 1 },
dayNumber: {
width: 36,
height: 36,
+5 -1
View File
@@ -38,6 +38,7 @@ interface WeekViewProps {
onSelectEvent?: (event: CalendarEvent) => void;
onCreateAtTime?: (date: Date) => void;
weekStartsOn?: 0 | 1;
timeFormat?: '12h' | '24h';
}
function WeekViewInner({
@@ -49,6 +50,7 @@ function WeekViewInner({
onSelectEvent,
onCreateAtTime,
weekStartsOn = 0,
timeFormat = '24h',
}: WeekViewProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
@@ -224,7 +226,9 @@ function WeekViewInner({
<View key={h} style={[styles.hourLabelCell, { height: HOUR_HEIGHT }]}>
{h > 0 && (
<Text style={styles.hourLabel}>
{h.toString().padStart(2, '0')}:00
{timeFormat === '12h'
? `${((h % 12) || 12)} ${h < 12 ? 'AM' : 'PM'}`
: `${h.toString().padStart(2, '0')}:00`}
</Text>
)}
</View>
+234 -48
View File
@@ -1,12 +1,25 @@
import React, { useEffect } from 'react';
import { View, Text, StyleSheet, ActivityIndicator } from 'react-native';
import React, { useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
Modal,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import {
Folder, Inbox, Send, FileText, Trash, ShieldAlert, Archive,
Plus, Pencil, Trash2, X,
} from 'lucide-react-native';
import { SettingsSection, SettingItem } from './settings-section';
import { SettingsSection } from './settings-section';
import Button from '../Button';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useEmailStore } from '../../stores/email-store';
import { createMailbox, updateMailbox, deleteMailbox } from '../../api/email';
import type { Mailbox } from '../../api/types';
const ROLE_LABEL: Record<string, string> = {
@@ -23,12 +36,21 @@ function getIcon(mb: Mailbox) {
return Folder;
}
type Editor =
| { kind: 'create' }
| { kind: 'rename'; mailbox: Mailbox };
export function FolderSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const mailboxes = useEmailStore((s) => s.mailboxes);
const fetchMailboxes = useEmailStore((s) => s.fetchMailboxes);
const [editor, setEditor] = useState<Editor | null>(null);
const [draftName, setDraftName] = useState('');
const [busyId, setBusyId] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (mailboxes.length === 0) void fetchMailboxes();
}, [mailboxes.length, fetchMailboxes]);
@@ -42,12 +64,98 @@ export function FolderSettings() {
const totalUnread = mailboxes.reduce((sum, m) => sum + (m.unreadEmails ?? 0), 0);
const openCreate = () => {
setEditor({ kind: 'create' });
setDraftName('');
};
const openRename = (mailbox: Mailbox) => {
if (mailbox.role) {
Alert.alert('Cannot rename', 'System folders cannot be renamed.');
return;
}
setEditor({ kind: 'rename', mailbox });
setDraftName(mailbox.name);
};
const closeEditor = () => setEditor(null);
const saveDraft = async () => {
const name = draftName.trim();
if (!name) {
Alert.alert('Name required');
return;
}
setSaving(true);
try {
if (editor?.kind === 'create') {
await createMailbox({ name });
} else if (editor?.kind === 'rename') {
await updateMailbox(editor.mailbox.id, { name });
}
closeEditor();
await fetchMailboxes();
} catch (err) {
Alert.alert('Save failed', err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
}
};
const confirmDelete = (mailbox: Mailbox) => {
if (mailbox.role) {
Alert.alert('Cannot delete', 'System folders cannot be removed.');
return;
}
if ((mailbox.totalEmails ?? 0) > 0) {
Alert.alert(
'Folder not empty',
`"${mailbox.name}" contains ${mailbox.totalEmails} email${mailbox.totalEmails === 1 ? '' : 's'}. Delete anyway?`,
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'Delete', style: 'destructive', onPress: () => { void performDelete(mailbox); } },
],
);
return;
}
Alert.alert(
'Delete folder',
`Permanently delete "${mailbox.name}"?`,
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'Delete', style: 'destructive', onPress: () => { void performDelete(mailbox); } },
],
);
};
const performDelete = async (mailbox: Mailbox) => {
setBusyId(mailbox.id);
try {
await deleteMailbox(mailbox.id);
await fetchMailboxes();
} catch (err) {
Alert.alert('Delete failed', err instanceof Error ? err.message : String(err));
} finally {
setBusyId(null);
}
};
return (
<View style={styles.container}>
<SettingsSection
title="Folders"
description={`Your mail folders. ${mailboxes.length} folder${mailboxes.length === 1 ? '' : 's'}, ${totalUnread} unread.`}
description={`${mailboxes.length} folder${mailboxes.length === 1 ? '' : 's'}, ${totalUnread} unread. Tap a folder to rename, long-press to delete.`}
>
<View style={styles.headerRow}>
<Button
variant="default"
size="sm"
onPress={openCreate}
icon={<Plus size={14} color={c.primaryForeground} />}
>
New folder
</Button>
</View>
{mailboxes.length === 0 ? (
<View style={styles.loading}>
<ActivityIndicator size="small" color={c.primary} />
@@ -56,13 +164,20 @@ export function FolderSettings() {
<View>
{sorted.map((mb) => {
const Icon = getIcon(mb);
const editable = !mb.role;
return (
<View key={mb.id} style={styles.folderRow}>
<Pressable
key={mb.id}
onPress={() => editable && openRename(mb)}
onLongPress={() => editable && confirmDelete(mb)}
disabled={!editable && busyId !== mb.id}
style={({ pressed }) => [
styles.folderRow,
editable && pressed && styles.folderRowPressed,
]}
>
<View style={styles.folderLeft}>
<Icon
size={16}
color={mb.role ? c.primary : c.mutedForeground}
/>
<Icon size={16} color={mb.role ? c.primary : c.mutedForeground} />
<Text style={styles.folderName} numberOfLines={1}>{mb.name}</Text>
{mb.role && ROLE_LABEL[mb.role] && (
<View style={styles.rolePill}>
@@ -77,56 +192,127 @@ export function FolderSettings() {
</View>
)}
<Text style={styles.total}>{mb.totalEmails}</Text>
{editable && (
busyId === mb.id ? (
<ActivityIndicator size="small" color={c.primary} />
) : (
<Pencil size={14} color={c.textMuted} />
)
)}
</View>
</View>
</Pressable>
);
})}
</View>
)}
</SettingsSection>
<SettingsSection
title="Management"
description="Creating, renaming and deleting folders is not yet available in the mobile app. Use the web client."
>
<SettingItem label="Read-only">
<Text style={styles.caption}>Folder list shown above is live.</Text>
</SettingItem>
</SettingsSection>
<Modal visible={!!editor} animationType="slide" transparent onRequestClose={closeEditor}>
<View style={styles.modalOverlay}>
<View style={styles.modalSheet}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>
{editor?.kind === 'rename' ? `Rename "${editor.mailbox.name}"` : 'New folder'}
</Text>
<Pressable onPress={closeEditor} hitSlop={8}>
<X size={20} color={c.text} />
</Pressable>
</View>
<ScrollView contentContainerStyle={styles.modalBody}>
<Text style={styles.fieldLabel}>Folder name</Text>
<TextInput
value={draftName}
onChangeText={setDraftName}
placeholder="Receipts"
placeholderTextColor={c.textMuted}
style={styles.input}
autoFocus
/>
{editor?.kind === 'rename' && (
<Pressable
onPress={() => {
closeEditor();
confirmDelete(editor.mailbox);
}}
style={styles.deleteRow}
>
<Trash2 size={14} color={c.error} />
<Text style={styles.deleteRowText}>Delete folder</Text>
</Pressable>
)}
</ScrollView>
<View style={styles.modalActions}>
<Button variant="outline" size="sm" onPress={closeEditor} disabled={saving}>Cancel</Button>
<Button
variant="default"
size="sm"
onPress={() => { void saveDraft(); }}
loading={saving}
>
Save
</Button>
</View>
</View>
</View>
</Modal>
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { gap: spacing.xxxl },
loading: { paddingVertical: 40, alignItems: 'center' },
folderRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
borderRadius: radius.sm,
},
folderLeft: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, flex: 1 },
folderName: { ...typography.body, color: c.text, flexShrink: 1 },
rolePill: {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: radius.full,
backgroundColor: c.primaryBg,
},
rolePillText: { fontSize: 10, fontWeight: '500', color: c.primary },
folderRight: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
unreadBadge: {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: radius.full,
backgroundColor: c.primary,
},
unreadText: { fontSize: 10, fontWeight: '500', color: c.primaryForeground },
total: { ...typography.caption, color: c.mutedForeground, minWidth: 32, textAlign: 'right' },
caption: { ...typography.caption, color: c.mutedForeground },
});
container: { gap: spacing.xxxl },
headerRow: { flexDirection: 'row', justifyContent: 'flex-end', paddingVertical: spacing.sm },
loading: { paddingVertical: 40, alignItems: 'center' },
folderRow: {
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
paddingVertical: spacing.sm, paddingHorizontal: spacing.md,
borderRadius: radius.sm,
},
folderRowPressed: { backgroundColor: c.muted },
folderLeft: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, flex: 1 },
folderName: { ...typography.body, color: c.text, flexShrink: 1 },
rolePill: {
paddingHorizontal: 6, paddingVertical: 2, borderRadius: radius.full,
backgroundColor: c.primaryBg,
},
rolePillText: { fontSize: 10, fontWeight: '500', color: c.primary },
folderRight: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
unreadBadge: {
paddingHorizontal: 6, paddingVertical: 2, borderRadius: radius.full,
backgroundColor: c.primary,
},
unreadText: { fontSize: 10, fontWeight: '500', color: c.primaryForeground },
total: { ...typography.caption, color: c.mutedForeground, minWidth: 32, textAlign: 'right' },
modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' },
modalSheet: {
backgroundColor: c.background,
borderTopLeftRadius: radius.lg, borderTopRightRadius: radius.lg,
maxHeight: '80%',
},
modalHeader: {
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
paddingHorizontal: spacing.lg, paddingVertical: spacing.md,
borderBottomWidth: 1, borderBottomColor: c.border,
},
modalTitle: { ...typography.h3, color: c.text, flexShrink: 1 },
modalBody: { padding: spacing.lg, gap: spacing.md },
fieldLabel: { ...typography.captionMedium, color: c.textSecondary },
input: {
...typography.body, color: c.text,
backgroundColor: c.surface,
borderWidth: 1, borderColor: c.border, borderRadius: radius.sm,
paddingHorizontal: spacing.md, paddingVertical: 10,
},
deleteRow: {
flexDirection: 'row', alignItems: 'center', gap: spacing.sm,
paddingVertical: spacing.md,
},
deleteRowText: { ...typography.body, color: c.error },
modalActions: {
flexDirection: 'row', justifyContent: 'flex-end', gap: spacing.sm,
padding: spacing.lg, borderTopWidth: 1, borderTopColor: c.border,
},
});
}
+259 -62
View File
@@ -1,10 +1,51 @@
import React, { useEffect } from 'react';
import { View, Text, StyleSheet, ActivityIndicator } from 'react-native';
import { SettingsSection, SettingItem } from './settings-section';
import React, { useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
Modal,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { Plus, Trash2, X } from 'lucide-react-native';
import { SettingsSection } from './settings-section';
import Button from '../Button';
import { typography, spacing, radius, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useSettingsStore } from '../../stores/settings-store';
import {
createIdentity,
deleteIdentity,
updateIdentity,
} from '../../api/identity';
import type { Identity } from '../../api/types';
type DraftIdentity = {
id: string;
name: string;
email: string;
textSignature: string;
mayDelete: boolean;
};
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function toDraft(identity: Identity): DraftIdentity {
return {
id: identity.id,
name: identity.name ?? '',
email: identity.email,
textSignature: identity.textSignature ?? '',
mayDelete: identity.mayDelete,
};
}
function emptyDraft(): DraftIdentity {
return { id: '', name: '', email: '', textSignature: '', mayDelete: true };
}
export function IdentitySettings() {
const c = useColors();
@@ -14,31 +55,100 @@ export function IdentitySettings() {
const error = useSettingsStore((s) => s.error);
const fetchIdentities = useSettingsStore((s) => s.fetchIdentities);
const [editing, setEditing] = useState<DraftIdentity | null>(null);
const [saving, setSaving] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
useEffect(() => {
void fetchIdentities();
}, [fetchIdentities]);
const count = identities.length;
const countLabel =
count === 0 ? 'No identities' : count === 1 ? '1 identity' : `${count} identities`;
const openCreate = () => setEditing(emptyDraft());
const openEdit = (i: Identity) => setEditing(toDraft(i));
const closeEditor = () => setEditing(null);
const saveDraft = async () => {
if (!editing) return;
const name = editing.name.trim();
const email = editing.email.trim();
if (!email || !EMAIL_RE.test(email)) {
Alert.alert('Invalid email', 'Enter a valid email address for this identity.');
return;
}
setSaving(true);
try {
if (editing.id) {
// JMAP doesn't allow changing `email` on an existing identity, so we
// only PATCH the editable fields. The server will reject email
// changes; the form disables that input below to make this obvious.
await updateIdentity(editing.id, {
name,
textSignature: editing.textSignature,
});
} else {
await createIdentity({
name,
email,
textSignature: editing.textSignature || undefined,
});
}
closeEditor();
await fetchIdentities();
} catch (err) {
Alert.alert('Save failed', err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
}
};
const confirmDelete = (identity: Identity) => {
if (!identity.mayDelete) {
Alert.alert('Cannot delete', 'The primary identity cannot be removed.');
return;
}
Alert.alert(
'Delete identity',
`Remove "${identity.name || identity.email}"?`,
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: async () => {
setDeletingId(identity.id);
try {
await deleteIdentity(identity.id);
await fetchIdentities();
} catch (err) {
Alert.alert('Delete failed', err instanceof Error ? err.message : String(err));
} finally {
setDeletingId(null);
}
},
},
],
);
};
return (
<SettingsSection
title="Identities"
description="Manage sender names, email addresses, and signatures."
description="Manage sender names, email addresses, and signatures. Tap a row to edit."
>
<SettingItem
label="Identities"
description="Your configured sender identities."
>
<View style={styles.row}>
{loading ? (
<ActivityIndicator size="small" color={c.primary} />
) : (
<Text style={styles.count}>{countLabel}</Text>
)}
</View>
</SettingItem>
<View style={styles.headerRow}>
<Text style={styles.count}>
{loading ? 'Loading…' : identities.length === 1 ? '1 identity' : `${identities.length} identities`}
</Text>
<Button
variant="default"
size="sm"
onPress={openCreate}
icon={<Plus size={14} color={c.primaryForeground} />}
disabled={loading}
>
New
</Button>
</View>
{error && (
<View style={styles.errorBox}>
@@ -47,63 +157,150 @@ export function IdentitySettings() {
)}
{identities.map((identity) => (
<View key={identity.id} style={styles.identityRow}>
<Pressable
key={identity.id}
onPress={() => openEdit(identity)}
style={({ pressed }) => [styles.identityRow, pressed && styles.identityRowPressed]}
>
<View style={{ flex: 1 }}>
<Text style={styles.identityName}>{identity.name || '(no name)'}</Text>
<Text style={styles.identityEmail}>{identity.email}</Text>
</View>
{!identity.mayDelete && (
{!identity.mayDelete ? (
<View style={styles.badge}>
<Text style={styles.badgeText}>primary</Text>
</View>
) : (
<Pressable
onPress={() => confirmDelete(identity)}
hitSlop={8}
style={styles.identityDelete}
disabled={deletingId === identity.id}
>
{deletingId === identity.id ? (
<ActivityIndicator size="small" color={c.error} />
) : (
<Trash2 size={16} color={c.error} />
)}
</Pressable>
)}
</View>
</Pressable>
))}
<SettingItem
label="Sub-addressing"
description="Use plus-addressing (you+tag@example.com) to route mail."
>
<Button variant="outline" size="sm">Learn More</Button>
</SettingItem>
<Modal visible={!!editing} animationType="slide" transparent onRequestClose={closeEditor}>
<View style={styles.modalOverlay}>
<View style={styles.modalSheet}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>
{editing?.id ? 'Edit identity' : 'New identity'}
</Text>
<Pressable onPress={closeEditor} hitSlop={8}>
<X size={20} color={c.text} />
</Pressable>
</View>
<ScrollView contentContainerStyle={styles.modalBody}>
<Text style={styles.fieldLabel}>Display name</Text>
<TextInput
value={editing?.name ?? ''}
onChangeText={(name) => setEditing((d) => (d ? { ...d, name } : d))}
placeholder="Jane Doe"
placeholderTextColor={c.textMuted}
style={styles.input}
/>
<Text style={styles.fieldLabel}>Email address</Text>
<TextInput
value={editing?.email ?? ''}
onChangeText={(email) => setEditing((d) => (d ? { ...d, email } : d))}
placeholder="jane@example.com"
placeholderTextColor={c.textMuted}
style={[styles.input, !!editing?.id && styles.inputDisabled]}
editable={!editing?.id}
autoCapitalize="none"
autoCorrect={false}
keyboardType="email-address"
/>
{!!editing?.id && (
<Text style={styles.hint}>JMAP does not allow changing an identity&apos;s email; create a new one instead.</Text>
)}
<Text style={styles.fieldLabel}>Plain-text signature</Text>
<TextInput
value={editing?.textSignature ?? ''}
onChangeText={(textSignature) => setEditing((d) => (d ? { ...d, textSignature } : d))}
placeholder="--&#10;Jane Doe&#10;Bulwark Mail"
placeholderTextColor={c.textMuted}
multiline
style={[styles.input, styles.bodyInput]}
/>
</ScrollView>
<View style={styles.modalActions}>
<Button variant="outline" size="sm" onPress={closeEditor} disabled={saving}>Cancel</Button>
<Button
variant="default"
size="sm"
onPress={() => { void saveDraft(); }}
loading={saving}
>
Save
</Button>
</View>
</View>
</View>
</Modal>
</SettingsSection>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
},
count: {
...typography.body,
color: c.text,
},
errorBox: {
padding: spacing.md,
borderRadius: radius.sm,
backgroundColor: c.errorBg,
},
errorText: { ...typography.caption, color: c.error },
identityRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingVertical: spacing.md,
paddingHorizontal: spacing.md,
borderRadius: radius.sm,
backgroundColor: c.muted,
},
identityName: { ...typography.bodyMedium, color: c.text },
identityEmail: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
badge: {
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: radius.full,
backgroundColor: 'rgba(59,130,246,0.15)',
},
badgeText: { fontSize: 10, fontWeight: '500', color: c.primary },
});
headerRow: {
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
paddingVertical: spacing.sm,
},
count: { ...typography.body, color: c.mutedForeground },
errorBox: { padding: spacing.md, borderRadius: radius.sm, backgroundColor: c.errorBg },
errorText: { ...typography.caption, color: c.error },
identityRow: {
flexDirection: 'row', alignItems: 'center', gap: spacing.md,
paddingVertical: spacing.md, paddingHorizontal: spacing.md,
borderRadius: radius.sm, backgroundColor: c.muted,
marginTop: spacing.xs,
},
identityRowPressed: { backgroundColor: c.surfaceHover },
identityName: { ...typography.bodyMedium, color: c.text },
identityEmail: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
identityDelete: { padding: 6 },
badge: {
paddingHorizontal: 6, paddingVertical: 2, borderRadius: radius.full,
backgroundColor: 'rgba(59,130,246,0.15)',
},
badgeText: { fontSize: 10, fontWeight: '500', color: c.primary },
modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'flex-end' },
modalSheet: {
backgroundColor: c.background,
borderTopLeftRadius: radius.lg, borderTopRightRadius: radius.lg,
maxHeight: '90%',
},
modalHeader: {
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
paddingHorizontal: spacing.lg, paddingVertical: spacing.md,
borderBottomWidth: 1, borderBottomColor: c.border,
},
modalTitle: { ...typography.h3, color: c.text },
modalBody: { padding: spacing.lg, gap: spacing.sm },
fieldLabel: { ...typography.captionMedium, color: c.textSecondary, marginTop: spacing.sm },
input: {
...typography.body, color: c.text,
backgroundColor: c.surface,
borderWidth: 1, borderColor: c.border, borderRadius: radius.sm,
paddingHorizontal: spacing.md, paddingVertical: 10,
},
inputDisabled: { opacity: 0.6 },
bodyInput: { minHeight: 100, textAlignVertical: 'top' },
hint: { ...typography.caption, color: c.mutedForeground },
modalActions: {
flexDirection: 'row', justifyContent: 'flex-end', gap: spacing.sm,
padding: spacing.lg, borderTopWidth: 1, borderTopColor: c.border,
},
});
}
+275 -22
View File
@@ -1,60 +1,313 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { FileText, Download, Upload } from 'lucide-react-native';
import React, { useEffect, useState } from 'react';
import {
Alert,
Modal,
Pressable,
ScrollView,
Share,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { Download, FileText, Plus, Trash2, Upload, X } from 'lucide-react-native';
import { SettingsSection } from './settings-section';
import Button from '../Button';
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useTemplatesStore, type EmailTemplate } from '../../stores/templates-store';
export function TemplateSettings() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const [templates] = useState<{ id: string; name: string }[]>([]);
const templates = useTemplatesStore((s) => s.templates);
const hydrated = useTemplatesStore((s) => s.hydrated);
const hydrate = useTemplatesStore((s) => s.hydrate);
const addTemplate = useTemplatesStore((s) => s.addTemplate);
const updateTemplate = useTemplatesStore((s) => s.updateTemplate);
const deleteTemplate = useTemplatesStore((s) => s.deleteTemplate);
const exportAll = useTemplatesStore((s) => s.exportAll);
const importTemplates = useTemplatesStore((s) => s.importTemplates);
const [editing, setEditing] = useState<EmailTemplate | null>(null);
const [importVisible, setImportVisible] = useState(false);
const [importText, setImportText] = useState('');
const [draftName, setDraftName] = useState('');
const [draftSubject, setDraftSubject] = useState('');
const [draftBody, setDraftBody] = useState('');
useEffect(() => {
if (!hydrated) void hydrate();
}, [hydrated, hydrate]);
const openCreate = () => {
setEditing({
id: '',
name: '',
subject: '',
body: '',
category: '',
isFavorite: false,
createdAt: '',
updatedAt: '',
});
setDraftName('');
setDraftSubject('');
setDraftBody('');
};
const openEdit = (t: EmailTemplate) => {
setEditing(t);
setDraftName(t.name);
setDraftSubject(t.subject);
setDraftBody(t.body);
};
const closeEditor = () => setEditing(null);
const saveDraft = () => {
const name = draftName.trim();
if (!name) {
Alert.alert('Template name required');
return;
}
if (!editing) return;
if (editing.id) {
updateTemplate(editing.id, { name, subject: draftSubject, body: draftBody });
} else {
addTemplate({ name, subject: draftSubject, body: draftBody });
}
closeEditor();
};
const confirmDelete = (t: EmailTemplate) => {
Alert.alert(
'Delete template',
`Permanently delete "${t.name}"?`,
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'Delete', style: 'destructive', onPress: () => deleteTemplate(t.id) },
],
);
};
const exportTemplatesAsShare = async () => {
const json = exportAll();
try {
await Share.share({ message: json, title: 'Templates export' });
} catch (err) {
Alert.alert('Export failed', err instanceof Error ? err.message : 'Unable to share');
}
};
const performImport = () => {
const result = importTemplates(importText);
if (result.error) {
Alert.alert('Import failed', result.error);
return;
}
setImportText('');
setImportVisible(false);
Alert.alert('Imported', `${result.count} template${result.count === 1 ? '' : 's'} added.`);
};
return (
<View style={styles.container}>
<SettingsSection title="Templates" description="Reusable email templates for common responses.">
<View style={styles.row}>
<SettingsSection title="Templates" description="Reusable snippets for common replies. Tap one to edit; long-press to delete.">
<View style={styles.headerRow}>
<View style={styles.countRow}>
<FileText size={16} color={c.mutedForeground} />
<Text style={styles.count}>
{templates.length === 1 ? '1 template' : `${templates.length} templates`}
</Text>
</View>
<Button variant="outline" size="sm">Manage</Button>
<Button variant="default" size="sm" onPress={openCreate} icon={<Plus size={14} color={c.primaryForeground} />}>
New
</Button>
</View>
{templates.length === 0 ? (
<Text style={styles.emptyHint}>No templates yet. Tap "New" to add one.</Text>
) : (
<View style={styles.list}>
{templates.map((t) => (
<Pressable
key={t.id}
onPress={() => openEdit(t)}
onLongPress={() => confirmDelete(t)}
style={({ pressed }) => [styles.listRow, pressed && styles.listRowPressed]}
>
<View style={styles.listRowText}>
<Text style={styles.listRowName} numberOfLines={1}>{t.name}</Text>
{!!t.subject && (
<Text style={styles.listRowSubject} numberOfLines={1}>{t.subject}</Text>
)}
</View>
<Pressable onPress={() => confirmDelete(t)} hitSlop={8} style={styles.listRowDelete}>
<Trash2 size={16} color={c.error} />
</Pressable>
</Pressable>
))}
</View>
)}
</SettingsSection>
<SettingsSection title="Export / Import" description="Back up or restore your templates.">
<SettingsSection title="Export / Import" description="Back up or restore your templates as JSON.">
<View style={styles.actions}>
<Button
variant="outline"
size="sm"
disabled={templates.length === 0}
icon={<Download size={14} color={c.text} />}
onPress={() => { void exportTemplatesAsShare(); }}
>
Export
Share JSON
</Button>
<Button variant="outline" size="sm" icon={<Upload size={14} color={c.text} />}>
<Button
variant="outline"
size="sm"
icon={<Upload size={14} color={c.text} />}
onPress={() => setImportVisible(true)}
>
Import
</Button>
</View>
</SettingsSection>
{/* Edit/Create modal */}
<Modal visible={!!editing} animationType="slide" transparent onRequestClose={closeEditor}>
<View style={styles.modalOverlay}>
<View style={styles.modalSheet}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>
{editing?.id ? 'Edit template' : 'New template'}
</Text>
<Pressable onPress={closeEditor} hitSlop={8}>
<X size={20} color={c.text} />
</Pressable>
</View>
<ScrollView contentContainerStyle={styles.modalBody}>
<Text style={styles.fieldLabel}>Name</Text>
<TextInput
value={draftName}
onChangeText={setDraftName}
placeholder="Quick reply"
placeholderTextColor={c.textMuted}
style={styles.input}
/>
<Text style={styles.fieldLabel}>Subject</Text>
<TextInput
value={draftSubject}
onChangeText={setDraftSubject}
placeholder="(optional)"
placeholderTextColor={c.textMuted}
style={styles.input}
/>
<Text style={styles.fieldLabel}>Body</Text>
<TextInput
value={draftBody}
onChangeText={setDraftBody}
placeholder="Hi {recipient_name}, …"
placeholderTextColor={c.textMuted}
multiline
style={[styles.input, styles.bodyInput]}
/>
</ScrollView>
<View style={styles.modalActions}>
<Button variant="outline" size="sm" onPress={closeEditor}>Cancel</Button>
<Button variant="default" size="sm" onPress={saveDraft}>Save</Button>
</View>
</View>
</View>
</Modal>
{/* Import modal */}
<Modal visible={importVisible} animationType="slide" transparent onRequestClose={() => setImportVisible(false)}>
<View style={styles.modalOverlay}>
<View style={styles.modalSheet}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>Import templates</Text>
<Pressable onPress={() => setImportVisible(false)} hitSlop={8}>
<X size={20} color={c.text} />
</Pressable>
</View>
<ScrollView contentContainerStyle={styles.modalBody}>
<Text style={styles.fieldLabel}>JSON</Text>
<TextInput
value={importText}
onChangeText={setImportText}
placeholder='{"templates": [...]}'
placeholderTextColor={c.textMuted}
multiline
style={[styles.input, styles.bodyInput]}
autoCapitalize="none"
autoCorrect={false}
/>
</ScrollView>
<View style={styles.modalActions}>
<Button variant="outline" size="sm" onPress={() => setImportVisible(false)}>Cancel</Button>
<Button variant="default" size="sm" onPress={performImport} disabled={!importText.trim()}>
Import
</Button>
</View>
</View>
</View>
</Modal>
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { gap: spacing.xxxl },
row: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: spacing.md,
},
countRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
count: { ...typography.body, color: c.mutedForeground },
actions: { flexDirection: 'row', gap: spacing.md, paddingVertical: spacing.md },
});
container: { gap: spacing.xxxl },
headerRow: {
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
paddingVertical: spacing.md,
},
countRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
count: { ...typography.body, color: c.mutedForeground },
emptyHint: { ...typography.caption, color: c.mutedForeground, paddingVertical: spacing.md },
list: { gap: spacing.sm },
listRow: {
flexDirection: 'row', alignItems: 'center',
borderWidth: 1, borderColor: c.border, borderRadius: radius.md,
padding: spacing.md, gap: spacing.md,
},
listRowPressed: { backgroundColor: c.surface },
listRowText: { flex: 1 },
listRowName: { ...typography.bodyMedium, color: c.text },
listRowSubject: { ...typography.caption, color: c.mutedForeground, marginTop: 2 },
listRowDelete: { padding: 4 },
actions: { flexDirection: 'row', gap: spacing.md, paddingVertical: spacing.md, flexWrap: 'wrap' },
modalOverlay: {
flex: 1, backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'flex-end',
},
modalSheet: {
backgroundColor: c.background,
borderTopLeftRadius: radius.lg, borderTopRightRadius: radius.lg,
maxHeight: '90%',
},
modalHeader: {
flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between',
paddingHorizontal: spacing.lg, paddingVertical: spacing.md,
borderBottomWidth: 1, borderBottomColor: c.border,
},
modalTitle: { ...typography.h3, color: c.text },
modalBody: { padding: spacing.lg, gap: spacing.sm },
fieldLabel: { ...typography.captionMedium, color: c.textSecondary, marginTop: spacing.sm },
input: {
...typography.body, color: c.text,
backgroundColor: c.surface,
borderWidth: 1, borderColor: c.border, borderRadius: radius.sm,
paddingHorizontal: spacing.md, paddingVertical: 10,
},
bodyInput: { minHeight: 160, textAlignVertical: 'top' },
modalActions: {
flexDirection: 'row', justifyContent: 'flex-end', gap: spacing.sm,
padding: spacing.lg,
borderTopWidth: 1, borderTopColor: c.border,
},
});
}
+34 -14
View File
@@ -1,4 +1,3 @@
import { Platform } from 'react-native';
import { File, Paths } from 'expo-file-system';
import * as Sharing from 'expo-sharing';
import { jmapClient } from '../api/jmap-client';
@@ -7,9 +6,7 @@ import { getDownloadUrl } from '../api/blob';
const RFC822 = 'message/rfc822';
function authHeader(): string {
const h = (jmapClient as unknown as { authHeader?: string }).authHeader;
if (!h) throw new Error('Not connected');
return h;
return jmapClient.authHeader;
}
function safeAttachmentName(name: string | undefined, type: string | undefined): string {
@@ -19,14 +16,10 @@ function safeAttachmentName(name: string | undefined, type: string | undefined):
return `attachment.${fallbackExt}`;
}
// On Android, sharing a `file://` URI from the cache directory throws a
// FileUriExposedException on modern OS versions; the system requires a
// FileProvider-backed `content://` URI instead. expo-file-system exposes that
// via `file.contentUri`, which is the only field iOS doesn't expose.
function shareableUri(file: { uri: string; contentUri?: string }): string {
if (Platform.OS === 'android' && file.contentUri) return file.contentUri;
return file.uri;
}
// expo-sharing only accepts `file://` URLs and rejects `content://` with
// "Only local file URLs are supported". On Android it then wraps the file
// itself with its bundled SharingFileProvider before launching the share
// intent, so we must NOT pre-translate to `file.contentUri`.
export async function shareAttachment(
blobId: string,
@@ -44,7 +37,34 @@ export async function shareAttachment(
if (!(await Sharing.isAvailableAsync())) {
throw new Error('Sharing is not available on this device');
}
await Sharing.shareAsync(shareableUri(downloaded), {
await Sharing.shareAsync(downloaded.uri, {
mimeType,
dialogTitle: filename,
});
}
// Save-to-disk variant. iOS doesn't expose a user-visible "Downloads" folder,
// so on both platforms we land the file in the document directory and hand it
// to the share sheet — which on iOS surfaces "Save to Files" and on Android
// surfaces the system save dialog. The 'preview' counterpart (shareAttachment)
// uses cache + share, which on most viewers opens directly without prompting.
export async function downloadAttachment(
blobId: string,
name: string | undefined,
type: string | undefined,
): Promise<void> {
const filename = safeAttachmentName(name, type);
const mimeType = type || 'application/octet-stream';
const dest = new File(Paths.document, filename);
const url = getDownloadUrl(blobId, filename, mimeType);
const downloaded = await File.downloadFileAsync(url, dest, {
headers: { Authorization: authHeader() },
idempotent: true,
});
if (!(await Sharing.isAvailableAsync())) {
throw new Error('Sharing is not available on this device');
}
await Sharing.shareAsync(downloaded.uri, {
mimeType,
dialogTitle: filename,
});
@@ -72,7 +92,7 @@ export async function shareEmailEml(blobId: string, subject?: string): Promise<v
if (!(await Sharing.isAvailableAsync())) {
throw new Error('Sharing is not available on this device');
}
await Sharing.shareAsync(shareableUri(downloaded), {
await Sharing.shareAsync(downloaded.uri, {
mimeType: RFC822,
dialogTitle: 'Share email',
UTI: 'public.email-message',
+21
View File
@@ -11,6 +11,25 @@ import {
const PUSH_ACCOUNT_ID_KEY = 'push:accountId:v1';
const LAST_NOTIFIED_EMAIL_ID_KEY = 'push:lastNotifiedEmailId:v1';
// Mirrors STORAGE_KEY in `stores/settings-store.ts`. The headless task can't
// import the Zustand store (would pull in React) so we read AsyncStorage
// directly. Keep this in sync if the store key ever changes.
const SETTINGS_STORAGE_KEY = 'webmail:settings:v1';
interface PushPersistedSettings {
emailNotificationsEnabled?: boolean;
}
async function emailNotificationsAllowed(): Promise<boolean> {
try {
const raw = await AsyncStorage.getItem(SETTINGS_STORAGE_KEY);
if (!raw) return true; // first launch: default to on
const parsed = JSON.parse(raw) as PushPersistedSettings;
return parsed.emailNotificationsEnabled !== false;
} catch {
return true;
}
}
interface ShowNotificationOptions {
notificationId: string;
@@ -33,6 +52,8 @@ interface BulwarkFcmNative {
// resolve so the native service can release its wake lock.
export async function pushBackgroundTask(_data: unknown): Promise<void> {
try {
if (!(await emailNotificationsAllowed())) return;
const accountId = await AsyncStorage.getItem(PUSH_ACCOUNT_ID_KEY);
if (!accountId) return;
+36 -12
View File
@@ -35,6 +35,7 @@ import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'
import { useColors } from '../theme/colors';
import { Button } from '../components';
import { useCalendarStore } from '../stores/calendar-store';
import { useSettingsStore } from '../stores/settings-store';
import { MonthView } from '../components/calendar/MonthView';
import { WeekView } from '../components/calendar/WeekView';
import { AgendaView } from '../components/calendar/AgendaView';
@@ -62,15 +63,19 @@ type PendingAction =
const AGENDA_DAYS = 30;
const RANGE_BUFFER_DAYS = 14;
function rangeForView(viewMode: ViewMode, currentDate: Date): { after: Date; before: Date } {
function rangeForView(
viewMode: ViewMode,
currentDate: Date,
weekStartsOn: 0 | 1,
): { after: Date; before: Date } {
if (viewMode === 'month') {
const start = startOfWeek(startOfMonth(currentDate));
const end = endOfWeek(endOfMonth(currentDate));
const start = startOfWeek(startOfMonth(currentDate), { weekStartsOn });
const end = endOfWeek(endOfMonth(currentDate), { weekStartsOn });
return { after: addDays(start, -RANGE_BUFFER_DAYS), before: addDays(end, RANGE_BUFFER_DAYS) };
}
if (viewMode === 'week') {
const start = startOfWeek(currentDate);
const end = endOfWeek(currentDate);
const start = startOfWeek(currentDate, { weekStartsOn });
const end = endOfWeek(currentDate, { weekStartsOn });
return { after: addDays(start, -RANGE_BUFFER_DAYS), before: addDays(end, RANGE_BUFFER_DAYS) };
}
return {
@@ -79,11 +84,15 @@ function rangeForView(viewMode: ViewMode, currentDate: Date): { after: Date; bef
};
}
function headerTitle(viewMode: ViewMode, currentDate: Date): string {
function headerTitle(
viewMode: ViewMode,
currentDate: Date,
weekStartsOn: 0 | 1,
): string {
if (viewMode === 'month') return format(currentDate, 'MMMM yyyy');
if (viewMode === 'week') {
const start = startOfWeek(currentDate);
const end = endOfWeek(currentDate);
const start = startOfWeek(currentDate, { weekStartsOn });
const end = endOfWeek(currentDate, { weekStartsOn });
if (start.getMonth() === end.getMonth()) {
return `${format(start, 'MMM d')} – ${format(end, 'd, yyyy')}`;
}
@@ -95,9 +104,20 @@ function headerTitle(viewMode: ViewMode, currentDate: Date): string {
export default function CalendarScreen() {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const calendarDefaultView = useSettingsStore((s) => s.calendarDefaultView);
const calendarFirstDayOfWeek = useSettingsStore((s) => s.calendarFirstDayOfWeek);
const calendarShowWeekNumbers = useSettingsStore((s) => s.calendarShowWeekNumbers);
const calendarTimeFormat = useSettingsStore((s) => s.calendarTimeFormat);
const [currentDate, setCurrentDate] = React.useState(new Date());
const [selectedDate, setSelectedDate] = React.useState(new Date());
const [viewMode, setViewMode] = React.useState<ViewMode>('month');
// Day view falls back to Agenda on mobile (we don't have a dedicated day
// grid yet, but the agenda's 30-day view already serves the same purpose).
const initialViewMode: ViewMode =
calendarDefaultView === 'week' ? 'week'
: calendarDefaultView === 'day' || calendarDefaultView === 'agenda' ? 'agenda'
: 'month';
const [viewMode, setViewMode] = React.useState<ViewMode>(initialViewMode);
const [detailEvent, setDetailEvent] = React.useState<CalendarEvent | null>(null);
const [modalEvent, setModalEvent] = React.useState<CalendarEvent | null>(null);
@@ -144,9 +164,9 @@ export default function CalendarScreen() {
}, [hydrate, fetchCalendarsAction]);
React.useEffect(() => {
const { after, before } = rangeForView(viewMode, currentDate);
const { after, before } = rangeForView(viewMode, currentDate, calendarFirstDayOfWeek);
void ensureRange(after.toISOString(), before.toISOString());
}, [viewMode, currentDate, ensureRange]);
}, [viewMode, currentDate, ensureRange, calendarFirstDayOfWeek]);
const goPrev = React.useCallback(() => {
setCurrentDate((d) => {
@@ -267,7 +287,7 @@ export default function CalendarScreen() {
<Menu size={20} color={c.text} />
</Pressable>
<View style={styles.headerLeft}>
<Text style={styles.headerTitle}>{headerTitle(viewMode, currentDate)}</Text>
<Text style={styles.headerTitle}>{headerTitle(viewMode, currentDate, calendarFirstDayOfWeek)}</Text>
<Text style={styles.headerSubtitle}>
{isSelectedToday ? 'Today' : format(selectedDate, 'EEE, MMM d')}
</Text>
@@ -321,6 +341,8 @@ export default function CalendarScreen() {
events={events}
eventsByDay={eventsByDay}
calendars={calendars}
weekStartsOn={calendarFirstDayOfWeek}
showWeekNumbers={calendarShowWeekNumbers}
onSelectDate={handleSelectDate}
onLongPressDate={openCreate}
/>
@@ -331,6 +353,8 @@ export default function CalendarScreen() {
events={events}
eventsByDay={eventsByDay}
calendars={calendars}
weekStartsOn={calendarFirstDayOfWeek}
timeFormat={calendarTimeFormat}
onSelectDate={handleSelectDate}
onSelectEvent={setDetailEvent}
onCreateAtTime={openCreate}
+84 -4
View File
@@ -24,6 +24,7 @@ import RichTextEditor, {
import { useEmailStore } from '../stores/email-store';
import { useContactsStore } from '../stores/contacts-store';
import { useLocaleStore } from '../stores/locale-store';
import { useSettingsStore } from '../stores/settings-store';
import { isGroup } from '../lib/contact-utils';
import {
getContactDisplayName,
@@ -254,6 +255,12 @@ export default function ComposeScreen({ route, navigation }: Props) {
const [identities, setIdentities] = React.useState<Identity[]>([]);
const [identityError, setIdentityError] = React.useState<string | null>(null);
const [sending, setSending] = React.useState(false);
const [selectedIdentityId, setSelectedIdentityId] = React.useState<string | null>(null);
const autoSelectReplyIdentity = useSettingsStore((s) => s.autoSelectReplyIdentity);
const plainTextMode = useSettingsStore((s) => s.plainTextMode);
const attachmentReminderEnabled = useSettingsStore((s) => s.attachmentReminderEnabled);
const attachmentReminderKeywords = useSettingsStore((s) => s.attachmentReminderKeywords);
const initialTo = React.useMemo<Recipient[]>(() => {
if (!replyTo) {
@@ -381,7 +388,40 @@ export default function ComposeScreen({ route, navigation }: Props) {
return () => { cancelled = true; };
}, []);
const primaryIdentity = identities[0];
// Choose the identity once we know both the loaded identities and the reply
// context. With auto-select on, prefer the identity that received the
// original message (matches webmail's reply-identity behaviour).
React.useEffect(() => {
if (selectedIdentityId || identities.length === 0) return;
if (autoSelectReplyIdentity && replyTo) {
const candidates = [...(replyTo.to ?? []), ...(replyTo.cc ?? [])];
const candidateAddrs = new Set(
candidates.map((r) => r.email?.toLowerCase()).filter(Boolean) as string[],
);
const matched = identities.find((i) => candidateAddrs.has(i.email.toLowerCase()));
setSelectedIdentityId((matched ?? identities[0]).id);
} else {
setSelectedIdentityId(identities[0].id);
}
}, [identities, autoSelectReplyIdentity, replyTo, selectedIdentityId]);
const primaryIdentity =
identities.find((i) => i.id === selectedIdentityId) ?? identities[0];
const openIdentityPicker = () => {
if (identities.length <= 1) return;
Alert.alert(
t('email_composer.from', 'From'),
undefined,
[
...identities.map((i) => ({
text: i.name ? `${i.name} <${i.email}>` : i.email,
onPress: () => setSelectedIdentityId(i.id),
})),
{ text: t('email_composer.cancel', 'Cancel'), style: 'cancel' as const },
],
);
};
const commitTyped = () => {
const extraTo = parseRecipients(toInput);
@@ -664,8 +704,42 @@ export default function ComposeScreen({ route, navigation }: Props) {
};
// ── Send ─────────────────────────────────────────────────────────────
// Catch the classic "I forgot the attachment" footgun. Only file attachments
// count - inline images don't satisfy the user's intent of attaching a file.
// Returns true if it's safe to proceed, false if the user cancelled.
const passesAttachmentReminder = async (): Promise<boolean> => {
if (!attachmentReminderEnabled) return true;
const hasFileAttachment = attachments.some(
(a) => !a.inline && a.blobId && !a.error,
);
if (hasFileAttachment) return true;
const haystack = `${subject}\n${htmlToPlainText(bodyHtml)}`.toLowerCase();
const matchedKeyword = attachmentReminderKeywords.find((kw) => {
const k = kw.trim().toLowerCase();
if (!k) return false;
// Word-boundary match so 'attach' doesn't fire on 'detached'.
return new RegExp(`\\b${k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`).test(haystack);
});
if (!matchedKeyword) return true;
return new Promise<boolean>((resolve) => {
Alert.alert(
t('email_composer.attachment_reminder_title', 'Forgot an attachment?'),
t(
'email_composer.attachment_reminder_body',
'Your message mentions "{keyword}" but no file is attached. Send anyway?',
).replace('{keyword}', matchedKeyword),
[
{ text: t('email_composer.cancel', 'Cancel'), style: 'cancel', onPress: () => resolve(false) },
{ text: t('email_composer.send', 'Send'), onPress: () => resolve(true) },
],
);
});
};
const onSend = async () => {
if (!canSend || !primaryIdentity || !sentMailbox) return;
if (!(await passesAttachmentReminder())) return;
setSending(true);
try {
const from: EmailAddress[] = [{ name: primaryIdentity.name, email: primaryIdentity.email }];
@@ -709,7 +783,9 @@ export default function ComposeScreen({ route, navigation }: Props) {
? finalCc.map((r) => ({ name: r.name || undefined, email: r.email }))
: undefined,
subject,
htmlBody: finalHtml,
// Plain-text mode: skip the HTML part entirely so receiving clients
// render the text/plain alternative without any formatting.
htmlBody: plainTextMode ? undefined : finalHtml,
textBody: finalText,
attachments: outgoing.length ? outgoing : undefined,
inReplyTo: replyTo?.inReplyTo,
@@ -782,12 +858,16 @@ export default function ComposeScreen({ route, navigation }: Props) {
>
<View style={styles.fieldRow}>
<Text style={styles.fieldLabel}>{t('email_composer.from', 'From')}</Text>
<View style={styles.fieldContent}>
<Pressable
onPress={openIdentityPicker}
disabled={identities.length <= 1}
style={styles.fieldContent}
>
<Text style={styles.fromText} numberOfLines={1}>
{primaryIdentity?.email ?? (identityError ? 'identity unavailable' : 'Loading...')}
</Text>
<ChevronDown size={14} color={c.textMuted} />
</View>
</Pressable>
</View>
<View style={styles.fieldRow}>
+62 -15
View File
@@ -8,6 +8,7 @@ import {
} from 'lucide-react-native';
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useTypography, useDensity } from '../theme/dynamic';
import SidebarDrawer from '../components/SidebarDrawer';
import SenderAvatar from '../components/SenderAvatar';
import { SwipeableRow } from '../components/SwipeableRow';
@@ -56,12 +57,16 @@ function formatRelativeTime(date: Date): string {
const EmailRow = React.memo(function EmailRow({
item,
threadCount,
showPreview,
onPress,
onLongPress,
selected,
selectionMode,
}: {
item: Email;
threadCount: number;
showPreview: boolean;
onPress: (id: string) => void;
onLongPress: (id: string) => void;
selected: boolean;
@@ -69,6 +74,8 @@ const EmailRow = React.memo(function EmailRow({
}) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const dyn = useTypography();
const density = useDensity();
const senderName = getSenderName(item);
const senderEmail = getSenderEmail(item);
const unread = isUnread(item);
@@ -81,6 +88,7 @@ const EmailRow = React.memo(function EmailRow({
<Pressable
style={({ pressed }) => [
styles.emailRow,
{ paddingVertical: density.rowPaddingVertical },
pressed && styles.emailRowPressed,
selected && styles.emailRowSelected,
]}
@@ -97,14 +105,16 @@ const EmailRow = React.memo(function EmailRow({
)}
</View>
)}
<SenderAvatar name={senderName} email={senderEmail} size={componentSizes.avatarMd} />
{density.showAvatar && (
<SenderAvatar name={senderName} email={senderEmail} size={componentSizes.avatarMd} />
)}
{/* Content */}
<View style={styles.emailContent}>
{/* Row 1: Sender + indicators + time */}
<View style={styles.emailHeaderRow}>
<View style={styles.senderRow}>
<Text style={[styles.emailFrom, unread && styles.textUnread]} numberOfLines={1}>
<Text style={[styles.emailFrom, dyn.bodyMedium, unread && styles.textUnread]} numberOfLines={1}>
{senderName}
</Text>
{starred && (
@@ -115,21 +125,28 @@ const EmailRow = React.memo(function EmailRow({
)}
</View>
<View style={styles.timeAndTag}>
<Text style={styles.emailDate}>{formatRelativeTime(new Date(item.receivedAt))}</Text>
{threadCount > 1 && (
<View style={styles.threadBadge}>
<Text style={styles.threadBadgeText}>{threadCount}</Text>
</View>
)}
<Text style={[styles.emailDate, dyn.caption]}>{formatRelativeTime(new Date(item.receivedAt))}</Text>
</View>
</View>
{/* Row 2: Subject */}
<View style={styles.subjectRow}>
<Text style={[styles.emailSubject, unread && styles.textBold]} numberOfLines={1}>
<Text style={[styles.emailSubject, dyn.body, unread && styles.textBold]} numberOfLines={1}>
{item.subject || '(no subject)'}
</Text>
</View>
{/* Row 3: Preview */}
<Text style={styles.emailPreview} numberOfLines={2}>
{item.preview}
</Text>
{/* Row 3: Preview - hidden in compact density modes regardless of toggle */}
{showPreview && density.showPreview && (
<Text style={[styles.emailPreview, dyn.body]} numberOfLines={2}>
{item.preview}
</Text>
)}
</View>
</Pressable>
);
@@ -178,8 +195,36 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
const swipeLeftAction = useSettingsStore((s) => s.swipeLeftAction);
const swipeRightAction = useSettingsStore((s) => s.swipeRightAction);
const swipeMode = useSettingsStore((s) => s.swipeMode);
const showPreview = useSettingsStore((s) => s.showPreview);
const disableThreading = useSettingsStore((s) => s.disableThreading);
const networkOnline = useNetworkStore((s) => s.online);
// When threading is on, collapse same-thread emails so the list shows the
// most recent message per thread with a count badge. Disabling threading
// falls back to the flat list (every message is its own row).
const visibleEmails = React.useMemo(() => {
if (disableThreading) return emails;
const seen = new Set<string>();
const out: Email[] = [];
for (const e of emails) {
const key = e.threadId || e.id;
if (seen.has(key)) continue;
seen.add(key);
out.push(e);
}
return out;
}, [emails, disableThreading]);
const threadCounts = React.useMemo(() => {
if (disableThreading) return new Map<string, number>();
const counts = new Map<string, number>();
for (const e of emails) {
const key = e.threadId || e.id;
counts.set(key, (counts.get(key) ?? 0) + 1);
}
return counts;
}, [emails, disableThreading]);
const archiveMailboxId = React.useMemo(
() => mailboxes.find((m) => m.role === 'archive')?.id ?? null,
[mailboxes],
@@ -200,7 +245,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(new Set());
const selectionMode = selectedIds.size > 0;
const allSelected =
emails.length > 0 && emails.every((e) => selectedIds.has(e.id));
visibleEmails.length > 0 && visibleEmails.every((e) => selectedIds.has(e.id));
// Refs so row press handlers stay referentially stable across renders.
// FlatList rows then skip re-render when the parent re-renders for unrelated
@@ -281,6 +326,8 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
>
<EmailRow
item={item}
threadCount={threadCounts.get(item.threadId || item.id) ?? 1}
showPreview={showPreview}
selected={selectedIds.has(item.id)}
selectionMode={selectionMode}
onPress={handleRowPress}
@@ -297,11 +344,11 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
const toggleSelectAllVisible = React.useCallback(() => {
setSelectedIds((prev) => {
const allCurrent = emails.length > 0 && emails.every((e) => prev.has(e.id));
const allCurrent = visibleEmails.length > 0 && visibleEmails.every((e) => prev.has(e.id));
if (allCurrent) return new Set();
return new Set(emails.map((e) => e.id));
return new Set(visibleEmails.map((e) => e.id));
});
}, [emails]);
}, [visibleEmails]);
// Clear selection when mailbox changes
React.useEffect(() => {
@@ -309,8 +356,8 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
}, [currentMailboxId]);
const selectedEmails = React.useMemo(
() => emails.filter((e) => selectedIds.has(e.id)),
[emails, selectedIds],
() => visibleEmails.filter((e) => selectedIds.has(e.id)),
[visibleEmails, selectedIds],
);
const allSelectedAreRead = selectedEmails.length > 0 && selectedEmails.every((e) => !isUnread(e));
const allSelectedAreStarred = selectedEmails.length > 0 && selectedEmails.every((e) => isStarred(e));
@@ -621,7 +668,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
</View>
) : (
<FlatList
data={emails}
data={visibleEmails}
keyExtractor={emailKeyExtractor}
renderItem={renderEmailRow}
ItemSeparatorComponent={EmailRowSeparator}
+102 -62
View File
@@ -17,8 +17,9 @@ import EmailBodyView from '../components/EmailBodyView';
import SenderAvatar from '../components/SenderAvatar';
import { MoveSheet } from '../components/MoveSheet';
import { useEmailStore } from '../stores/email-store';
import { useSettingsStore } from '../stores/settings-store';
import { setEmailKeywords } from '../api/email';
import { shareEmailEml, shareAttachment } from '../lib/email-export';
import { shareEmailEml, shareAttachment, downloadAttachment } from '../lib/email-export';
import { useKeywordsStore, keywordToken, type KeywordDef } from '../stores/keywords-store';
import { useSheetDrag } from '../lib/use-sheet-drag';
import type { Email, Mailbox } from '../api/types';
@@ -84,20 +85,87 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
const [attachmentsExpanded, setAttachmentsExpanded] = React.useState(false);
const [downloadingBlobId, setDownloadingBlobId] = React.useState<string | null>(null);
const mailAttachmentAction = useSettingsStore((s) => s.mailAttachmentAction);
const attachmentPosition = useSettingsStore((s) => s.attachmentPosition);
const markAsReadDelay = useSettingsStore((s) => s.markAsReadDelay);
const hideInlineImageAttachments = useSettingsStore((s) => s.hideInlineImageAttachments);
const onPressAttachment = React.useCallback(
async (blobId: string, name: string | undefined, type: string | undefined) => {
if (downloadingBlobId) return;
setDownloadingBlobId(blobId);
try {
await shareAttachment(blobId, name, type);
if (mailAttachmentAction === 'download') {
await downloadAttachment(blobId, name, type);
} else {
await shareAttachment(blobId, name, type);
}
} catch (e) {
Alert.alert('Download failed', e instanceof Error ? e.message : String(e));
} finally {
setDownloadingBlobId(null);
}
},
[downloadingBlobId],
[downloadingBlobId, mailAttachmentAction],
);
const renderAttachments = () => {
const all = email?.attachments;
if (!all || all.length === 0) return null;
// hideInlineImageAttachments: drop chips for images already shown inline
// in the body (any image attachment with a cid:). Non-image inline
// attachments stay visible because the user wouldn't see them otherwise.
const atts = hideInlineImageAttachments
? all.filter((att) => !(att.cid && (att.type ?? '').startsWith('image/')))
: all;
if (atts.length === 0) return null;
const visible = attachmentsExpanded ? atts : atts.slice(0, 3);
return (
<View style={styles.attachmentsBlock}>
<View style={styles.attachmentsRow}>
{visible.map((att, idx) => {
const isDownloading = downloadingBlobId === att.blobId;
return (
<Pressable
key={att.blobId ?? idx}
style={({ pressed }) => [
styles.attachmentChip,
pressed && styles.attachmentChipPressed,
]}
onPress={() => onPressAttachment(att.blobId, att.name, att.type)}
disabled={!!downloadingBlobId}
>
{isDownloading ? (
<ActivityIndicator size="small" color={c.textMuted} />
) : (
<Paperclip size={14} color={c.textMuted} />
)}
<Text style={styles.attachmentName} numberOfLines={1}>
{att.name || 'attachment'}
</Text>
<Text style={styles.attachmentSize}>{formatSize(att.size)}</Text>
<Download size={14} color={c.textMuted} />
</Pressable>
);
})}
</View>
{atts.length > 3 && (
<Pressable
onPress={() => setAttachmentsExpanded((v) => !v)}
style={({ pressed }) => [
styles.attachmentsToggle,
pressed && styles.attachmentsTogglePressed,
]}
hitSlop={6}
>
<Text style={styles.attachmentsToggleText}>
{attachmentsExpanded ? 'Show less' : `Show all (${atts.length})`}
</Text>
</Pressable>
)}
</View>
);
};
const keywordDefs = useKeywordsStore((s) => s.keywords);
const hydrateKeywords = useKeywordsStore((s) => s.hydrate);
const keywordsHydrated = useKeywordsStore((s) => s.hydrated);
@@ -105,6 +173,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
React.useEffect(() => {
let cancelled = false;
let readTimer: ReturnType<typeof setTimeout> | null = null;
setLoading(true);
setError(null);
void (async () => {
@@ -113,7 +182,13 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
if (cancelled) return;
setEmail(fetched);
if (fetched && !fetched.keywords?.$seen) {
void markRead(emailId);
if (markAsReadDelay > 0) {
readTimer = setTimeout(() => {
if (!cancelled) void markRead(emailId);
}, markAsReadDelay);
} else {
void markRead(emailId);
}
}
} catch (e) {
if (!cancelled) {
@@ -123,8 +198,11 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [emailId, getEmailDetail, markRead]);
return () => {
cancelled = true;
if (readTimer) clearTimeout(readTimer);
};
}, [emailId, getEmailDetail, markRead, markAsReadDelay]);
const starred = !!email?.keywords?.$flagged;
const unread = !!email && !email.keywords?.$seen;
@@ -350,59 +428,11 @@ export default function EmailThreadScreen({ route, navigation }: Props) {
</Text>
</View>
</View>
{attachmentPosition === 'beside-sender' && renderAttachments()}
</View>
{/* Attachments chips */}
{email.attachments && email.attachments.length > 0 && (
<View style={styles.attachmentsBlock}>
<View style={styles.attachmentsRow}>
{(attachmentsExpanded
? email.attachments
: email.attachments.slice(0, 3)
).map((att, idx) => {
const isDownloading = downloadingBlobId === att.blobId;
return (
<Pressable
key={att.blobId ?? idx}
style={({ pressed }) => [
styles.attachmentChip,
pressed && styles.attachmentChipPressed,
]}
onPress={() => onPressAttachment(att.blobId, att.name, att.type)}
disabled={!!downloadingBlobId}
>
{isDownloading ? (
<ActivityIndicator size="small" color={c.textMuted} />
) : (
<Paperclip size={14} color={c.textMuted} />
)}
<Text style={styles.attachmentName} numberOfLines={1}>
{att.name || 'attachment'}
</Text>
<Text style={styles.attachmentSize}>{formatSize(att.size)}</Text>
<Download size={14} color={c.textMuted} />
</Pressable>
);
})}
</View>
{email.attachments.length > 3 && (
<Pressable
onPress={() => setAttachmentsExpanded((v) => !v)}
style={({ pressed }) => [
styles.attachmentsToggle,
pressed && styles.attachmentsTogglePressed,
]}
hitSlop={6}
>
<Text style={styles.attachmentsToggleText}>
{attachmentsExpanded
? 'Show less'
: `Show all (${email.attachments.length})`}
</Text>
</Pressable>
)}
</View>
)}
{/* Attachments chips (full-width below header) */}
{attachmentPosition === 'below-header' && renderAttachments()}
{/* Body */}
<View style={styles.bodyBlock}>
@@ -736,10 +766,16 @@ function ToolbarButton({
}: { icon: React.ReactNode; label: string; onPress?: () => void }) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const showLabels = useSettingsStore((s) => s.showToolbarLabels);
return (
<Pressable onPress={onPress} style={styles.toolbarAction} hitSlop={6}>
<Pressable
onPress={onPress}
style={styles.toolbarAction}
hitSlop={6}
accessibilityLabel={label}
>
{icon}
<Text style={styles.toolbarActionLabel}>{label}</Text>
{showLabels && <Text style={styles.toolbarActionLabel}>{label}</Text>}
</Pressable>
);
}
@@ -749,16 +785,20 @@ function BottomBarButton({
}: { icon: React.ReactNode; label: string; onPress?: () => void; disabled?: boolean }) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const showLabels = useSettingsStore((s) => s.showToolbarLabels);
return (
<Pressable
onPress={disabled ? undefined : onPress}
style={[styles.bottomBarBtn, disabled && styles.bottomBarBtnDisabled]}
hitSlop={4}
accessibilityLabel={label}
>
{icon}
<Text style={[styles.bottomBarLabel, disabled && styles.bottomBarLabelDisabled]}>
{label}
</Text>
{showLabels && (
<Text style={[styles.bottomBarLabel, disabled && styles.bottomBarLabelDisabled]}>
{label}
</Text>
)}
</Pressable>
);
}
+84 -20
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
FlatList,
@@ -7,11 +7,42 @@ import {
TouchableOpacity,
View,
} from 'react-native';
import { FileText, Folder, HardDrive } from 'lucide-react-native';
import {
FileText, Folder, HardDrive, FileImage, FileVideo, FileAudio,
FileArchive, FileSpreadsheet, FileCode2,
} from 'lucide-react-native';
import { getFileNodes } from '../api/files';
import type { FileNode } from '../api/types';
import { spacing, typography, type ThemePalette } from '../theme/tokens';
import { useColors } from '../theme/colors';
import { useSettingsStore } from '../stores/settings-store';
function pickFileIcon(name: string) {
const ext = name.split('.').pop()?.toLowerCase() ?? '';
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'].includes(ext)) return FileImage;
if (['mp4', 'mov', 'webm', 'avi', 'mkv'].includes(ext)) return FileVideo;
if (['mp3', 'wav', 'flac', 'm4a', 'ogg', 'opus'].includes(ext)) return FileAudio;
if (['zip', 'rar', 'tar', 'gz', '7z', 'bz2'].includes(ext)) return FileArchive;
if (['xlsx', 'xls', 'csv', 'numbers'].includes(ext)) return FileSpreadsheet;
if (['ts', 'tsx', 'js', 'jsx', 'py', 'rb', 'go', 'rs', 'java', 'c', 'cpp', 'h', 'json', 'xml', 'yaml', 'yml', 'sh'].includes(ext)) return FileCode2;
return FileText;
}
function fileIconColor(name: string, c: ThemePalette): string {
const ext = name.split('.').pop()?.toLowerCase() ?? '';
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'].includes(ext)) return c.calendar.green;
if (['mp4', 'mov', 'webm', 'avi', 'mkv'].includes(ext)) return c.calendar.purple;
if (['mp3', 'wav', 'flac', 'm4a', 'ogg', 'opus'].includes(ext)) return c.calendar.pink;
if (['zip', 'rar', 'tar', 'gz', '7z', 'bz2'].includes(ext)) return c.calendar.orange;
if (['xlsx', 'xls', 'csv', 'numbers'].includes(ext)) return c.calendar.teal;
if (['pdf'].includes(ext)) return c.error;
if (['ts', 'tsx', 'js', 'jsx', 'py', 'rb', 'go', 'rs', 'java', 'c', 'cpp', 'h', 'json', 'xml', 'yaml', 'yml', 'sh'].includes(ext)) return c.calendar.indigo;
return c.textMuted;
}
function isHidden(node: FileNode): boolean {
return node.name.startsWith('.');
}
function formatFileSize(bytes?: number): string {
if (bytes == null) return '';
@@ -27,6 +58,12 @@ export default function FilesScreen() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const showIcons = useSettingsStore((s) => s.filesShowIcons);
const coloredIcons = useSettingsStore((s) => s.filesColoredIcons);
const showHiddenFiles = useSettingsStore((s) => s.filesShowHiddenFiles);
const sortKey = useSettingsStore((s) => s.filesDefaultSortKey);
const sortDir = useSettingsStore((s) => s.filesDefaultSortDir);
const loadFiles = useCallback(async () => {
setLoading(true);
setError(null);
@@ -40,6 +77,31 @@ export default function FilesScreen() {
}
}, []);
const visibleFiles = useMemo(() => {
let out = files;
if (!showHiddenFiles) out = out.filter((f) => !isHidden(f));
// Directories always sort to the top regardless of sort key, then secondary
// sort by the requested column. This matches macOS Finder / web file pickers.
const dir = sortDir === 'desc' ? -1 : 1;
return [...out].sort((a, b) => {
const aDir = a.type === 'directory' ? 0 : 1;
const bDir = b.type === 'directory' ? 0 : 1;
if (aDir !== bDir) return aDir - bDir;
switch (sortKey) {
case 'size':
return ((a.size ?? 0) - (b.size ?? 0)) * dir;
case 'modified': {
const am = a.updated ? new Date(a.updated).getTime() : 0;
const bm = b.updated ? new Date(b.updated).getTime() : 0;
return (am - bm) * dir;
}
case 'name':
default:
return a.name.localeCompare(b.name) * dir;
}
});
}, [files, showHiddenFiles, sortKey, sortDir]);
useEffect(() => {
void loadFiles();
}, [loadFiles]);
@@ -63,7 +125,7 @@ export default function FilesScreen() {
);
}
if (files.length === 0) {
if (visibleFiles.length === 0) {
return (
<View style={styles.container}>
<View style={styles.header}>
@@ -83,25 +145,27 @@ export default function FilesScreen() {
<Text style={styles.title}>Files</Text>
</View>
<FlatList
data={files}
data={visibleFiles}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<View style={styles.fileRow}>
{item.type === 'directory' ? (
<Folder size={20} color={c.primary} />
) : (
<FileText size={20} color={c.textMuted} />
)}
<View style={styles.fileInfo}>
<Text style={styles.fileName} numberOfLines={1}>
{item.name}
</Text>
{item.size != null && (
<Text style={styles.fileSize}>{formatFileSize(item.size)}</Text>
)}
renderItem={({ item }) => {
const Icon = item.type === 'directory' ? Folder : pickFileIcon(item.name);
const tint = item.type === 'directory'
? (coloredIcons ? c.primary : c.textMuted)
: (coloredIcons ? fileIconColor(item.name, c) : c.textMuted);
return (
<View style={styles.fileRow}>
{showIcons && <Icon size={20} color={tint} />}
<View style={styles.fileInfo}>
<Text style={styles.fileName} numberOfLines={1}>
{item.name}
</Text>
{item.size != null && (
<Text style={styles.fileSize}>{formatFileSize(item.size)}</Text>
)}
</View>
</View>
</View>
)}
);
}}
/>
</View>
);
+29 -6
View File
@@ -151,7 +151,8 @@ export const useEmailStore = create<EmailState>()(
});
try {
const filter = buildJmapFilter(mailboxId, '', {});
const { ids, total } = await queryEmails(mailboxId, { limit: 50, filter });
const limit = useSettingsStore.getState().emailsPerPage;
const { ids, total } = await queryEmails(mailboxId, { limit, filter });
const emails = ids.length > 0 ? await fetchEmails(ids) : [];
set({ emails, totalEmails: total, loading: false });
} catch (err) {
@@ -167,9 +168,10 @@ export const useEmailStore = create<EmailState>()(
set({ loading: true });
try {
const filter = buildJmapFilter(currentMailboxId, searchQuery, filters);
const limit = useSettingsStore.getState().emailsPerPage;
const { ids } = await queryEmails(currentMailboxId, {
position: emails.length,
limit: 50,
limit,
filter,
});
const newEmails = ids.length > 0 ? await fetchEmails(ids) : [];
@@ -187,7 +189,8 @@ export const useEmailStore = create<EmailState>()(
set({ loading: true, error: null });
try {
const filter = buildJmapFilter(currentMailboxId, searchQuery, filters);
const { ids, total } = await queryEmails(currentMailboxId, { limit: 50, filter });
const limit = useSettingsStore.getState().emailsPerPage;
const { ids, total } = await queryEmails(currentMailboxId, { limit, filter });
const emails = ids.length > 0 ? await fetchEmails(ids) : [];
set({ emails, totalEmails: total, loading: false });
} catch (err) {
@@ -335,14 +338,34 @@ export const useEmailStore = create<EmailState>()(
deleteEmail: async (emailId, trashMailboxId, currentMailboxId) => {
const email = get().emails.find((e) => e.id === emailId);
const original = email ? { ...email.mailboxIds } : null;
const isPermanent = currentMailboxId === trashMailboxId;
const settings = useSettingsStore.getState();
const { mailboxes } = get();
const junkMailbox = mailboxes.find((m) => m.role === 'junk' || m.role === 'spam');
const inJunk = !!(junkMailbox && email?.mailboxIds?.[junkMailbox.id]);
const inTrash = currentMailboxId === trashMailboxId;
await apiDeleteEmail(emailId, trashMailboxId, currentMailboxId);
// Resolve effective destination:
// - already in trash → must destroy (no further folder to move to)
// - in junk and the user opted to skip the trash for junk → destroy
// - the user set 'permanent' as the global default → destroy
// - otherwise → move to trash and offer undo
const destroy =
inTrash ||
settings.deleteAction === 'permanent' ||
(settings.permanentlyDeleteJunk && inJunk);
if (destroy) {
// Use the trash mailbox as the "current" so apiDeleteEmail takes the
// destroy branch even when the source folder isn't trash.
await apiDeleteEmail(emailId, trashMailboxId, trashMailboxId);
} else {
await apiDeleteEmail(emailId, trashMailboxId, currentMailboxId);
}
set({ emails: get().emails.filter((e) => e.id !== emailId) });
// Permanent destroy can't be undone - skip the snackbar so we don't
// promise an undo we can't deliver.
if (email && original && !isPermanent) {
if (email && original && !destroy) {
set({
pendingUndo: {
kind: 'delete',
+138
View File
@@ -0,0 +1,138 @@
// Mobile-side templates store. Mirrors the webmail's `template-store` but
// keeps the surface minimal — categories, defaults, placeholders, and the
// favourite/recent tracking are not yet exposed in the mobile UI. We persist
// the same shape so an export from one platform imports cleanly into the
// other.
import { create } from 'zustand';
import AsyncStorage from '@react-native-async-storage/async-storage';
export interface EmailTemplate {
id: string;
name: string;
subject: string;
body: string;
category: string;
isFavorite: boolean;
createdAt: string;
updatedAt: string;
}
const STORAGE_KEY = 'webmail:templates:v1';
interface TemplatesState {
templates: EmailTemplate[];
hydrated: boolean;
hydrate: () => Promise<void>;
addTemplate: (data: Pick<EmailTemplate, 'name' | 'subject' | 'body'> & Partial<Pick<EmailTemplate, 'category' | 'isFavorite'>>) => EmailTemplate;
updateTemplate: (id: string, updates: Partial<Omit<EmailTemplate, 'id' | 'createdAt'>>) => void;
deleteTemplate: (id: string) => void;
exportAll: () => string;
importTemplates: (json: string) => { count: number; error?: string };
}
function persist(state: EmailTemplate[]): void {
void AsyncStorage.setItem(STORAGE_KEY, JSON.stringify({ templates: state })).catch((err) => {
console.warn('[templates-store] persist failed', err);
});
}
function genId(): string {
return `tpl-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
export const useTemplatesStore = create<TemplatesState>((set, get) => ({
templates: [],
hydrated: false,
hydrate: async () => {
if (get().hydrated) return;
try {
const raw = await AsyncStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw) as { templates?: EmailTemplate[] };
if (Array.isArray(parsed.templates)) {
set({ templates: parsed.templates, hydrated: true });
return;
}
}
} catch (err) {
console.warn('[templates-store] hydrate failed', err);
}
set({ hydrated: true });
},
addTemplate: (data) => {
const now = new Date().toISOString();
const template: EmailTemplate = {
id: genId(),
name: data.name,
subject: data.subject,
body: data.body,
category: data.category ?? '',
isFavorite: data.isFavorite ?? false,
createdAt: now,
updatedAt: now,
};
const next = [...get().templates, template];
set({ templates: next });
persist(next);
return template;
},
updateTemplate: (id, updates) => {
const next = get().templates.map((t) =>
t.id === id ? { ...t, ...updates, updatedAt: new Date().toISOString() } : t,
);
set({ templates: next });
persist(next);
},
deleteTemplate: (id) => {
const next = get().templates.filter((t) => t.id !== id);
set({ templates: next });
persist(next);
},
exportAll: () => {
return JSON.stringify({ version: 1, templates: get().templates }, null, 2);
},
importTemplates: (json) => {
try {
const parsed = JSON.parse(json) as { templates?: unknown };
if (!Array.isArray(parsed.templates)) {
return { count: 0, error: 'Invalid template file: expected `templates` array' };
}
const incoming: EmailTemplate[] = [];
for (const raw of parsed.templates) {
if (!raw || typeof raw !== 'object') continue;
const t = raw as Partial<EmailTemplate>;
if (typeof t.name !== 'string' || typeof t.body !== 'string') continue;
const now = new Date().toISOString();
incoming.push({
id: typeof t.id === 'string' ? t.id : genId(),
name: t.name,
subject: typeof t.subject === 'string' ? t.subject : '',
body: t.body,
category: typeof t.category === 'string' ? t.category : '',
isFavorite: !!t.isFavorite,
createdAt: typeof t.createdAt === 'string' ? t.createdAt : now,
updatedAt: typeof t.updatedAt === 'string' ? t.updatedAt : now,
});
}
const existing = get().templates;
const existingIds = new Set(existing.map((t) => t.id));
const merged = [
...existing,
...incoming.map((t) => (existingIds.has(t.id) ? { ...t, id: genId() } : t)),
];
set({ templates: merged });
persist(merged);
return { count: incoming.length };
} catch (err) {
return { count: 0, error: err instanceof Error ? err.message : 'Parse failed' };
}
},
}));
+98
View File
@@ -0,0 +1,98 @@
// Dynamic theme hooks that read settings the user can change at runtime.
// The static tokens in `./tokens` cover the baseline; these hooks return
// scaled / preference-driven variants so screens can react to settings
// without a relaunch.
import { useMemo } from 'react';
import { useSettingsStore } from '../stores/settings-store';
import { typography as baseTypography, spacing as baseSpacing } from './tokens';
export type FontScale = 'small' | 'medium' | 'large';
export type DensityKind = 'extra-compact' | 'compact' | 'regular' | 'comfortable';
const FONT_SCALE: Record<FontScale, number> = {
small: 0.92,
medium: 1,
large: 1.12,
};
// How tall an email-list-style row should be. Mirrors the webmail
// `--density-item-py` token, halved for the per-side padding.
const ROW_PADY: Record<DensityKind, number> = {
'extra-compact': 4,
compact: 8,
regular: 12,
comfortable: 16,
};
// Vertical gap between rows (used for the FlatList separator strength too).
const ROW_GAP: Record<DensityKind, number> = {
'extra-compact': 2,
compact: 6,
regular: 10,
comfortable: 14,
};
function scaleStyle(
style: { fontSize: number; lineHeight: number; fontWeight: '400' | '500' | '600' | '700' },
factor: number,
) {
return {
fontSize: Math.round(style.fontSize * factor),
lineHeight: Math.round(style.lineHeight * factor),
fontWeight: style.fontWeight,
};
}
export function useTypography() {
const fontSize = useSettingsStore((s) => s.fontSize);
return useMemo(() => {
const f = FONT_SCALE[fontSize] ?? 1;
if (f === 1) return baseTypography;
return {
h1: scaleStyle(baseTypography.h1, f),
h2: scaleStyle(baseTypography.h2, f),
h3: scaleStyle(baseTypography.h3, f),
body: scaleStyle(baseTypography.body, f),
bodyMedium: scaleStyle(baseTypography.bodyMedium, f),
bodySemibold: scaleStyle(baseTypography.bodySemibold, f),
bodyBold: scaleStyle(baseTypography.bodyBold, f),
base: scaleStyle(baseTypography.base, f),
baseMedium: scaleStyle(baseTypography.baseMedium, f),
caption: scaleStyle(baseTypography.caption, f),
captionMedium: scaleStyle(baseTypography.captionMedium, f),
small: scaleStyle(baseTypography.small, f),
tabLabel: scaleStyle(baseTypography.tabLabel, f),
};
}, [fontSize]);
}
export function useDensity() {
const density = useSettingsStore((s) => s.density);
return useMemo(
() => ({
kind: density,
rowPaddingVertical: ROW_PADY[density] ?? ROW_PADY.regular,
rowGap: ROW_GAP[density] ?? ROW_GAP.regular,
// Compact modes hide secondary content so rows breathe less. The
// webmail uses extra-compact to drop preview lines entirely.
showPreview: density !== 'extra-compact' && density !== 'compact',
showAvatar: density !== 'extra-compact',
verticalSpacing: Math.max(2, baseSpacing.sm - (4 - ROW_PADY[density] / 4)),
}),
[density],
);
}
// Returns whether the user has opted into in-app animations. When false,
// callers should pass duration=0 to `Animated.timing` so transitions snap.
export function useShouldAnimate() {
return useSettingsStore((s) => s.animationsEnabled);
}
// Convenience: returns `requested` when animations are on, otherwise 0.
// Use as `Animated.timing(v, { duration: useAnimDuration(240), ... })`.
export function useAnimDuration(requested: number): number {
const enabled = useShouldAnimate();
return enabled ? requested : 0;
}