mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +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 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
@@ -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: {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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