feat: show mail as folders, list and message side by side on tablets

This commit is contained in:
Linus Rath
2026-10-15 01:45:57 +02:00
parent bfa02f1177
commit 802474d3f2
5 changed files with 676 additions and 430 deletions
+20 -28
View File
@@ -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<RootStackParam
settings: t('sidebar.settings', 'Settings'),
};
const unavailable = (name: string) => 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 (
<View style={{ flex: 1, backgroundColor: c.background }}>
@@ -255,9 +258,21 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
headerShown: false,
// Tabs out of view stop re-rendering until they are shown again.
freezeOnBlur: true,
tabBarPosition: isSplit ? 'left' : 'bottom',
tabBarVariant: isSplit ? 'material' : 'uikit',
// A narrow rail (icon over label), not a wide sidebar with labels
// beside the icons.
...(isSplit ? { tabBarLabelPosition: 'below-icon' as const } : null),
tabBarActiveTintColor: c.text,
tabBarInactiveTintColor: c.textSecondary,
tabBarStyle: {
tabBarStyle: isSplit ? {
backgroundColor: c.secondary,
borderRightColor: c.border,
borderRightWidth: 1,
elevation: 0,
shadowOpacity: 0,
shadowColor: 'transparent',
} : {
backgroundColor: c.background,
borderTopColor: c.border,
borderTopWidth: 1,
@@ -291,30 +306,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
},
}}
>
{() => (
<EmailListScreen
onComposePress={() => 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,
});
}}
/>
)}
{() => <MailTab navigation={navigation} />}
</Tab.Screen>
<Tab.Screen
name="Calendar"
+416 -379
View File
@@ -302,9 +302,11 @@ function NamePrompt({ title, message, initial, confirmLabel, onSubmit, onClose }
interface SidebarDrawerProps {
visible: boolean;
onClose: () => 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 = (
<SafeAreaView style={styles.drawerSafe} edges={['top', 'bottom', 'left']}>
{/* Header: close + account switcher */}
<View style={styles.header}>
{!docked && (
<Pressable
onPress={onClose}
style={styles.headerClose}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
>
<X size={20} color={c.text} />
</Pressable>
)}
<Pressable
onPress={() => setAccountMenuOpen((v) => !v)}
style={({ pressed }) => [styles.account, pressed && styles.accountPressed]}
>
<View style={[styles.accountAvatar, { backgroundColor: avatarBg }]}>
<Text style={styles.accountAvatarText}>{initials}</Text>
</View>
<View style={styles.accountInfo}>
<Text style={styles.accountName} numberOfLines={1}>{username || t('settings.account.title', 'Account')}</Text>
{hostname ? (
<Text style={styles.accountEmail} numberOfLines={1}>{hostname}</Text>
) : null}
</View>
{busy ? (
<ActivityIndicator size="small" color={c.textMuted} />
) : (
<ChevronDown
size={16}
color={c.textMuted}
style={accountMenuOpen ? styles.accountChevronOpen : undefined}
/>
)}
</Pressable>
</View>
{accountMenuOpen && (
<View style={styles.accountMenu}>
<ScrollView style={styles.accountMenuList}>
{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 (
<Pressable
key={acc.id}
onPress={() => {
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,
]}
>
<View style={styles.accountMenuAvatarWrap}>
<View style={[styles.accountMenuAvatar, { backgroundColor: acc.avatarColor }]}>
<Text style={styles.accountMenuAvatarText}>
{getAccountInitials(acc.displayName, acc.email || acc.username)}
</Text>
</View>
{isActive && (
<View style={styles.accountMenuCheckBadge}>
<Check size={10} color={c.primaryForeground} strokeWidth={3} />
</View>
)}
</View>
<View style={styles.accountMenuInfo}>
<View style={styles.accountMenuNameRow}>
<Text style={styles.accountMenuName} numberOfLines={1}>
{acc.displayName || acc.username}
</Text>
{acc.isDefault && (
<Star size={12} color="#f59e0b" fill="#f59e0b" />
)}
</View>
<Text style={styles.accountMenuHost} numberOfLines={1}>{accEmail}</Text>
<View style={styles.accountMenuStatusRow}>
{acc.hasError ? (
<>
<AlertTriangle size={10} color={c.error} />
<Text style={[styles.accountMenuStatusText, { color: c.error }]} numberOfLines={1}>
{acc.errorMessage || t('sidebar.account_error', 'Needs attention')}
</Text>
</>
) : (
<>
{/* The connection dot only means something for the
account the live session serves. */}
{isActive && (
<View style={[
styles.accountMenuStatusDot,
!acc.isConnected && styles.accountMenuStatusDotOffline,
]} />
)}
<Text style={styles.accountMenuStatusText} numberOfLines={1}>{accHost}</Text>
</>
)}
</View>
</View>
{!isActive && (
<Pressable
onPress={() => confirmRemoveAccount(acc)}
hitSlop={8}
style={styles.accountMenuRemove}
accessibilityRole="button"
accessibilityLabel={t('sidebar.remove_account', 'Remove account')}
>
<UserMinus size={14} color={c.textMuted} />
</Pressable>
)}
</Pressable>
);
})}
</ScrollView>
{accounts.length < MAX_ACCOUNTS && (
<>
<View style={styles.accountMenuDivider} />
<Pressable
style={({ pressed }) => [
styles.accountMenuAction,
pressed && styles.accountMenuActionPressed,
]}
onPress={() => {
setAccountMenuOpen(false);
onClose();
navigation.navigate('AddAccount');
}}
>
<Plus size={16} color={c.textSecondary} />
<Text style={styles.accountMenuActionText}>{t('sidebar.add_account', 'Add account')}</Text>
</Pressable>
</>
)}
<View style={styles.accountMenuDivider} />
{accounts.length > 1 && activeAccountId && (() => {
const active = accounts.find((a) => a.id === activeAccountId);
if (!active || active.isDefault) return null;
return (
<Pressable
style={({ pressed }) => [
styles.accountMenuAction,
pressed && styles.accountMenuActionPressed,
]}
onPress={() => setDefaultAccount(active.id)}
>
<Star size={16} color={c.textSecondary} />
<Text style={styles.accountMenuActionText}>{t('sidebar.set_as_default', 'Set as default')}</Text>
</Pressable>
);
})()}
<Pressable
style={({ pressed }) => [
styles.accountMenuAction,
pressed && styles.accountMenuActionPressed,
]}
onPress={() => { setAccountMenuOpen(false); onClose(); void logout(); }}
>
<LogOut size={16} color={c.textSecondary} />
<Text style={styles.accountMenuActionText} numberOfLines={1}>
{t('sidebar.sign_out_of', `Sign out of ${accountEmailFull || 'account'}`, { account: accountEmailFull || 'account' })}
</Text>
</Pressable>
{accounts.length > 1 && (
<Pressable
style={({ pressed }) => [
styles.accountMenuAction,
pressed && styles.accountMenuActionPressed,
]}
onPress={() => { setAccountMenuOpen(false); onClose(); void logoutAll(); }}
>
<LogOut size={16} color={c.error} />
<Text style={[styles.accountMenuActionText, { color: c.error }]}>
{t('sidebar.sign_out_all', 'Sign out of all accounts')}
</Text>
</Pressable>
)}
</View>
)}
<ScrollView style={styles.scroll} contentContainerStyle={styles.scrollContent}>
{/* Quick views */}
{showUnified && (
<Pressable
style={({ pressed }) => [styles.quickRow, pressed && styles.rowPressed]}
onPress={() => { onClose(); navigation.navigate('UnifiedInbox', { role: 'inbox' }); }}
>
<Layers size={16} color={c.textSecondary} />
<Text style={styles.quickRowLabel}>
{unifiedCrossAccount && accounts.length > 1
? t('sidebar.all_accounts', 'All accounts')
: t('sidebar.unified_inbox', 'All inboxes')}
</Text>
<View style={{ flex: 1 }} />
<RowCounts unread={unifiedCounts.inbox.unread} total={unifiedCounts.inbox.total} showTotal={showFolderTotalCount} />
</Pressable>
)}
<Pressable
style={({ pressed }) => [styles.quickRow, pressed && styles.rowPressed]}
onPress={() => { onClose(); navigation.navigate('Scheduled'); }}
>
<Clock size={16} color={c.textSecondary} />
<Text style={styles.quickRowLabel}>{t('sidebar.scheduled', 'Scheduled')}</Text>
</Pressable>
{/* Unified per-role and cross views */}
{showUnified && (
<>
<Pressable style={styles.sectionHeader} onPress={() => toggleSection('unifiedExpanded', setUnifiedExpanded)}>
{unifiedExpanded ? (
<ChevronDown size={14} color={c.textMuted} />
) : (
<ChevronRight size={14} color={c.textMuted} />
)}
<Text style={styles.sectionHeaderText}>{t('sidebar.unified_mailbox', 'Unified mailbox')}</Text>
</Pressable>
{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) => (
<SidebarRow
key={row.view}
icon={<row.Icon size={16} color={c.textSecondary} />}
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 (
<SidebarRow
key={r.role}
icon={<Icon size={16} color={iconColor(c, r.role, false)} />}
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 */}
<Pressable style={styles.sectionHeader} onPress={() => toggleSection('foldersExpanded', setFoldersExpanded)}>
{foldersExpanded ? (
<ChevronDown size={14} color={c.textMuted} />
) : (
<ChevronRight size={14} color={c.textMuted} />
)}
<Text style={styles.sectionHeaderText}>{t('sidebar.folders', 'Folders')}</Text>
<View style={{ flex: 1 }} />
<Pressable
style={styles.sectionSettings}
hitSlop={8}
onPress={() => { onClose(); navigation.navigate('MainTabs'); }}
accessibilityLabel={t('sidebar.settings', 'Settings')}
>
<Settings size={14} color={c.textMuted} />
</Pressable>
</Pressable>
{foldersExpanded && (
mailboxes.length === 0 ? (
<Text style={styles.empty}>{t('sidebar.loading_mailboxes', 'Loading mailboxes...')}</Text>
) : (
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 (
<SidebarRow
key={node.id}
icon={
<Icon
size={16}
color={node.isAccountNode ? c.textMuted : iconColor(c, node.role, isSelected)}
/>
}
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 && (
<>
<Pressable style={styles.sectionHeader} onPress={() => toggleSection('tagsExpanded', setTagsExpanded)}>
{tagsExpanded ? (
<ChevronDown size={14} color={c.textMuted} />
) : (
<ChevronRight size={14} color={c.textMuted} />
)}
<Text style={styles.sectionHeaderText}>{t('sidebar.tags', 'Tags')}</Text>
</Pressable>
{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 (
<SidebarRow
key={kw.id}
icon={<Tag size={16} color={dot} fill={dot} />}
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={() => {}}
/>
);
})}
</>
)}
</ScrollView>
</SafeAreaView>
);
// On a wide screen the drawer is a permanent sidebar next to the list.
if (docked) {
return (
<View style={styles.docked}>
{panel}
{sheet && <ActionSheet title={sheet.title} actions={sheet.actions} onClose={() => setSheet(null)} />}
{prompt && (
<NamePrompt
title={prompt.title}
message={prompt.message}
initial={prompt.initial}
confirmLabel={prompt.confirmLabel}
onSubmit={prompt.onSubmit}
onClose={() => setPrompt(null)}
/>
)}
</View>
);
}
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS}
visible={visible}
@@ -760,384 +1165,7 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
</Animated.View>
<Animated.View style={[styles.drawer, { transform: [{ translateX: slideX }] }]}>
<SafeAreaView style={styles.drawerSafe} edges={['top', 'bottom', 'left']}>
{/* Header: close + account switcher */}
<View style={styles.header}>
<Pressable
onPress={onClose}
style={styles.headerClose}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('common.close', 'Close')}
>
<X size={20} color={c.text} />
</Pressable>
<Pressable
onPress={() => setAccountMenuOpen((v) => !v)}
style={({ pressed }) => [styles.account, pressed && styles.accountPressed]}
>
<View style={[styles.accountAvatar, { backgroundColor: avatarBg }]}>
<Text style={styles.accountAvatarText}>{initials}</Text>
</View>
<View style={styles.accountInfo}>
<Text style={styles.accountName} numberOfLines={1}>{username || t('settings.account.title', 'Account')}</Text>
{hostname ? (
<Text style={styles.accountEmail} numberOfLines={1}>{hostname}</Text>
) : null}
</View>
{busy ? (
<ActivityIndicator size="small" color={c.textMuted} />
) : (
<ChevronDown
size={16}
color={c.textMuted}
style={accountMenuOpen ? styles.accountChevronOpen : undefined}
/>
)}
</Pressable>
</View>
{accountMenuOpen && (
<View style={styles.accountMenu}>
<ScrollView style={styles.accountMenuList}>
{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 (
<Pressable
key={acc.id}
onPress={() => {
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,
]}
>
<View style={styles.accountMenuAvatarWrap}>
<View style={[styles.accountMenuAvatar, { backgroundColor: acc.avatarColor }]}>
<Text style={styles.accountMenuAvatarText}>
{getAccountInitials(acc.displayName, acc.email || acc.username)}
</Text>
</View>
{isActive && (
<View style={styles.accountMenuCheckBadge}>
<Check size={10} color={c.primaryForeground} strokeWidth={3} />
</View>
)}
</View>
<View style={styles.accountMenuInfo}>
<View style={styles.accountMenuNameRow}>
<Text style={styles.accountMenuName} numberOfLines={1}>
{acc.displayName || acc.username}
</Text>
{acc.isDefault && (
<Star size={12} color="#f59e0b" fill="#f59e0b" />
)}
</View>
<Text style={styles.accountMenuHost} numberOfLines={1}>{accEmail}</Text>
<View style={styles.accountMenuStatusRow}>
{acc.hasError ? (
<>
<AlertTriangle size={10} color={c.error} />
<Text style={[styles.accountMenuStatusText, { color: c.error }]} numberOfLines={1}>
{acc.errorMessage || t('sidebar.account_error', 'Needs attention')}
</Text>
</>
) : (
<>
{/* The connection dot only means something for the
account the live session serves. */}
{isActive && (
<View style={[
styles.accountMenuStatusDot,
!acc.isConnected && styles.accountMenuStatusDotOffline,
]} />
)}
<Text style={styles.accountMenuStatusText} numberOfLines={1}>{accHost}</Text>
</>
)}
</View>
</View>
{!isActive && (
<Pressable
onPress={() => confirmRemoveAccount(acc)}
hitSlop={8}
style={styles.accountMenuRemove}
accessibilityRole="button"
accessibilityLabel={t('sidebar.remove_account', 'Remove account')}
>
<UserMinus size={14} color={c.textMuted} />
</Pressable>
)}
</Pressable>
);
})}
</ScrollView>
{accounts.length < MAX_ACCOUNTS && (
<>
<View style={styles.accountMenuDivider} />
<Pressable
style={({ pressed }) => [
styles.accountMenuAction,
pressed && styles.accountMenuActionPressed,
]}
onPress={() => {
setAccountMenuOpen(false);
onClose();
navigation.navigate('AddAccount');
}}
>
<Plus size={16} color={c.textSecondary} />
<Text style={styles.accountMenuActionText}>{t('sidebar.add_account', 'Add account')}</Text>
</Pressable>
</>
)}
<View style={styles.accountMenuDivider} />
{accounts.length > 1 && activeAccountId && (() => {
const active = accounts.find((a) => a.id === activeAccountId);
if (!active || active.isDefault) return null;
return (
<Pressable
style={({ pressed }) => [
styles.accountMenuAction,
pressed && styles.accountMenuActionPressed,
]}
onPress={() => setDefaultAccount(active.id)}
>
<Star size={16} color={c.textSecondary} />
<Text style={styles.accountMenuActionText}>{t('sidebar.set_as_default', 'Set as default')}</Text>
</Pressable>
);
})()}
<Pressable
style={({ pressed }) => [
styles.accountMenuAction,
pressed && styles.accountMenuActionPressed,
]}
onPress={() => { setAccountMenuOpen(false); onClose(); void logout(); }}
>
<LogOut size={16} color={c.textSecondary} />
<Text style={styles.accountMenuActionText} numberOfLines={1}>
{t('sidebar.sign_out_of', `Sign out of ${accountEmailFull || 'account'}`, { account: accountEmailFull || 'account' })}
</Text>
</Pressable>
{accounts.length > 1 && (
<Pressable
style={({ pressed }) => [
styles.accountMenuAction,
pressed && styles.accountMenuActionPressed,
]}
onPress={() => { setAccountMenuOpen(false); onClose(); void logoutAll(); }}
>
<LogOut size={16} color={c.error} />
<Text style={[styles.accountMenuActionText, { color: c.error }]}>
{t('sidebar.sign_out_all', 'Sign out of all accounts')}
</Text>
</Pressable>
)}
</View>
)}
<ScrollView style={styles.scroll} contentContainerStyle={styles.scrollContent}>
{/* Quick views */}
{showUnified && (
<Pressable
style={({ pressed }) => [styles.quickRow, pressed && styles.rowPressed]}
onPress={() => { onClose(); navigation.navigate('UnifiedInbox', { role: 'inbox' }); }}
>
<Layers size={16} color={c.textSecondary} />
<Text style={styles.quickRowLabel}>
{unifiedCrossAccount && accounts.length > 1
? t('sidebar.all_accounts', 'All accounts')
: t('sidebar.unified_inbox', 'All inboxes')}
</Text>
<View style={{ flex: 1 }} />
<RowCounts unread={unifiedCounts.inbox.unread} total={unifiedCounts.inbox.total} showTotal={showFolderTotalCount} />
</Pressable>
)}
<Pressable
style={({ pressed }) => [styles.quickRow, pressed && styles.rowPressed]}
onPress={() => { onClose(); navigation.navigate('Scheduled'); }}
>
<Clock size={16} color={c.textSecondary} />
<Text style={styles.quickRowLabel}>{t('sidebar.scheduled', 'Scheduled')}</Text>
</Pressable>
{/* Unified per-role and cross views */}
{showUnified && (
<>
<Pressable style={styles.sectionHeader} onPress={() => toggleSection('unifiedExpanded', setUnifiedExpanded)}>
{unifiedExpanded ? (
<ChevronDown size={14} color={c.textMuted} />
) : (
<ChevronRight size={14} color={c.textMuted} />
)}
<Text style={styles.sectionHeaderText}>{t('sidebar.unified_mailbox', 'Unified mailbox')}</Text>
</Pressable>
{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) => (
<SidebarRow
key={row.view}
icon={<row.Icon size={16} color={c.textSecondary} />}
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 (
<SidebarRow
key={r.role}
icon={<Icon size={16} color={iconColor(c, r.role, false)} />}
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 */}
<Pressable style={styles.sectionHeader} onPress={() => toggleSection('foldersExpanded', setFoldersExpanded)}>
{foldersExpanded ? (
<ChevronDown size={14} color={c.textMuted} />
) : (
<ChevronRight size={14} color={c.textMuted} />
)}
<Text style={styles.sectionHeaderText}>{t('sidebar.folders', 'Folders')}</Text>
<View style={{ flex: 1 }} />
<Pressable
style={styles.sectionSettings}
hitSlop={8}
onPress={() => { onClose(); navigation.navigate('MainTabs'); }}
accessibilityLabel={t('sidebar.settings', 'Settings')}
>
<Settings size={14} color={c.textMuted} />
</Pressable>
</Pressable>
{foldersExpanded && (
mailboxes.length === 0 ? (
<Text style={styles.empty}>{t('sidebar.loading_mailboxes', 'Loading mailboxes...')}</Text>
) : (
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 (
<SidebarRow
key={node.id}
icon={
<Icon
size={16}
color={node.isAccountNode ? c.textMuted : iconColor(c, node.role, isSelected)}
/>
}
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 && (
<>
<Pressable style={styles.sectionHeader} onPress={() => toggleSection('tagsExpanded', setTagsExpanded)}>
{tagsExpanded ? (
<ChevronDown size={14} color={c.textMuted} />
) : (
<ChevronRight size={14} color={c.textMuted} />
)}
<Text style={styles.sectionHeaderText}>{t('sidebar.tags', 'Tags')}</Text>
</Pressable>
{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 (
<SidebarRow
key={kw.id}
icon={<Tag size={16} color={dot} fill={dot} />}
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={() => {}}
/>
);
})}
</>
)}
</ScrollView>
</SafeAreaView>
{panel}
</Animated.View>
{sheet && <ActionSheet title={sheet.title} actions={sheet.actions} onClose={() => 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: {
+35 -11
View File
@@ -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
</View>
) : (
<View style={styles.header}>
<Pressable
onPress={() => setDrawerOpen(true)}
style={styles.headerButton}
accessibilityRole="button"
accessibilityLabel={t('sidebar.mobile.toggle_menu', 'Toggle menu')}
>
<Menu size={20} color={c.textMuted} />
</Pressable>
{!sidebarDocked && (
<Pressable
onPress={() => setDrawerOpen(true)}
style={styles.headerButton}
accessibilityRole="button"
accessibilityLabel={t('sidebar.mobile.toggle_menu', 'Toggle menu')}
>
<Menu size={20} color={c.textMuted} />
</Pressable>
)}
<Text style={styles.headerTitle} numberOfLines={1}>
{headerTitle}
</Text>
@@ -1467,7 +1483,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
<SquarePen size={24} color={c.background} />
</Pressable>
<SidebarDrawer visible={drawerOpen} onClose={() => setDrawerOpen(false)} />
{!sidebarDocked && <SidebarDrawer visible={drawerOpen} onClose={() => setDrawerOpen(false)} />}
<ListAttachmentOpener ref={attachmentOpenerRef} />
<Modal supportedOrientations={MODAL_ORIENTATIONS}
@@ -2175,6 +2191,14 @@ function makeStyles(c: ThemePalette) {
},
emailRowPressed: { backgroundColor: c.surface },
emailRowSelected: { backgroundColor: c.selection },
// The message open next to the list (webmail: bg-selection with a primary
// bar on the start edge).
emailRowActive: {
backgroundColor: c.selection,
borderLeftWidth: 2,
borderLeftColor: c.primary,
paddingLeft: spacing.lg - 2,
},
// Mirrors the webmail's unread indicator: an 8px filled circle at the row's
// start edge, vertically centered (email-list-item.tsx, fill-unread). The
// weight/color change alone is too subtle on some device fonts (#27).
+23 -12
View File
@@ -49,7 +49,12 @@ import type { Email, EmailAddress, Identity } from '../api/types';
import type { RootStackParamList } from '../navigation/types';
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
type Props = NativeStackScreenProps<RootStackParamList, 'EmailThread'>;
type Props = NativeStackScreenProps<RootStackParamList, 'EmailThread'> & {
/** 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 <EmailViewer key={instance.current.key} {...props} />;
}
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 (
<SafeAreaView style={styles.container} edges={['top']}>
{/* Toolbar */}
<View style={styles.toolbar}>
<Pressable
onPress={() => navigation.goBack()}
style={styles.toolbarBack}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('common.back', 'Back')}
>
<ArrowLeft size={22} color={c.text} />
</Pressable>
<View style={[styles.toolbar, embedded && styles.toolbarEmbedded]}>
{!embedded && (
<Pressable
onPress={() => navigation.goBack()}
style={styles.toolbarBack}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('common.back', 'Back')}
>
<ArrowLeft size={22} color={c.text} />
</Pressable>
)}
<View style={styles.toolbarActions}>
{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',
+182
View File
@@ -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<RootStackParamList, 'MainTabs'>;
/** 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<OpenMessage | null>(null);
// The pager in the pane can move to another message; the list follows it.
const [activeKey, setActiveKey] = React.useState<string | null>(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 = (
<EmailListScreen
onComposePress={() => 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 (
<View style={styles.row}>
{layout.hasSidebar && <SidebarDrawer docked visible onClose={noop} />}
<View style={[styles.listPane, { width: listWidth }]}>{list}</View>
<View style={styles.detailPane}>
{open ? (
<EmailThreadScreen
route={{ key: 'mail-pane', name: 'EmailThread', params: open.params }}
navigation={paneNavigation as unknown as React.ComponentProps<typeof EmailThreadScreen>['navigation']}
embedded
onActiveChange={onActiveChange}
/>
) : (
<View style={styles.empty}>
<Mail size={40} color={c.textMuted} strokeWidth={1.5} />
<Text style={styles.emptyTitle}>{t('email_viewer.no_email_selected', 'No email selected')}</Text>
<Text style={styles.emptyText}>
{t('email_viewer.no_email_description', 'Select an email from the list to view it here')}
</Text>
</View>
)}
</View>
</View>
);
}
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' },
});
}