mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-04 16:06:41 +00:00
feat: show mail as folders, list and message side by side on tablets
This commit is contained in:
@@ -43,7 +43,7 @@ import ComposeScreen from './src/screens/ComposeScreen';
|
|||||||
import EmailThreadScreen from './src/screens/EmailThreadScreen';
|
import EmailThreadScreen from './src/screens/EmailThreadScreen';
|
||||||
import EmailSourceScreen from './src/screens/EmailSourceScreen';
|
import EmailSourceScreen from './src/screens/EmailSourceScreen';
|
||||||
import LoginScreen from './src/screens/LoginScreen';
|
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 FilesScreen from './src/screens/FilesScreen';
|
||||||
import CalendarScreen from './src/screens/CalendarScreen';
|
import CalendarScreen from './src/screens/CalendarScreen';
|
||||||
import ContactsScreen from './src/screens/ContactsScreen';
|
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 { useAuthStore } from './src/stores/auth-store';
|
||||||
import { useCalendarStore } from './src/stores/calendar-store';
|
import { useCalendarStore } from './src/stores/calendar-store';
|
||||||
import { useContactsStore } from './src/stores/contacts-store';
|
import { useContactsStore } from './src/stores/contacts-store';
|
||||||
import { useEmailStore, viewerParamsForRow } from './src/stores/email-store';
|
import { useEmailStore } from './src/stores/email-store';
|
||||||
import { mailboxAccountId } from './src/lib/mailbox-tree';
|
|
||||||
import { loadDetail, prefetchMessage } from './src/lib/email-detail-cache';
|
import { loadDetail, prefetchMessage } from './src/lib/email-detail-cache';
|
||||||
import { useHasCalendar, useHasContacts, useHasFiles } from './src/lib/capabilities';
|
import { useHasCalendar, useHasContacts, useHasFiles } from './src/lib/capabilities';
|
||||||
import { useSettingsStore } from './src/stores/settings-store';
|
import { useSettingsStore } from './src/stores/settings-store';
|
||||||
import { useLocaleStore } from './src/stores/locale-store';
|
import { useLocaleStore } from './src/stores/locale-store';
|
||||||
|
import { useLayout } from './src/lib/use-layout';
|
||||||
import { toast } from './src/stores/toast-store';
|
import { toast } from './src/stores/toast-store';
|
||||||
import { useNetworkStore } from './src/stores/network-store';
|
import { useNetworkStore } from './src/stores/network-store';
|
||||||
import { useUpdatesStore } from './src/stores/updates-store';
|
import { useUpdatesStore } from './src/stores/updates-store';
|
||||||
@@ -243,6 +243,9 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
|||||||
settings: t('sidebar.settings', 'Settings'),
|
settings: t('sidebar.settings', 'Settings'),
|
||||||
};
|
};
|
||||||
const unavailable = (name: string) => t('sidebar.tab_unavailable', '{name} (unavailable)', { name });
|
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 (
|
return (
|
||||||
<View style={{ flex: 1, backgroundColor: c.background }}>
|
<View style={{ flex: 1, backgroundColor: c.background }}>
|
||||||
@@ -255,9 +258,21 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
|||||||
headerShown: false,
|
headerShown: false,
|
||||||
// Tabs out of view stop re-rendering until they are shown again.
|
// Tabs out of view stop re-rendering until they are shown again.
|
||||||
freezeOnBlur: true,
|
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,
|
tabBarActiveTintColor: c.text,
|
||||||
tabBarInactiveTintColor: c.textSecondary,
|
tabBarInactiveTintColor: c.textSecondary,
|
||||||
tabBarStyle: {
|
tabBarStyle: isSplit ? {
|
||||||
|
backgroundColor: c.secondary,
|
||||||
|
borderRightColor: c.border,
|
||||||
|
borderRightWidth: 1,
|
||||||
|
elevation: 0,
|
||||||
|
shadowOpacity: 0,
|
||||||
|
shadowColor: 'transparent',
|
||||||
|
} : {
|
||||||
backgroundColor: c.background,
|
backgroundColor: c.background,
|
||||||
borderTopColor: c.border,
|
borderTopColor: c.border,
|
||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
@@ -291,30 +306,7 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{() => (
|
{() => <MailTab navigation={navigation} />}
|
||||||
<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,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Tab.Screen>
|
</Tab.Screen>
|
||||||
<Tab.Screen
|
<Tab.Screen
|
||||||
name="Calendar"
|
name="Calendar"
|
||||||
|
|||||||
+416
-379
@@ -302,9 +302,11 @@ function NamePrompt({ title, message, initial, confirmLabel, onSubmit, onClose }
|
|||||||
interface SidebarDrawerProps {
|
interface SidebarDrawerProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
onClose: () => void;
|
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 c = useColors();
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const t = useLocaleStore((s) => s.t);
|
const t = useLocaleStore((s) => s.t);
|
||||||
@@ -746,6 +748,409 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
|
|||||||
|
|
||||||
const tagViewActive = !!filters.keyword;
|
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 (
|
return (
|
||||||
<Modal supportedOrientations={MODAL_ORIENTATIONS}
|
<Modal supportedOrientations={MODAL_ORIENTATIONS}
|
||||||
visible={visible}
|
visible={visible}
|
||||||
@@ -760,384 +1165,7 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps)
|
|||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
<Animated.View style={[styles.drawer, { transform: [{ translateX: slideX }] }]}>
|
<Animated.View style={[styles.drawer, { transform: [{ translateX: slideX }] }]}>
|
||||||
<SafeAreaView style={styles.drawerSafe} edges={['top', 'bottom', 'left']}>
|
{panel}
|
||||||
{/* 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>
|
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
{sheet && <ActionSheet title={sheet.title} actions={sheet.actions} onClose={() => setSheet(null)} />}
|
{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) {
|
function makeStyles(c: ThemePalette) {
|
||||||
return StyleSheet.create({
|
return StyleSheet.create({
|
||||||
overlay: {
|
overlay: {
|
||||||
@@ -1174,6 +1205,12 @@ function makeStyles(c: ThemePalette) {
|
|||||||
borderRightColor: c.border,
|
borderRightColor: c.border,
|
||||||
},
|
},
|
||||||
drawerSafe: { flex: 1 },
|
drawerSafe: { flex: 1 },
|
||||||
|
docked: {
|
||||||
|
width: DOCKED_WIDTH,
|
||||||
|
backgroundColor: c.secondary,
|
||||||
|
borderRightWidth: 1,
|
||||||
|
borderRightColor: c.border,
|
||||||
|
},
|
||||||
|
|
||||||
// Header
|
// Header
|
||||||
header: {
|
header: {
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ const EmailRow = React.memo(function EmailRow({
|
|||||||
onPress,
|
onPress,
|
||||||
onLongPress,
|
onLongPress,
|
||||||
selected,
|
selected,
|
||||||
|
active = false,
|
||||||
selectionMode,
|
selectionMode,
|
||||||
loadAttachments,
|
loadAttachments,
|
||||||
onOpenAttachment,
|
onOpenAttachment,
|
||||||
@@ -116,6 +117,8 @@ const EmailRow = React.memo(function EmailRow({
|
|||||||
onPress: (id: string) => void;
|
onPress: (id: string) => void;
|
||||||
onLongPress: (id: string) => void;
|
onLongPress: (id: string) => void;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
|
/** The row open in the detail pane of a split view. */
|
||||||
|
active?: boolean;
|
||||||
selectionMode: boolean;
|
selectionMode: boolean;
|
||||||
loadAttachments?: LoadListAttachments;
|
loadAttachments?: LoadListAttachments;
|
||||||
onOpenAttachment?: (email: Email, attachment: Attachment) => void;
|
onOpenAttachment?: (email: Email, attachment: Attachment) => void;
|
||||||
@@ -161,6 +164,7 @@ const EmailRow = React.memo(function EmailRow({
|
|||||||
styles.emailRow,
|
styles.emailRow,
|
||||||
{ paddingVertical: density.rowPaddingVertical },
|
{ paddingVertical: density.rowPaddingVertical },
|
||||||
pressed && styles.emailRowPressed,
|
pressed && styles.emailRowPressed,
|
||||||
|
active && styles.emailRowActive,
|
||||||
selected && styles.emailRowSelected,
|
selected && styles.emailRowSelected,
|
||||||
]}
|
]}
|
||||||
onPress={handlePress}
|
onPress={handlePress}
|
||||||
@@ -319,9 +323,18 @@ const emailKeyExtractor = rowKeyOf;
|
|||||||
interface EmailListScreenProps {
|
interface EmailListScreenProps {
|
||||||
onEmailPress?: (email: Email) => void;
|
onEmailPress?: (email: Email) => void;
|
||||||
onComposePress?: () => 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 c = useColors();
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
// The navigator's safe-area provider knows the insets on the first render;
|
// 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}
|
answered={flags?.answered ?? false}
|
||||||
forwarded={flags?.forwarded ?? false}
|
forwarded={flags?.forwarded ?? false}
|
||||||
selected={selectedIds.has(rowKeyOf(item))}
|
selected={selectedIds.has(rowKeyOf(item))}
|
||||||
|
active={activeRowKey === rowKeyOf(item)}
|
||||||
selectionMode={selectionMode}
|
selectionMode={selectionMode}
|
||||||
onPress={handleRowPress}
|
onPress={handleRowPress}
|
||||||
onLongPress={toggleSelect}
|
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,
|
swipeMode, handleRowSwipe, disableThreading, rowFlags, rowTagIds, threadCountFor,
|
||||||
showPreview, showRecipient, keywordDefs, inJunk, showAvatarsInJunk,
|
showPreview, showRecipient, keywordDefs, inJunk, showAvatarsInJunk,
|
||||||
loadAttachments, openAttachment,
|
loadAttachments, openAttachment,
|
||||||
@@ -1076,14 +1090,16 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
|||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Pressable
|
{!sidebarDocked && (
|
||||||
onPress={() => setDrawerOpen(true)}
|
<Pressable
|
||||||
style={styles.headerButton}
|
onPress={() => setDrawerOpen(true)}
|
||||||
accessibilityRole="button"
|
style={styles.headerButton}
|
||||||
accessibilityLabel={t('sidebar.mobile.toggle_menu', 'Toggle menu')}
|
accessibilityRole="button"
|
||||||
>
|
accessibilityLabel={t('sidebar.mobile.toggle_menu', 'Toggle menu')}
|
||||||
<Menu size={20} color={c.textMuted} />
|
>
|
||||||
</Pressable>
|
<Menu size={20} color={c.textMuted} />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
<Text style={styles.headerTitle} numberOfLines={1}>
|
<Text style={styles.headerTitle} numberOfLines={1}>
|
||||||
{headerTitle}
|
{headerTitle}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -1467,7 +1483,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL
|
|||||||
<SquarePen size={24} color={c.background} />
|
<SquarePen size={24} color={c.background} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
<SidebarDrawer visible={drawerOpen} onClose={() => setDrawerOpen(false)} />
|
{!sidebarDocked && <SidebarDrawer visible={drawerOpen} onClose={() => setDrawerOpen(false)} />}
|
||||||
<ListAttachmentOpener ref={attachmentOpenerRef} />
|
<ListAttachmentOpener ref={attachmentOpenerRef} />
|
||||||
|
|
||||||
<Modal supportedOrientations={MODAL_ORIENTATIONS}
|
<Modal supportedOrientations={MODAL_ORIENTATIONS}
|
||||||
@@ -2175,6 +2191,14 @@ function makeStyles(c: ThemePalette) {
|
|||||||
},
|
},
|
||||||
emailRowPressed: { backgroundColor: c.surface },
|
emailRowPressed: { backgroundColor: c.surface },
|
||||||
emailRowSelected: { backgroundColor: c.selection },
|
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
|
// 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
|
// start edge, vertically centered (email-list-item.tsx, fill-unread). The
|
||||||
// weight/color change alone is too subtle on some device fonts (#27).
|
// weight/color change alone is too subtle on some device fonts (#27).
|
||||||
|
|||||||
@@ -49,7 +49,12 @@ import type { Email, EmailAddress, Identity } from '../api/types';
|
|||||||
import type { RootStackParamList } from '../navigation/types';
|
import type { RootStackParamList } from '../navigation/types';
|
||||||
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
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.
|
// How long the pages beside the one on screen wait for its body at most.
|
||||||
const NEIGHBOUR_FALLBACK_MS = 1500;
|
const NEIGHBOUR_FALLBACK_MS = 1500;
|
||||||
@@ -74,7 +79,7 @@ export default function EmailThreadScreen(props: Props) {
|
|||||||
return <EmailViewer key={instance.current.key} {...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 c = useColors();
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
const { t } = useLocaleStore();
|
const { t } = useLocaleStore();
|
||||||
@@ -87,6 +92,9 @@ function EmailViewer({ route, navigation }: Props) {
|
|||||||
// swiping / Prev-Next can switch messages in place without remounting the
|
// swiping / Prev-Next can switch messages in place without remounting the
|
||||||
// screen — which is what produced the loading flash on every change.
|
// screen — which is what produced the loading flash on every change.
|
||||||
const [activeEmailId, setActiveEmailId] = React.useState(route.params.emailId);
|
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 insets = useSafeAreaInsets();
|
||||||
const { width: windowWidth } = useWindowDimensions();
|
const { width: windowWidth } = useWindowDimensions();
|
||||||
// Pages are as wide as the pager itself, which is the window on a phone
|
// 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 (
|
return (
|
||||||
<SafeAreaView style={styles.container} edges={['top']}>
|
<SafeAreaView style={styles.container} edges={['top']}>
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<View style={styles.toolbar}>
|
<View style={[styles.toolbar, embedded && styles.toolbarEmbedded]}>
|
||||||
<Pressable
|
{!embedded && (
|
||||||
onPress={() => navigation.goBack()}
|
<Pressable
|
||||||
style={styles.toolbarBack}
|
onPress={() => navigation.goBack()}
|
||||||
hitSlop={8}
|
style={styles.toolbarBack}
|
||||||
accessibilityRole="button"
|
hitSlop={8}
|
||||||
accessibilityLabel={t('common.back', 'Back')}
|
accessibilityRole="button"
|
||||||
>
|
accessibilityLabel={t('common.back', 'Back')}
|
||||||
<ArrowLeft size={22} color={c.text} />
|
>
|
||||||
</Pressable>
|
<ArrowLeft size={22} color={c.text} />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
<View style={styles.toolbarActions}>
|
<View style={styles.toolbarActions}>
|
||||||
{relocatedActions.map((id) => {
|
{relocatedActions.map((id) => {
|
||||||
const def = quickActionRegistry[id];
|
const def = quickActionRegistry[id];
|
||||||
@@ -1560,6 +1570,7 @@ function makeStyles(c: ThemePalette) {
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
borderRadius: radius.full,
|
borderRadius: radius.full,
|
||||||
},
|
},
|
||||||
|
toolbarEmbedded: { justifyContent: 'flex-end' },
|
||||||
toolbarActions: {
|
toolbarActions: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user