diff --git a/App.tsx b/App.tsx index c9c46e40..efd4d0a1 100644 --- a/App.tsx +++ b/App.tsx @@ -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) { diff --git a/src/api/blob.ts b/src/api/blob.ts index 976dd5ab..1d6926a4 100644 --- a/src/api/blob.ts +++ b/src/api/blob.ts @@ -31,7 +31,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; + 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( diff --git a/src/api/email.ts b/src/api/email.ts index 01a5c009..61e90bb9 100644 --- a/src/api/email.ts +++ b/src/api/email.ts @@ -22,6 +22,67 @@ export async function getMailboxes(): Promise { return res.methodResponses[0][1].list; } +export async function createMailbox( + data: { name: string; parentId?: string | null }, +): Promise { + 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 { + 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 { + 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?: { diff --git a/src/components/EmailBodyView.tsx b/src/components/EmailBodyView.tsx index 813a50c0..6cbf0460 100644 --- a/src/components/EmailBodyView.tsx +++ b/src/components/EmailBodyView.tsx @@ -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(); + 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('(empty message)'), + html: wrapEmailHtml('(empty message)', { + 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); diff --git a/src/components/SidebarDrawer.tsx b/src/components/SidebarDrawer.tsx index e8ba8301..a87b3f09 100644 --- a/src/components/SidebarDrawer.tsx +++ b/src/components/SidebarDrawer.tsx @@ -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( diff --git a/src/components/calendar/CalendarSidebarDrawer.tsx b/src/components/calendar/CalendarSidebarDrawer.tsx index 0af9040f..aaa20c78 100644 --- a/src/components/calendar/CalendarSidebarDrawer.tsx +++ b/src/components/calendar/CalendarSidebarDrawer.tsx @@ -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]); diff --git a/src/components/calendar/MonthView.tsx b/src/components/calendar/MonthView.tsx index e298f8b6..aac588ec 100644 --- a/src/components/calendar/MonthView.tsx +++ b/src/components/calendar/MonthView.tsx @@ -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 ( + onSelectDate(d)} + onLongPress={onLongPressDate ? () => onLongPressDate(d) : undefined} + > + + + {format(d, 'd')} + + + {dayEvents.length > 0 && ( + + {visibleDots.map((event, idx) => ( + + ))} + {overflow > 0 && ( + +{overflow} + )} + + )} + + ); + }; return ( - {WEEKDAYS.map((wd, i) => ( + {showWeekNumbers && #} + {weekdayLabels.map((wd, i) => ( {wd} ))} - - {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 ( - onSelectDate(d)} - onLongPress={onLongPressDate ? () => onLongPressDate(d) : undefined} - > - - - {format(d, 'd')} - - - {dayEvents.length > 0 && ( - - {visibleDots.map((event, idx) => ( - - ))} - {overflow > 0 && ( - +{overflow} - )} - - )} - - ); - })} - + {showWeekNumbers ? ( + rows.map((row, rowIdx) => ( + + {getWeek(row[0], { weekStartsOn })} + {row.map((d, i) => renderDay(d, `${rowIdx}-${i}`, true))} + + )) + ) : ( + + {days.map((d, i) => renderDay(d, i))} + + )} ); } @@ -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, diff --git a/src/components/calendar/WeekView.tsx b/src/components/calendar/WeekView.tsx index be06a76b..49a82c66 100644 --- a/src/components/calendar/WeekView.tsx +++ b/src/components/calendar/WeekView.tsx @@ -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({ {h > 0 && ( - {h.toString().padStart(2, '0')}:00 + {timeFormat === '12h' + ? `${((h % 12) || 12)} ${h < 12 ? 'AM' : 'PM'}` + : `${h.toString().padStart(2, '0')}:00`} )} diff --git a/src/components/settings/FolderSettings.tsx b/src/components/settings/FolderSettings.tsx index effa14e4..9f8f4b28 100644 --- a/src/components/settings/FolderSettings.tsx +++ b/src/components/settings/FolderSettings.tsx @@ -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 = { @@ -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(null); + const [draftName, setDraftName] = useState(''); + const [busyId, setBusyId] = useState(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 ( + + + {mailboxes.length === 0 ? ( @@ -56,13 +164,20 @@ export function FolderSettings() { {sorted.map((mb) => { const Icon = getIcon(mb); + const editable = !mb.role; return ( - + editable && openRename(mb)} + onLongPress={() => editable && confirmDelete(mb)} + disabled={!editable && busyId !== mb.id} + style={({ pressed }) => [ + styles.folderRow, + editable && pressed && styles.folderRowPressed, + ]} + > - + {mb.name} {mb.role && ROLE_LABEL[mb.role] && ( @@ -77,56 +192,127 @@ export function FolderSettings() { )} {mb.totalEmails} + {editable && ( + busyId === mb.id ? ( + + ) : ( + + ) + )} - + ); })} )} - - - Folder list shown above is live. - - + + + + + + {editor?.kind === 'rename' ? `Rename "${editor.mailbox.name}"` : 'New folder'} + + + + + + + Folder name + + {editor?.kind === 'rename' && ( + { + closeEditor(); + confirmDelete(editor.mailbox); + }} + style={styles.deleteRow} + > + + Delete folder + + )} + + + + + + + + ); } 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, + }, + }); } diff --git a/src/components/settings/IdentitySettings.tsx b/src/components/settings/IdentitySettings.tsx index 7c068d96..96a3e1e6 100644 --- a/src/components/settings/IdentitySettings.tsx +++ b/src/components/settings/IdentitySettings.tsx @@ -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(null); + const [saving, setSaving] = useState(false); + const [deletingId, setDeletingId] = useState(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 ( - - - {loading ? ( - - ) : ( - {countLabel} - )} - - + + + {loading ? 'Loading…' : identities.length === 1 ? '1 identity' : `${identities.length} identities`} + + + {error && ( @@ -47,63 +157,150 @@ export function IdentitySettings() { )} {identities.map((identity) => ( - + openEdit(identity)} + style={({ pressed }) => [styles.identityRow, pressed && styles.identityRowPressed]} + > {identity.name || '(no name)'} {identity.email} - {!identity.mayDelete && ( + {!identity.mayDelete ? ( primary + ) : ( + confirmDelete(identity)} + hitSlop={8} + style={styles.identityDelete} + disabled={deletingId === identity.id} + > + {deletingId === identity.id ? ( + + ) : ( + + )} + )} - + ))} - - - + + + + + + {editing?.id ? 'Edit identity' : 'New identity'} + + + + + + + Display name + setEditing((d) => (d ? { ...d, name } : d))} + placeholder="Jane Doe" + placeholderTextColor={c.textMuted} + style={styles.input} + /> + Email address + 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 && ( + JMAP does not allow changing an identity's email; create a new one instead. + )} + Plain-text signature + setEditing((d) => (d ? { ...d, textSignature } : d))} + placeholder="-- Jane Doe Bulwark Mail" + placeholderTextColor={c.textMuted} + multiline + style={[styles.input, styles.bodyInput]} + /> + + + + + + + + ); } 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, + }, + }); } diff --git a/src/components/settings/TemplateSettings.tsx b/src/components/settings/TemplateSettings.tsx index a83b9e8b..7ae95eb4 100644 --- a/src/components/settings/TemplateSettings.tsx +++ b/src/components/settings/TemplateSettings.tsx @@ -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(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 ( - - + + {templates.length === 1 ? '1 template' : `${templates.length} templates`} - + + + {templates.length === 0 ? ( + No templates yet. Tap "New" to add one. + ) : ( + + {templates.map((t) => ( + openEdit(t)} + onLongPress={() => confirmDelete(t)} + style={({ pressed }) => [styles.listRow, pressed && styles.listRowPressed]} + > + + {t.name} + {!!t.subject && ( + {t.subject} + )} + + confirmDelete(t)} hitSlop={8} style={styles.listRowDelete}> + + + + ))} + + )} - + - + + {/* Edit/Create modal */} + + + + + + {editing?.id ? 'Edit template' : 'New template'} + + + + + + + Name + + Subject + + Body + + + + + + + + + + + {/* Import modal */} + setImportVisible(false)}> + + + + Import templates + setImportVisible(false)} hitSlop={8}> + + + + + JSON + + + + + + + + + ); } 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, + }, + }); } diff --git a/src/lib/email-export.ts b/src/lib/email-export.ts index 91c45af4..594ee65a 100644 --- a/src/lib/email-export.ts +++ b/src/lib/email-export.ts @@ -50,6 +50,33 @@ export async function shareAttachment( }); } +// 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 { + 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(shareableUri(downloaded), { + mimeType, + dialogTitle: filename, + }); +} + export async function fetchRawEmail(blobId: string): Promise { const url = getDownloadUrl(blobId, 'email.eml', RFC822); const r = await fetch(url, { headers: { Authorization: authHeader() } }); diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index cbd21037..4f690166 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -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('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(initialViewMode); const [detailEvent, setDetailEvent] = React.useState(null); const [modalEvent, setModalEvent] = React.useState(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() { - {headerTitle(viewMode, currentDate)} + {headerTitle(viewMode, currentDate, calendarFirstDayOfWeek)} {isSelectedToday ? 'Today' : format(selectedDate, 'EEE, MMM d')} @@ -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} diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index c17fd4fd..748e923f 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -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([]); const [identityError, setIdentityError] = React.useState(null); const [sending, setSending] = React.useState(false); + const [selectedIdentityId, setSelectedIdentityId] = React.useState(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(() => { 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 => { + 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((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) { > {t('email_composer.from', 'From')} - + {primaryIdentity?.email ?? (identityError ? 'identity unavailable' : 'Loading...')} - + diff --git a/src/screens/EmailListScreen.tsx b/src/screens/EmailListScreen.tsx index 3b688f95..61b7b781 100644 --- a/src/screens/EmailListScreen.tsx +++ b/src/screens/EmailListScreen.tsx @@ -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({ [ styles.emailRow, + { paddingVertical: density.rowPaddingVertical }, pressed && styles.emailRowPressed, selected && styles.emailRowSelected, ]} @@ -97,14 +105,16 @@ const EmailRow = React.memo(function EmailRow({ )} )} - + {density.showAvatar && ( + + )} {/* Content */} {/* Row 1: Sender + indicators + time */} - + {senderName} {starred && ( @@ -115,21 +125,28 @@ const EmailRow = React.memo(function EmailRow({ )} - {formatRelativeTime(new Date(item.receivedAt))} + {threadCount > 1 && ( + + {threadCount} + + )} + {formatRelativeTime(new Date(item.receivedAt))} {/* Row 2: Subject */} - + {item.subject || '(no subject)'} - {/* Row 3: Preview */} - - {item.preview} - + {/* Row 3: Preview - hidden in compact density modes regardless of toggle */} + {showPreview && density.showPreview && ( + + {item.preview} + + )} ); @@ -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(); + 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(); + const counts = new Map(); + 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>(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 > { 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 ) : ( (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 ( + + + {visible.map((att, idx) => { + const isDownloading = downloadingBlobId === att.blobId; + return ( + [ + styles.attachmentChip, + pressed && styles.attachmentChipPressed, + ]} + onPress={() => onPressAttachment(att.blobId, att.name, att.type)} + disabled={!!downloadingBlobId} + > + {isDownloading ? ( + + ) : ( + + )} + + {att.name || 'attachment'} + + {formatSize(att.size)} + + + ); + })} + + {atts.length > 3 && ( + setAttachmentsExpanded((v) => !v)} + style={({ pressed }) => [ + styles.attachmentsToggle, + pressed && styles.attachmentsTogglePressed, + ]} + hitSlop={6} + > + + {attachmentsExpanded ? 'Show less' : `Show all (${atts.length})`} + + + )} + + ); + }; 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 | 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) { + {attachmentPosition === 'beside-sender' && renderAttachments()} - {/* Attachments chips */} - {email.attachments && email.attachments.length > 0 && ( - - - {(attachmentsExpanded - ? email.attachments - : email.attachments.slice(0, 3) - ).map((att, idx) => { - const isDownloading = downloadingBlobId === att.blobId; - return ( - [ - styles.attachmentChip, - pressed && styles.attachmentChipPressed, - ]} - onPress={() => onPressAttachment(att.blobId, att.name, att.type)} - disabled={!!downloadingBlobId} - > - {isDownloading ? ( - - ) : ( - - )} - - {att.name || 'attachment'} - - {formatSize(att.size)} - - - ); - })} - - {email.attachments.length > 3 && ( - setAttachmentsExpanded((v) => !v)} - style={({ pressed }) => [ - styles.attachmentsToggle, - pressed && styles.attachmentsTogglePressed, - ]} - hitSlop={6} - > - - {attachmentsExpanded - ? 'Show less' - : `Show all (${email.attachments.length})`} - - - )} - - )} + {/* Attachments chips (full-width below header) */} + {attachmentPosition === 'below-header' && renderAttachments()} {/* Body */} @@ -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 ( - + {icon} - {label} + {showLabels && {label}} ); } @@ -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 ( {icon} - - {label} - + {showLabels && ( + + {label} + + )} ); } diff --git a/src/screens/FilesScreen.tsx b/src/screens/FilesScreen.tsx index bc75eb19..39b488b5 100644 --- a/src/screens/FilesScreen.tsx +++ b/src/screens/FilesScreen.tsx @@ -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(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 ( @@ -83,25 +145,27 @@ export default function FilesScreen() { Files item.id} - renderItem={({ item }) => ( - - {item.type === 'directory' ? ( - - ) : ( - - )} - - - {item.name} - - {item.size != null && ( - {formatFileSize(item.size)} - )} + 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 ( + + {showIcons && } + + + {item.name} + + {item.size != null && ( + {formatFileSize(item.size)} + )} + - - )} + ); + }} /> ); diff --git a/src/stores/email-store.ts b/src/stores/email-store.ts index 78f3bd45..ddf5ab4a 100644 --- a/src/stores/email-store.ts +++ b/src/stores/email-store.ts @@ -335,14 +335,34 @@ export const useEmailStore = create()( 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', diff --git a/src/stores/templates-store.ts b/src/stores/templates-store.ts new file mode 100644 index 00000000..6c3927c2 --- /dev/null +++ b/src/stores/templates-store.ts @@ -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; + addTemplate: (data: Pick & Partial>) => EmailTemplate; + updateTemplate: (id: string, updates: Partial>) => 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((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; + 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' }; + } + }, +})); diff --git a/src/theme/dynamic.ts b/src/theme/dynamic.ts new file mode 100644 index 00000000..733ad1fe --- /dev/null +++ b/src/theme/dynamic.ts @@ -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 = { + 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 = { + 'extra-compact': 4, + compact: 8, + regular: 12, + comfortable: 16, +}; + +// Vertical gap between rows (used for the FlatList separator strength too). +const ROW_GAP: Record = { + '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; +}