From 802474d3f2d8977c821268fd9eca4219f9a4e334 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:45:57 +0200 Subject: [PATCH] feat: show mail as folders, list and message side by side on tablets --- App.tsx | 48 +- src/components/SidebarDrawer.tsx | 795 ++++++++++++++++-------------- src/screens/EmailListScreen.tsx | 46 +- src/screens/EmailThreadScreen.tsx | 35 +- src/screens/MailTab.tsx | 182 +++++++ 5 files changed, 676 insertions(+), 430 deletions(-) create mode 100644 src/screens/MailTab.tsx diff --git a/App.tsx b/App.tsx index a275df46..92176989 100644 --- a/App.tsx +++ b/App.tsx @@ -43,7 +43,7 @@ import ComposeScreen from './src/screens/ComposeScreen'; import EmailThreadScreen from './src/screens/EmailThreadScreen'; import EmailSourceScreen from './src/screens/EmailSourceScreen'; import LoginScreen from './src/screens/LoginScreen'; -import EmailListScreen from './src/screens/EmailListScreen'; +import MailTab from './src/screens/MailTab'; import FilesScreen from './src/screens/FilesScreen'; import CalendarScreen from './src/screens/CalendarScreen'; import ContactsScreen from './src/screens/ContactsScreen'; @@ -57,12 +57,12 @@ import { useAccountStore } from './src/stores/account-store'; import { useAuthStore } from './src/stores/auth-store'; import { useCalendarStore } from './src/stores/calendar-store'; import { useContactsStore } from './src/stores/contacts-store'; -import { useEmailStore, viewerParamsForRow } from './src/stores/email-store'; -import { mailboxAccountId } from './src/lib/mailbox-tree'; +import { useEmailStore } from './src/stores/email-store'; import { loadDetail, prefetchMessage } from './src/lib/email-detail-cache'; import { useHasCalendar, useHasContacts, useHasFiles } from './src/lib/capabilities'; import { useSettingsStore } from './src/stores/settings-store'; import { useLocaleStore } from './src/stores/locale-store'; +import { useLayout } from './src/lib/use-layout'; import { toast } from './src/stores/toast-store'; import { useNetworkStore } from './src/stores/network-store'; import { useUpdatesStore } from './src/stores/updates-store'; @@ -243,6 +243,9 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps t('sidebar.tab_unavailable', '{name} (unavailable)', { name }); + // Side by side layouts move the tabs into a rail on the left, like the + // webmail's navigation rail. + const { isSplit } = useLayout(); return ( @@ -255,9 +258,21 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps - {() => ( - navigation.navigate('Compose')} - onEmailPress={(email) => { - // The list holds the open folder's mail: name its account so the - // viewer never has to guess it from whatever folder is open. - const { mailboxes: all, currentMailboxId } = useEmailStore.getState(); - const target = { - jmapAccountId: mailboxAccountId(all, currentMailboxId), - // A row of an "All folders" list or a tag view names its own - // account instead, and pages over that account's rows. - ...viewerParamsForRow(email), - }; - // Start on the body now: mounting the viewer takes a while. - prefetchMessage(email, target.jmapAccountId); - navigation.navigate('EmailThread', { - emailId: email.id, - threadId: email.threadId, - subject: email.subject, - ...target, - }); - }} - /> - )} + {() => } void; + /** Render as a permanent sidebar (wide screens) instead of a modal drawer. */ + docked?: boolean; } -export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps) { +export default function SidebarDrawer({ visible, onClose, docked = false }: SidebarDrawerProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const t = useLocaleStore((s) => s.t); @@ -746,6 +748,409 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps) const tagViewActive = !!filters.keyword; + const panel = ( + + {/* Header: close + account switcher */} + + {!docked && ( + + + + )} + setAccountMenuOpen((v) => !v)} + style={({ pressed }) => [styles.account, pressed && styles.accountPressed]} + > + + {initials} + + + {username || t('settings.account.title', 'Account')} + {hostname ? ( + {hostname} + ) : null} + + {busy ? ( + + ) : ( + + )} + + + + {accountMenuOpen && ( + + + {accounts.map((acc) => { + const isActive = acc.id === activeAccountId; + let accHost = ''; + try { accHost = new URL(acc.serverUrl).hostname; } catch { accHost = acc.serverUrl; } + const accEmail = acc.email || (acc.username.includes('@') + ? acc.username + : accHost ? `${acc.username}@${accHost}` : acc.username); + return ( + { + if (isActive) return; + setAccountMenuOpen(false); + onClose(); + void switchAccount(acc.id); + }} + onLongPress={isActive ? undefined : () => confirmRemoveAccount(acc)} + delayLongPress={350} + accessibilityHint={isActive ? undefined : t('sidebar.remove_account_hint', 'Long-press to remove this account')} + style={({ pressed }) => [ + styles.accountMenuRow, + isActive && styles.accountMenuRowActive, + pressed && !isActive && styles.accountMenuActionPressed, + ]} + > + + + + {getAccountInitials(acc.displayName, acc.email || acc.username)} + + + {isActive && ( + + + + )} + + + + + {acc.displayName || acc.username} + + {acc.isDefault && ( + + )} + + {accEmail} + + {acc.hasError ? ( + <> + + + {acc.errorMessage || t('sidebar.account_error', 'Needs attention')} + + + ) : ( + <> + {/* The connection dot only means something for the + account the live session serves. */} + {isActive && ( + + )} + {accHost} + + )} + + + {!isActive && ( + confirmRemoveAccount(acc)} + hitSlop={8} + style={styles.accountMenuRemove} + accessibilityRole="button" + accessibilityLabel={t('sidebar.remove_account', 'Remove account')} + > + + + )} + + ); + })} + + + {accounts.length < MAX_ACCOUNTS && ( + <> + + [ + styles.accountMenuAction, + pressed && styles.accountMenuActionPressed, + ]} + onPress={() => { + setAccountMenuOpen(false); + onClose(); + navigation.navigate('AddAccount'); + }} + > + + {t('sidebar.add_account', 'Add account')} + + + )} + + + {accounts.length > 1 && activeAccountId && (() => { + const active = accounts.find((a) => a.id === activeAccountId); + if (!active || active.isDefault) return null; + return ( + [ + styles.accountMenuAction, + pressed && styles.accountMenuActionPressed, + ]} + onPress={() => setDefaultAccount(active.id)} + > + + {t('sidebar.set_as_default', 'Set as default')} + + ); + })()} + [ + styles.accountMenuAction, + pressed && styles.accountMenuActionPressed, + ]} + onPress={() => { setAccountMenuOpen(false); onClose(); void logout(); }} + > + + + {t('sidebar.sign_out_of', `Sign out of ${accountEmailFull || 'account'}`, { account: accountEmailFull || 'account' })} + + + {accounts.length > 1 && ( + [ + styles.accountMenuAction, + pressed && styles.accountMenuActionPressed, + ]} + onPress={() => { setAccountMenuOpen(false); onClose(); void logoutAll(); }} + > + + + {t('sidebar.sign_out_all', 'Sign out of all accounts')} + + + )} + + )} + + + {/* Quick views */} + {showUnified && ( + [styles.quickRow, pressed && styles.rowPressed]} + onPress={() => { onClose(); navigation.navigate('UnifiedInbox', { role: 'inbox' }); }} + > + + + {unifiedCrossAccount && accounts.length > 1 + ? t('sidebar.all_accounts', 'All accounts') + : t('sidebar.unified_inbox', 'All inboxes')} + + + + + )} + [styles.quickRow, pressed && styles.rowPressed]} + onPress={() => { onClose(); navigation.navigate('Scheduled'); }} + > + + {t('sidebar.scheduled', 'Scheduled')} + + + {/* Unified per-role and cross views */} + {showUnified && ( + <> + toggleSection('unifiedExpanded', setUnifiedExpanded)}> + {unifiedExpanded ? ( + + ) : ( + + )} + {t('sidebar.unified_mailbox', 'Unified mailbox')} + + {unifiedExpanded && ( + <> + {([ + { view: 'unread' as const, Icon: MailOpen, label: t('sidebar.unified_all_unread', 'All unread'), unread: unifiedCounts.crossUnread }, + { view: 'starred' as const, Icon: Star, label: t('sidebar.unified_all_starred', 'All starred'), unread: 0 }, + { view: 'all' as const, Icon: Mails, label: t('sidebar.unified_all_mail', 'All mail'), unread: unifiedCounts.crossUnread }, + ]).map((row) => ( + } + label={row.label} + depth={0} + isSelected={false} + unread={row.unread} + total={0} + showTotal={false} + hasChildren={false} + isExpanded={false} + onPress={() => { onClose(); navigation.navigate('UnifiedInbox', { view: row.view }); }} + onToggleExpand={() => {}} + /> + ))} + {unifiedCounts.roles.map((r) => { + const Icon = unifiedIcon(r.role); + return ( + } + label={t(`sidebar.unified_${r.role}`, `All ${r.role}`)} + depth={0} + isSelected={false} + unread={r.unread} + total={r.total} + showTotal={showFolderTotalCount} + hasChildren={false} + isExpanded={false} + onPress={() => { onClose(); navigation.navigate('UnifiedInbox', { role: r.role }); }} + onToggleExpand={() => {}} + /> + ); + })} + + )} + + )} + + {/* Folders section */} + toggleSection('foldersExpanded', setFoldersExpanded)}> + {foldersExpanded ? ( + + ) : ( + + )} + {t('sidebar.folders', 'Folders')} + + { onClose(); navigation.navigate('MainTabs'); }} + accessibilityLabel={t('sidebar.settings', 'Settings')} + > + + + + + {foldersExpanded && ( + mailboxes.length === 0 ? ( + {t('sidebar.loading_mailboxes', 'Loading mailboxes...')} + ) : ( + visibleNodes.map((node) => { + const hasChildren = node.children.length > 0; + const isExpanded = expandedFolders.has(node.id); + // A shared/group account's header has no mailbox behind it — + // tapping it only opens or closes that account's folders. + const Icon = node.isAccountNode + ? Users + : iconFor(node.role, node.name, hasChildren, isExpanded); + const isSelected = !node.isAccountNode && !tagViewActive && node.id === currentMailboxId; + return ( + + } + label={node.isAccountNode ? node.name : localizeMailboxName(node.role, node.name, t)} + depth={node.depth} + isSelected={isSelected} + isAccountHeader={node.isAccountNode} + unread={node.unreadEmails} + total={node.totalEmails} + showTotal={showFolderTotalCount && !node.isAccountNode} + hasChildren={hasChildren} + isExpanded={isExpanded} + onPress={ + node.isAccountNode + ? () => toggleExpand(node.id) + : () => handleSelect(node.id) + } + onLongPress={ + node.isAccountNode + ? () => openSharedAccountSheet(node) + : () => openFolderSheet(node) + } + onToggleExpand={() => toggleExpand(node.id)} + onPressUnread={node.isAccountNode ? undefined : () => handleSelectUnread(node.id)} + /> + ); + }) + ) + )} + + {/* Tags section (#175) */} + {keywordDefs.length > 0 && ( + <> + toggleSection('tagsExpanded', setTagsExpanded)}> + {tagsExpanded ? ( + + ) : ( + + )} + {t('sidebar.tags', 'Tags')} + + {tagsExpanded && keywordDefs.map((kw) => { + const counts = tagCounts[kw.id]; + const isSelected = filters.keyword === keywordToken(kw.id); + const dot = c.tags[kw.color]?.dot ?? c.textMuted; + return ( + } + label={kw.label} + depth={0} + isSelected={isSelected} + unread={counts?.unread ?? 0} + total={counts?.total ?? 0} + showTotal={showFolderTotalCount} + hasChildren={false} + isExpanded={false} + onPress={() => selectTag(kw.id)} + onToggleExpand={() => {}} + /> + ); + })} + + )} + + + ); + + // On a wide screen the drawer is a permanent sidebar next to the list. + if (docked) { + return ( + + {panel} + {sheet && setSheet(null)} />} + {prompt && ( + setPrompt(null)} + /> + )} + + ); + } + return ( - - {/* Header: close + account switcher */} - - - - - setAccountMenuOpen((v) => !v)} - style={({ pressed }) => [styles.account, pressed && styles.accountPressed]} - > - - {initials} - - - {username || t('settings.account.title', 'Account')} - {hostname ? ( - {hostname} - ) : null} - - {busy ? ( - - ) : ( - - )} - - - - {accountMenuOpen && ( - - - {accounts.map((acc) => { - const isActive = acc.id === activeAccountId; - let accHost = ''; - try { accHost = new URL(acc.serverUrl).hostname; } catch { accHost = acc.serverUrl; } - const accEmail = acc.email || (acc.username.includes('@') - ? acc.username - : accHost ? `${acc.username}@${accHost}` : acc.username); - return ( - { - if (isActive) return; - setAccountMenuOpen(false); - onClose(); - void switchAccount(acc.id); - }} - onLongPress={isActive ? undefined : () => confirmRemoveAccount(acc)} - delayLongPress={350} - accessibilityHint={isActive ? undefined : t('sidebar.remove_account_hint', 'Long-press to remove this account')} - style={({ pressed }) => [ - styles.accountMenuRow, - isActive && styles.accountMenuRowActive, - pressed && !isActive && styles.accountMenuActionPressed, - ]} - > - - - - {getAccountInitials(acc.displayName, acc.email || acc.username)} - - - {isActive && ( - - - - )} - - - - - {acc.displayName || acc.username} - - {acc.isDefault && ( - - )} - - {accEmail} - - {acc.hasError ? ( - <> - - - {acc.errorMessage || t('sidebar.account_error', 'Needs attention')} - - - ) : ( - <> - {/* The connection dot only means something for the - account the live session serves. */} - {isActive && ( - - )} - {accHost} - - )} - - - {!isActive && ( - confirmRemoveAccount(acc)} - hitSlop={8} - style={styles.accountMenuRemove} - accessibilityRole="button" - accessibilityLabel={t('sidebar.remove_account', 'Remove account')} - > - - - )} - - ); - })} - - - {accounts.length < MAX_ACCOUNTS && ( - <> - - [ - styles.accountMenuAction, - pressed && styles.accountMenuActionPressed, - ]} - onPress={() => { - setAccountMenuOpen(false); - onClose(); - navigation.navigate('AddAccount'); - }} - > - - {t('sidebar.add_account', 'Add account')} - - - )} - - - {accounts.length > 1 && activeAccountId && (() => { - const active = accounts.find((a) => a.id === activeAccountId); - if (!active || active.isDefault) return null; - return ( - [ - styles.accountMenuAction, - pressed && styles.accountMenuActionPressed, - ]} - onPress={() => setDefaultAccount(active.id)} - > - - {t('sidebar.set_as_default', 'Set as default')} - - ); - })()} - [ - styles.accountMenuAction, - pressed && styles.accountMenuActionPressed, - ]} - onPress={() => { setAccountMenuOpen(false); onClose(); void logout(); }} - > - - - {t('sidebar.sign_out_of', `Sign out of ${accountEmailFull || 'account'}`, { account: accountEmailFull || 'account' })} - - - {accounts.length > 1 && ( - [ - styles.accountMenuAction, - pressed && styles.accountMenuActionPressed, - ]} - onPress={() => { setAccountMenuOpen(false); onClose(); void logoutAll(); }} - > - - - {t('sidebar.sign_out_all', 'Sign out of all accounts')} - - - )} - - )} - - - {/* Quick views */} - {showUnified && ( - [styles.quickRow, pressed && styles.rowPressed]} - onPress={() => { onClose(); navigation.navigate('UnifiedInbox', { role: 'inbox' }); }} - > - - - {unifiedCrossAccount && accounts.length > 1 - ? t('sidebar.all_accounts', 'All accounts') - : t('sidebar.unified_inbox', 'All inboxes')} - - - - - )} - [styles.quickRow, pressed && styles.rowPressed]} - onPress={() => { onClose(); navigation.navigate('Scheduled'); }} - > - - {t('sidebar.scheduled', 'Scheduled')} - - - {/* Unified per-role and cross views */} - {showUnified && ( - <> - toggleSection('unifiedExpanded', setUnifiedExpanded)}> - {unifiedExpanded ? ( - - ) : ( - - )} - {t('sidebar.unified_mailbox', 'Unified mailbox')} - - {unifiedExpanded && ( - <> - {([ - { view: 'unread' as const, Icon: MailOpen, label: t('sidebar.unified_all_unread', 'All unread'), unread: unifiedCounts.crossUnread }, - { view: 'starred' as const, Icon: Star, label: t('sidebar.unified_all_starred', 'All starred'), unread: 0 }, - { view: 'all' as const, Icon: Mails, label: t('sidebar.unified_all_mail', 'All mail'), unread: unifiedCounts.crossUnread }, - ]).map((row) => ( - } - label={row.label} - depth={0} - isSelected={false} - unread={row.unread} - total={0} - showTotal={false} - hasChildren={false} - isExpanded={false} - onPress={() => { onClose(); navigation.navigate('UnifiedInbox', { view: row.view }); }} - onToggleExpand={() => {}} - /> - ))} - {unifiedCounts.roles.map((r) => { - const Icon = unifiedIcon(r.role); - return ( - } - label={t(`sidebar.unified_${r.role}`, `All ${r.role}`)} - depth={0} - isSelected={false} - unread={r.unread} - total={r.total} - showTotal={showFolderTotalCount} - hasChildren={false} - isExpanded={false} - onPress={() => { onClose(); navigation.navigate('UnifiedInbox', { role: r.role }); }} - onToggleExpand={() => {}} - /> - ); - })} - - )} - - )} - - {/* Folders section */} - toggleSection('foldersExpanded', setFoldersExpanded)}> - {foldersExpanded ? ( - - ) : ( - - )} - {t('sidebar.folders', 'Folders')} - - { onClose(); navigation.navigate('MainTabs'); }} - accessibilityLabel={t('sidebar.settings', 'Settings')} - > - - - - - {foldersExpanded && ( - mailboxes.length === 0 ? ( - {t('sidebar.loading_mailboxes', 'Loading mailboxes...')} - ) : ( - visibleNodes.map((node) => { - const hasChildren = node.children.length > 0; - const isExpanded = expandedFolders.has(node.id); - // A shared/group account's header has no mailbox behind it — - // tapping it only opens or closes that account's folders. - const Icon = node.isAccountNode - ? Users - : iconFor(node.role, node.name, hasChildren, isExpanded); - const isSelected = !node.isAccountNode && !tagViewActive && node.id === currentMailboxId; - return ( - - } - label={node.isAccountNode ? node.name : localizeMailboxName(node.role, node.name, t)} - depth={node.depth} - isSelected={isSelected} - isAccountHeader={node.isAccountNode} - unread={node.unreadEmails} - total={node.totalEmails} - showTotal={showFolderTotalCount && !node.isAccountNode} - hasChildren={hasChildren} - isExpanded={isExpanded} - onPress={ - node.isAccountNode - ? () => toggleExpand(node.id) - : () => handleSelect(node.id) - } - onLongPress={ - node.isAccountNode - ? () => openSharedAccountSheet(node) - : () => openFolderSheet(node) - } - onToggleExpand={() => toggleExpand(node.id)} - onPressUnread={node.isAccountNode ? undefined : () => handleSelectUnread(node.id)} - /> - ); - }) - ) - )} - - {/* Tags section (#175) */} - {keywordDefs.length > 0 && ( - <> - toggleSection('tagsExpanded', setTagsExpanded)}> - {tagsExpanded ? ( - - ) : ( - - )} - {t('sidebar.tags', 'Tags')} - - {tagsExpanded && keywordDefs.map((kw) => { - const counts = tagCounts[kw.id]; - const isSelected = filters.keyword === keywordToken(kw.id); - const dot = c.tags[kw.color]?.dot ?? c.textMuted; - return ( - } - label={kw.label} - depth={0} - isSelected={isSelected} - unread={counts?.unread ?? 0} - total={counts?.total ?? 0} - showTotal={showFolderTotalCount} - hasChildren={false} - isExpanded={false} - onPress={() => selectTag(kw.id)} - onToggleExpand={() => {}} - /> - ); - })} - - )} - - + {panel} {sheet && setSheet(null)} />} @@ -1155,6 +1183,9 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps) ); } +/** Width of the permanent sidebar (the webmail's default sidebar is 256 px). */ +export const DOCKED_WIDTH = 272; + function makeStyles(c: ThemePalette) { return StyleSheet.create({ overlay: { @@ -1174,6 +1205,12 @@ function makeStyles(c: ThemePalette) { borderRightColor: c.border, }, drawerSafe: { flex: 1 }, + docked: { + width: DOCKED_WIDTH, + backgroundColor: c.secondary, + borderRightWidth: 1, + borderRightColor: c.border, + }, // Header header: { diff --git a/src/screens/EmailListScreen.tsx b/src/screens/EmailListScreen.tsx index 91b9811c..57cd0c28 100644 --- a/src/screens/EmailListScreen.tsx +++ b/src/screens/EmailListScreen.tsx @@ -99,6 +99,7 @@ const EmailRow = React.memo(function EmailRow({ onPress, onLongPress, selected, + active = false, selectionMode, loadAttachments, onOpenAttachment, @@ -116,6 +117,8 @@ const EmailRow = React.memo(function EmailRow({ onPress: (id: string) => void; onLongPress: (id: string) => void; selected: boolean; + /** The row open in the detail pane of a split view. */ + active?: boolean; selectionMode: boolean; loadAttachments?: LoadListAttachments; onOpenAttachment?: (email: Email, attachment: Attachment) => void; @@ -161,6 +164,7 @@ const EmailRow = React.memo(function EmailRow({ styles.emailRow, { paddingVertical: density.rowPaddingVertical }, pressed && styles.emailRowPressed, + active && styles.emailRowActive, selected && styles.emailRowSelected, ]} onPress={handlePress} @@ -319,9 +323,18 @@ const emailKeyExtractor = rowKeyOf; interface EmailListScreenProps { onEmailPress?: (email: Email) => void; onComposePress?: () => void; + /** Row key (`rowKeyOf`) of the message open in a split view's detail pane. */ + activeRowKey?: string | null; + /** The folder list is a permanent sidebar: no menu button, no drawer. */ + sidebarDocked?: boolean; } -export default function EmailListScreen({ onEmailPress, onComposePress }: EmailListScreenProps) { +export default function EmailListScreen({ + onEmailPress, + onComposePress, + activeRowKey = null, + sidebarDocked = false, +}: EmailListScreenProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); // The navigator's safe-area provider knows the insets on the first render; @@ -692,6 +705,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL answered={flags?.answered ?? false} forwarded={flags?.forwarded ?? false} selected={selectedIds.has(rowKeyOf(item))} + active={activeRowKey === rowKeyOf(item)} selectionMode={selectionMode} onPress={handleRowPress} onLongPress={toggleSelect} @@ -701,7 +715,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL ); }, [ - selectedIds, selectionMode, handleRowPress, toggleSelect, swipeLeftAction, swipeRightAction, + selectedIds, activeRowKey, selectionMode, handleRowPress, toggleSelect, swipeLeftAction, swipeRightAction, swipeMode, handleRowSwipe, disableThreading, rowFlags, rowTagIds, threadCountFor, showPreview, showRecipient, keywordDefs, inJunk, showAvatarsInJunk, loadAttachments, openAttachment, @@ -1076,14 +1090,16 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL ) : ( - setDrawerOpen(true)} - style={styles.headerButton} - accessibilityRole="button" - accessibilityLabel={t('sidebar.mobile.toggle_menu', 'Toggle menu')} - > - - + {!sidebarDocked && ( + setDrawerOpen(true)} + style={styles.headerButton} + accessibilityRole="button" + accessibilityLabel={t('sidebar.mobile.toggle_menu', 'Toggle menu')} + > + + + )} {headerTitle} @@ -1467,7 +1483,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL - setDrawerOpen(false)} /> + {!sidebarDocked && setDrawerOpen(false)} />} ; +type Props = NativeStackScreenProps & { + /** Shown in a split view's detail pane: no back button. */ + embedded?: boolean; + /** The pager moved to another message (the list highlights it). */ + onActiveChange?: (emailId: string) => void; +}; // How long the pages beside the one on screen wait for its body at most. const NEIGHBOUR_FALLBACK_MS = 1500; @@ -74,7 +79,7 @@ export default function EmailThreadScreen(props: Props) { return ; } -function EmailViewer({ route, navigation }: Props) { +function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Props) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const { t } = useLocaleStore(); @@ -87,6 +92,9 @@ function EmailViewer({ route, navigation }: Props) { // swiping / Prev-Next can switch messages in place without remounting the // screen — which is what produced the loading flash on every change. const [activeEmailId, setActiveEmailId] = React.useState(route.params.emailId); + const onActiveChangeRef = React.useRef(onActiveChange); + onActiveChangeRef.current = onActiveChange; + React.useEffect(() => { onActiveChangeRef.current?.(activeEmailId); }, [activeEmailId]); const insets = useSafeAreaInsets(); const { width: windowWidth } = useWindowDimensions(); // Pages are as wide as the pager itself, which is the window on a phone @@ -675,16 +683,18 @@ function EmailViewer({ route, navigation }: Props) { return ( {/* Toolbar */} - - navigation.goBack()} - style={styles.toolbarBack} - hitSlop={8} - accessibilityRole="button" - accessibilityLabel={t('common.back', 'Back')} - > - - + + {!embedded && ( + navigation.goBack()} + style={styles.toolbarBack} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel={t('common.back', 'Back')} + > + + + )} {relocatedActions.map((id) => { const def = quickActionRegistry[id]; @@ -1560,6 +1570,7 @@ function makeStyles(c: ThemePalette) { justifyContent: 'center', borderRadius: radius.full, }, + toolbarEmbedded: { justifyContent: 'flex-end' }, toolbarActions: { flexDirection: 'row', alignItems: 'center', diff --git a/src/screens/MailTab.tsx b/src/screens/MailTab.tsx new file mode 100644 index 00000000..af348f78 --- /dev/null +++ b/src/screens/MailTab.tsx @@ -0,0 +1,182 @@ +import React from 'react'; +import { StyleSheet, Text, View, useWindowDimensions } from 'react-native'; +import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { Mail } from 'lucide-react-native'; + +import EmailListScreen from './EmailListScreen'; +import EmailThreadScreen from './EmailThreadScreen'; +import SidebarDrawer, { DOCKED_WIDTH } from '../components/SidebarDrawer'; +import type { RootStackParamList } from '../navigation/types'; +import type { Email } from '../api/types'; +import { useEmailStore, viewerParamsForRow } from '../stores/email-store'; +import { useSettingsStore } from '../stores/settings-store'; +import { useLocaleStore } from '../stores/locale-store'; +import { mailboxAccountId } from '../lib/mailbox-tree'; +import { prefetchMessage } from '../lib/email-detail-cache'; +import { collapseThreads, rowKeyOf } from '../lib/thread-utils'; +import { listPaneWidth, useLayout } from '../lib/use-layout'; +import { useColors } from '../theme/colors'; +import { spacing, typography, type ThemePalette } from '../theme/tokens'; + +type ThreadParams = RootStackParamList['EmailThread']; +type RootNav = NativeStackNavigationProp; + +/** What the detail pane shows: the route params plus the row it came from. */ +interface OpenMessage { + params: ThreadParams; + rowKey: string; + /** Position of the row in the list when it was opened. */ + index: number; +} + +function threadParamsFor(email: Email): ThreadParams { + // The list holds the open folder's mail: name its account so the viewer + // never has to guess it from whatever folder is open. + const { mailboxes, currentMailboxId } = useEmailStore.getState(); + return { + emailId: email.id, + threadId: email.threadId, + subject: email.subject, + jmapAccountId: mailboxAccountId(mailboxes, currentMailboxId), + // A row of an "All folders" list or a tag view names its own account + // instead, and pages over that account's rows. + ...viewerParamsForRow(email), + }; +} + +function visibleRows(): Email[] { + return collapseThreads(useEmailStore.getState().emails, useSettingsStore.getState().disableThreading); +} + +/** + * The Mail tab. On a phone it is the list, and a message opens on the stack. + * On a wide window (`useLayout().isSplit`) the message opens in a pane next + * to the list, and on the widest the folder list is a permanent sidebar; the + * same viewer screen runs in the pane with a navigation that keeps it there. + */ +export default function MailTab({ navigation }: { navigation: RootNav }) { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + const t = useLocaleStore((s) => s.t); + const layout = useLayout(); + const { width } = useWindowDimensions(); + const [open, setOpen] = React.useState(null); + // The pager in the pane can move to another message; the list follows it. + const [activeKey, setActiveKey] = React.useState(null); + + const openEmail = React.useCallback((email: Email) => { + const params = threadParamsFor(email); + // Start on the body now: mounting the viewer takes a while. + prefetchMessage(email, params.jmapAccountId); + if (!layout.isSplit) { + navigation.navigate('EmailThread', params); + return; + } + const key = rowKeyOf(email); + const index = Math.max(0, visibleRows().findIndex((e) => rowKeyOf(e) === key)); + setOpen({ params, rowKey: key, index }); + setActiveKey(key); + }, [layout.isSplit, navigation]); + + // The window got narrow (split-screen, a fold) with a message open: carry + // it over to the stack so it stays on screen. + const openRef = React.useRef(open); + openRef.current = open; + React.useEffect(() => { + if (layout.isSplit || !openRef.current) return; + navigation.navigate('EmailThread', openRef.current.params); + setOpen(null); + setActiveKey(null); + }, [layout.isSplit, navigation]); + + // Another folder: its list has nothing to do with the message on show. + const currentMailboxId = useEmailStore((s) => s.currentMailboxId); + React.useEffect(() => { + setOpen(null); + setActiveKey(null); + }, [currentMailboxId]); + + // A message that moved out of the list (deleted, archived, moved, marked + // spam) leaves the pane to the row that took its place, as Apple Mail does. + const showNextAfterRemoval = React.useCallback(() => { + const current = openRef.current; + if (!current) return; + const rows = visibleRows(); + const next = rows[Math.min(current.index, rows.length - 1)]; + if (!next || rowKeyOf(next) === current.rowKey) { + setOpen(null); + setActiveKey(null); + return; + } + openEmail(next); + }, [openEmail]); + + // The viewer drives its stack through `goBack` / `navigate`; in the pane + // `goBack` means "this message is done" and other routes go to the stack. + const paneNavigation = React.useMemo(() => ({ + ...navigation, + goBack: showNextAfterRemoval, + navigate: (...args: unknown[]) => { + const [name, params] = args as [unknown, ThreadParams | undefined]; + if (name === 'EmailThread' && params) { + setOpen({ params, rowKey: params.emailId, index: openRef.current?.index ?? 0 }); + return; + } + (navigation.navigate as (...a: unknown[]) => void)(...args); + }, + }), [navigation, showNextAfterRemoval]); + + const onActiveChange = React.useCallback((id: string) => { + const row = useEmailStore.getState().emails.find((e) => e.id === id); + setActiveKey(row ? rowKeyOf(row) : id); + }, []); + + const list = ( + navigation.navigate('Compose')} + onEmailPress={openEmail} + activeRowKey={layout.isSplit ? activeKey : null} + sidebarDocked={layout.hasSidebar} + /> + ); + if (!layout.isSplit) return list; + + const listWidth = listPaneWidth(width - (layout.hasSidebar ? DOCKED_WIDTH : 0)); + return ( + + {layout.hasSidebar && } + {list} + + {open ? ( + ['navigation']} + embedded + onActiveChange={onActiveChange} + /> + ) : ( + + + {t('email_viewer.no_email_selected', 'No email selected')} + + {t('email_viewer.no_email_description', 'Select an email from the list to view it here')} + + + )} + + + ); +} + +function noop() {} + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + row: { flex: 1, flexDirection: 'row', backgroundColor: c.background }, + listPane: { borderRightWidth: 1, borderRightColor: c.border }, + detailPane: { flex: 1 }, + empty: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing.sm, padding: spacing.xl }, + emptyTitle: { ...typography.bodySemibold, color: c.text, marginTop: spacing.sm }, + emptyText: { ...typography.body, color: c.textMuted, textAlign: 'center' }, + }); +}