mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
1625 lines
62 KiB
TypeScript
1625 lines
62 KiB
TypeScript
import React from 'react';
|
|
import {
|
|
View, Text, StyleSheet, Pressable, ScrollView, Modal, TextInput, Alert,
|
|
Animated, Dimensions, Easing, ActivityIndicator,
|
|
} from 'react-native';
|
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
import {
|
|
Inbox, Send, File as FileIcon, Trash2, Ban, Archive, Star,
|
|
Folder, FolderOpen, ChevronDown, ChevronRight, X, Settings, LogOut, Check, Plus,
|
|
Clock, Layers, Users, Tag, Mails, MailOpen, StickyNote, AlarmClock, Flag,
|
|
CheckCheck, Eraser, FolderPlus, Pencil, AlertTriangle, UserMinus, Bell, BellOff, RotateCcw,
|
|
type AppIcon,
|
|
} from './icons';
|
|
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
|
import { useColors } from '../theme/colors';
|
|
import { useAnimDuration } from '../theme/dynamic';
|
|
import { useEmailStore, spannedAccounts } from '../stores/email-store';
|
|
import { useAuthStore } from '../stores/auth-store';
|
|
import { useAccountStore } from '../stores/account-store';
|
|
import { useSettingsStore } from '../stores/settings-store';
|
|
import { useKeywordsStore, keywordToken } from '../stores/keywords-store';
|
|
import { useLocaleStore } from '../stores/locale-store';
|
|
import { MAX_ACCOUNTS } from '../lib/account-utils';
|
|
import {
|
|
buildMailboxTree, flattenVisible, mailboxSubtreeIds, ownMailboxes, type MailboxNode,
|
|
} from '../lib/mailbox-tree';
|
|
import { localizeMailboxName } from '../lib/mailbox-label';
|
|
import { showUnifiedSection } from '../lib/unified-section';
|
|
import { generateAvatarColor, getAccountInitials } from '../lib/avatar-utils';
|
|
import { jmapClient } from '../api/jmap-client';
|
|
import {
|
|
markMailboxAsRead, emptyMailbox, createMailbox, updateMailbox, deleteMailbox,
|
|
} from '../api/email';
|
|
import { useTagCountsStore } from '../stores/tag-counts-store';
|
|
import { useNavigation } from '@react-navigation/native';
|
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
|
import type { RootStackParamList } from '../navigation/types';
|
|
import type { Mailbox } from '../api/types';
|
|
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
|
import { confirmSignOut } from '../lib/sign-out-confirm';
|
|
import { setPendingSettingsTab } from '../navigation/pending-settings-tab';
|
|
import {
|
|
isAccountMuted, isMailboxMuted, mailboxOverridesForAccount, resetMailboxMutes, setAccountMuted, setMailboxMuted,
|
|
type NotificationMutes,
|
|
} from '../lib/notification-mutes';
|
|
import { serverSupportsEmailPush } from '../lib/push-notifications';
|
|
|
|
const CHEVRON_SLOT = 20;
|
|
const INDENT_STEP = 12;
|
|
const ROW_PX_BASE = 8;
|
|
|
|
const STORAGE_KEYS = {
|
|
expanded: 'sidebar:expandedMailboxes',
|
|
foldersExpanded: 'sidebar:foldersExpanded',
|
|
tagsExpanded: 'sidebar:tagsExpanded',
|
|
unifiedExpanded: 'sidebar:unifiedExpanded',
|
|
};
|
|
|
|
type UnifiedRole = 'inbox' | 'sent' | 'drafts' | 'junk' | 'archive' | 'trash';
|
|
const UNIFIED_ROLES: UnifiedRole[] = ['sent', 'drafts', 'archive', 'junk', 'trash'];
|
|
// Role folders that are kept out of the own tree while a virtual row stands
|
|
// in for them (#495).
|
|
const HIDDEN_WITH_VIRTUAL_ROW = new Set(['scheduled']);
|
|
|
|
// Distinct icons for the special-use roles (#288), then a name-based guess
|
|
// for servers that don't set roles, then a plain folder.
|
|
function iconFor(
|
|
role: string | null | undefined,
|
|
name: string | undefined,
|
|
hasChildren: boolean,
|
|
isExpanded: boolean,
|
|
): AppIcon {
|
|
switch (role) {
|
|
case 'inbox': return Inbox;
|
|
case 'sent': return Send;
|
|
case 'drafts': return FileIcon;
|
|
case 'trash': return Trash2;
|
|
case 'junk':
|
|
case 'spam': return Ban;
|
|
case 'archive': return Archive;
|
|
case 'important': return Flag;
|
|
case 'flagged': return Star;
|
|
case 'all': return Mails;
|
|
case 'memos': return StickyNote;
|
|
case 'scheduled': return Clock;
|
|
case 'snoozed': return AlarmClock;
|
|
case 'shared': return Users;
|
|
default: break;
|
|
}
|
|
const lower = (name ?? '').toLowerCase();
|
|
if (lower.includes('inbox')) return Inbox;
|
|
if (lower.includes('sent')) return Send;
|
|
if (lower.includes('draft')) return FileIcon;
|
|
if (lower.includes('trash') || lower.includes('deleted')) return Trash2;
|
|
if (lower.includes('junk') || lower.includes('spam')) return Ban;
|
|
if (lower.includes('archive')) return Archive;
|
|
if (lower.includes('star') || lower.includes('flag')) return Star;
|
|
if (hasChildren) return isExpanded ? FolderOpen : Folder;
|
|
return Folder;
|
|
}
|
|
|
|
const ROLE_COLOR_FIXED: Record<string, string> = {
|
|
inbox: '#60a5fa',
|
|
sent: '#4ade80',
|
|
drafts: '#a78bfa',
|
|
junk: '#f87171',
|
|
spam: '#f87171',
|
|
archive: '#fbbf24',
|
|
important: '#f97316',
|
|
flagged: '#f59e0b',
|
|
scheduled: '#38bdf8',
|
|
snoozed: '#c084fc',
|
|
memos: '#fbbf24',
|
|
};
|
|
|
|
function iconColor(c: ThemePalette, role: string | null | undefined, isSelected: boolean): string {
|
|
if (role === 'trash') return c.textMuted;
|
|
if (role && ROLE_COLOR_FIXED[role]) return ROLE_COLOR_FIXED[role];
|
|
return isSelected ? c.text : c.textSecondary;
|
|
}
|
|
|
|
function RowCounts({ unread, total, showTotal, onPressUnread }: {
|
|
unread: number;
|
|
total: number;
|
|
showTotal: boolean;
|
|
/** Tap the unread badge to open the folder filtered to unread. */
|
|
onPressUnread?: () => void;
|
|
}) {
|
|
const c = useColors();
|
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
|
const t = useLocaleStore((s) => s.t);
|
|
if (unread === 0 && (total === 0 || !showTotal)) return null;
|
|
return (
|
|
<View style={styles.counts}>
|
|
{unread > 0 && (
|
|
<Pressable
|
|
onPress={onPressUnread}
|
|
disabled={!onPressUnread}
|
|
hitSlop={8}
|
|
accessibilityRole={onPressUnread ? 'button' : undefined}
|
|
accessibilityLabel={onPressUnread ? t('sidebar.show_unread', 'Show unread') : undefined}
|
|
>
|
|
<Text style={styles.countUnread}>{unread}</Text>
|
|
</Pressable>
|
|
)}
|
|
{showTotal && unread > 0 && total > 0 && <Text style={styles.countSep}>/</Text>}
|
|
{showTotal && total > 0 && <Text style={styles.countTotal}>{total}</Text>}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
interface SidebarRowProps {
|
|
icon: React.ReactElement;
|
|
label: string;
|
|
depth: number;
|
|
isSelected: boolean;
|
|
/** Header for a shared/group account: styled as a section, never selected. */
|
|
isAccountHeader?: boolean;
|
|
unread: number;
|
|
total: number;
|
|
showTotal: boolean;
|
|
hasChildren: boolean;
|
|
isExpanded: boolean;
|
|
/** New mail here does not notify (#78). */
|
|
muted?: boolean;
|
|
onPress: () => void;
|
|
onLongPress?: () => void;
|
|
onToggleExpand: () => void;
|
|
onPressUnread?: () => void;
|
|
}
|
|
|
|
function SidebarRow({
|
|
icon, label, depth, isSelected, isAccountHeader, unread, total, showTotal,
|
|
hasChildren, isExpanded, muted, onPress, onLongPress, onToggleExpand, onPressUnread,
|
|
}: SidebarRowProps) {
|
|
const c = useColors();
|
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
|
const t = useLocaleStore((s) => s.t);
|
|
const leftPad = ROW_PX_BASE + depth * INDENT_STEP;
|
|
return (
|
|
<Pressable
|
|
onPress={onPress}
|
|
onLongPress={onLongPress}
|
|
delayLongPress={350}
|
|
style={({ pressed }) => [
|
|
styles.row,
|
|
isSelected && styles.rowSelected,
|
|
pressed && !isSelected && styles.rowPressed,
|
|
]}
|
|
>
|
|
<View style={[styles.rowIndent, { paddingLeft: leftPad }]}>
|
|
{hasChildren ? (
|
|
<Pressable
|
|
onPress={onToggleExpand}
|
|
// The 20 dp slot keeps the tree indent; the touch area reaches
|
|
// 44 dp so a near miss doesn't open the folder instead.
|
|
hitSlop={12}
|
|
style={styles.chevron}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={isExpanded ? t('sidebar.collapse', 'Collapse') : t('sidebar.expand', 'Expand')}
|
|
accessibilityState={{ expanded: isExpanded }}
|
|
>
|
|
{isExpanded ? (
|
|
<ChevronDown size={12} color={c.textMuted} />
|
|
) : (
|
|
<ChevronRight size={12} color={c.textMuted} />
|
|
)}
|
|
</Pressable>
|
|
) : (
|
|
<View style={styles.chevron} />
|
|
)}
|
|
</View>
|
|
<View style={styles.rowIcon}>{icon}</View>
|
|
<Text
|
|
style={[
|
|
styles.rowLabel,
|
|
isSelected && styles.rowLabelSelected,
|
|
isAccountHeader && styles.rowLabelAccount,
|
|
]}
|
|
numberOfLines={1}
|
|
>
|
|
{label}
|
|
</Text>
|
|
{muted && (
|
|
<View style={styles.rowMuted} accessibilityLabel={t('sidebar.notifications_muted', 'Notifications muted')}>
|
|
<BellOff size={12} color={c.textMuted} />
|
|
</View>
|
|
)}
|
|
<RowCounts unread={unread} total={total} showTotal={showTotal} onPressUnread={onPressUnread} />
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
// A long-press action sheet for a folder (the webmail's folder context menu).
|
|
interface SheetAction {
|
|
key: string;
|
|
label: string;
|
|
icon: AppIcon;
|
|
destructive?: boolean;
|
|
onPress: () => void;
|
|
}
|
|
|
|
function ActionSheet({ title, actions, onClose }: { title: string; actions: SheetAction[]; onClose: () => void }) {
|
|
const c = useColors();
|
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
|
return (
|
|
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="fade" onRequestClose={onClose} statusBarTranslucent>
|
|
<Pressable style={styles.sheetOverlay} onPress={onClose} />
|
|
<View style={styles.sheet}>
|
|
<Text style={styles.sheetTitle} numberOfLines={1}>{title}</Text>
|
|
{actions.map((a) => {
|
|
const Icon = a.icon;
|
|
return (
|
|
<Pressable
|
|
key={a.key}
|
|
onPress={() => { onClose(); a.onPress(); }}
|
|
style={({ pressed }) => [styles.sheetRow, pressed && styles.rowPressed]}
|
|
>
|
|
<Icon size={16} color={a.destructive ? c.error : c.textSecondary} />
|
|
<Text style={[styles.sheetRowText, a.destructive && { color: c.error }]}>{a.label}</Text>
|
|
</Pressable>
|
|
);
|
|
})}
|
|
</View>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
// Cross-platform text prompt (Alert.prompt is iOS-only).
|
|
function NamePrompt({ title, message, initial, confirmLabel, onSubmit, onClose }: {
|
|
title: string;
|
|
message?: string;
|
|
initial?: string;
|
|
confirmLabel: string;
|
|
onSubmit: (value: string) => void;
|
|
onClose: () => void;
|
|
}) {
|
|
const c = useColors();
|
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
|
const t = useLocaleStore((s) => s.t);
|
|
const [value, setValue] = React.useState(initial ?? '');
|
|
const submit = () => {
|
|
const name = value.trim();
|
|
if (!name) return;
|
|
onClose();
|
|
onSubmit(name);
|
|
};
|
|
return (
|
|
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="fade" onRequestClose={onClose} statusBarTranslucent>
|
|
<Pressable style={styles.sheetOverlay} onPress={onClose} />
|
|
<View style={styles.promptCard}>
|
|
<Text style={styles.sheetTitle}>{title}</Text>
|
|
{message ? <Text style={styles.promptMessage}>{message}</Text> : null}
|
|
<TextInput
|
|
value={value}
|
|
onChangeText={setValue}
|
|
placeholder={t('mailbox_context_menu.placeholder_folder_name', 'Folder name')}
|
|
placeholderTextColor={c.textMuted}
|
|
style={styles.promptInput}
|
|
autoFocus
|
|
onSubmitEditing={submit}
|
|
returnKeyType="done"
|
|
/>
|
|
<View style={styles.promptActions}>
|
|
<Pressable onPress={onClose} style={styles.promptButton} hitSlop={6}>
|
|
<Text style={styles.promptButtonText}>{t('common.cancel', 'Cancel')}</Text>
|
|
</Pressable>
|
|
<Pressable onPress={submit} style={styles.promptButton} hitSlop={6} disabled={!value.trim()}>
|
|
<Text style={[styles.promptButtonText, { color: c.primary }, !value.trim() && { opacity: 0.5 }]}>{confirmLabel}</Text>
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
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, docked = false }: SidebarDrawerProps) {
|
|
const c = useColors();
|
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
|
const t = useLocaleStore((s) => s.t);
|
|
const mailboxes = useEmailStore((s) => s.mailboxes);
|
|
const accountSnapshots = useEmailStore((s) => s.accountSnapshots);
|
|
const currentMailboxId = useEmailStore((s) => s.currentMailboxId);
|
|
const filters = useEmailStore((s) => s.filters);
|
|
const selectMailbox = useEmailStore((s) => s.selectMailbox);
|
|
const setFilters = useEmailStore((s) => s.setFilters);
|
|
const clearSearchAndFilters = useEmailStore((s) => s.clearSearchAndFilters);
|
|
const fetchMailboxes = useEmailStore((s) => s.fetchMailboxes);
|
|
const refreshEmails = useEmailStore((s) => s.refreshEmails);
|
|
const username = useAuthStore((s) => s.username);
|
|
const serverUrl = useAuthStore((s) => s.serverUrl);
|
|
const logout = useAuthStore((s) => s.logout);
|
|
const logoutAll = useAuthStore((s) => s.logoutAll);
|
|
const switchAccount = useAuthStore((s) => s.switchAccount);
|
|
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
|
const accounts = useAccountStore((s) => s.accounts);
|
|
const setDefaultAccount = useAccountStore((s) => s.setDefaultAccount);
|
|
const showFolderTotalCount = useSettingsStore((s) => s.showFolderTotalCount);
|
|
const includeGroupInUnified = useSettingsStore((s) => s.includeGroupInUnified);
|
|
const unifiedCrossAccount = useSettingsStore((s) => s.unifiedCrossAccount);
|
|
const notificationMutes = useSettingsStore((s) => s.notificationMutes);
|
|
const updateSetting = useSettingsStore((s) => s.updateSetting);
|
|
const keywordDefs = useKeywordsStore((s) => s.keywords);
|
|
const keywordsHydrated = useKeywordsStore((s) => s.hydrated);
|
|
const hydrateKeywords = useKeywordsStore((s) => s.hydrate);
|
|
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
|
|
|
const [foldersExpanded, setFoldersExpanded] = React.useState(true);
|
|
const [tagsExpanded, setTagsExpanded] = React.useState(true);
|
|
const [unifiedExpanded, setUnifiedExpanded] = React.useState(false);
|
|
const [expandedFolders, setExpandedFolders] = React.useState<Set<string>>(() => new Set());
|
|
const [accountMenuOpen, setAccountMenuOpen] = React.useState(false);
|
|
const [sheet, setSheet] = React.useState<{ title: string; actions: SheetAction[] } | null>(null);
|
|
const [prompt, setPrompt] = React.useState<{
|
|
title: string; message?: string; initial?: string; confirmLabel: string; onSubmit: (v: string) => void;
|
|
} | null>(null);
|
|
const [busy, setBusy] = React.useState(false);
|
|
const tagCounts = useTagCountsStore((s) => s.counts);
|
|
const tagCountsGeneration = useTagCountsStore((s) => s.generation);
|
|
const ensureTagCounts = useTagCountsStore((s) => s.ensure);
|
|
|
|
React.useEffect(() => { if (!keywordsHydrated) void hydrateKeywords(); }, [keywordsHydrated, hydrateKeywords]);
|
|
|
|
React.useEffect(() => {
|
|
void (async () => {
|
|
try {
|
|
const [rawExp, rawFld, rawTags, rawUnified] = await Promise.all([
|
|
AsyncStorage.getItem(STORAGE_KEYS.expanded),
|
|
AsyncStorage.getItem(STORAGE_KEYS.foldersExpanded),
|
|
AsyncStorage.getItem(STORAGE_KEYS.tagsExpanded),
|
|
AsyncStorage.getItem(STORAGE_KEYS.unifiedExpanded),
|
|
]);
|
|
if (rawExp) {
|
|
try {
|
|
const ids = JSON.parse(rawExp) as string[];
|
|
if (Array.isArray(ids)) setExpandedFolders(new Set(ids));
|
|
} catch { /* ignore */ }
|
|
} else {
|
|
const tree = buildMailboxTree(mailboxes);
|
|
const collect = (nodes: MailboxNode[]): string[] => {
|
|
const ids: string[] = [];
|
|
for (const n of nodes) {
|
|
if (n.children.length > 0) { ids.push(n.id); ids.push(...collect(n.children)); }
|
|
}
|
|
return ids;
|
|
};
|
|
setExpandedFolders(new Set(collect(tree)));
|
|
}
|
|
if (rawFld != null) setFoldersExpanded(rawFld === 'true');
|
|
if (rawTags != null) setTagsExpanded(rawTags === 'true');
|
|
if (rawUnified != null) setUnifiedExpanded(rawUnified === 'true');
|
|
} catch { /* ignore */ }
|
|
})();
|
|
}, [mailboxes]);
|
|
|
|
// The server's Scheduled folder is hidden while the virtual row stands in
|
|
// for it (#495) — only when the server can actually schedule sends.
|
|
const hideOwnRoles = React.useMemo(
|
|
() => (jmapClient.hasDelayedSend() ? HIDDEN_WITH_VIRTUAL_ROW : undefined),
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[mailboxes],
|
|
);
|
|
const tree = React.useMemo(() => buildMailboxTree(mailboxes, { hideOwnRoles }), [mailboxes, hideOwnRoles]);
|
|
const visibleNodes = React.useMemo(
|
|
() => flattenVisible(tree, expandedFolders),
|
|
[tree, expandedFolders],
|
|
);
|
|
|
|
const persistExpanded = React.useCallback(async (next: Set<string>) => {
|
|
try {
|
|
await AsyncStorage.setItem(STORAGE_KEYS.expanded, JSON.stringify(Array.from(next)));
|
|
} catch { /* ignore */ }
|
|
}, []);
|
|
|
|
const toggleExpand = React.useCallback((id: string) => {
|
|
setExpandedFolders((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) next.delete(id); else next.add(id);
|
|
void persistExpanded(next);
|
|
return next;
|
|
});
|
|
}, [persistExpanded]);
|
|
|
|
const toggleSection = (key: keyof typeof STORAGE_KEYS, setter: React.Dispatch<React.SetStateAction<boolean>>) => {
|
|
setter((prev) => {
|
|
const next = !prev;
|
|
void AsyncStorage.setItem(STORAGE_KEYS[key], String(next)).catch(() => {});
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const handleSelect = React.useCallback((id: string) => {
|
|
// A tag view was open: leave it so the folder shows its own mail.
|
|
if (filters.keyword) clearSearchAndFilters();
|
|
void selectMailbox(id);
|
|
onClose();
|
|
}, [selectMailbox, onClose, filters.keyword, clearSearchAndFilters]);
|
|
|
|
// Tap the unread count → the folder filtered to unread (webmail sidebar).
|
|
const handleSelectUnread = React.useCallback((id: string) => {
|
|
void selectMailbox(id).then(() => setFilters({ isUnread: true }));
|
|
onClose();
|
|
}, [selectMailbox, setFilters, onClose]);
|
|
|
|
// Tag view (#175): every folder, messages carrying the tag.
|
|
const selectTag = React.useCallback((id: string) => {
|
|
setFilters({ keyword: keywordToken(id) });
|
|
onClose();
|
|
}, [setFilters, onClose]);
|
|
|
|
// Tag counts: one query pair per tag and account. A tag view lists the
|
|
// tagged mail of the own and the shared accounts, so its badge counts all
|
|
// of them (#1038). They are cached until an Email change is reported
|
|
// (PF6): opening the drawer costs no request when nothing changed, and
|
|
// while it is open a change refreshes them.
|
|
const tagAccountsKey = React.useMemo(() => spannedAccounts(mailboxes).join('|'), [mailboxes]);
|
|
React.useEffect(() => {
|
|
if (!visible || !tagsExpanded || keywordDefs.length === 0 || !activeAccountId || !jmapClient.isConnected) return;
|
|
const accounts = tagAccountsKey.split('|').map((id) => id || undefined);
|
|
void ensureTagCounts(activeAccountId, keywordDefs.map((k) => k.id), accounts);
|
|
}, [visible, tagsExpanded, keywordDefs, tagAccountsKey, activeAccountId, tagCountsGeneration, ensureTagCounts]);
|
|
|
|
// ── Folder actions (long-press) ───────────────────────────────────────
|
|
const refFor = (mb: Mailbox) => ({
|
|
id: mb.originalId ?? mb.id,
|
|
accountId: mb.isShared ? mb.accountId : undefined,
|
|
});
|
|
|
|
// Notification mutes (#78). Muting only takes effect through the server's
|
|
// emailPush filter, so the actions show only where that exists. The
|
|
// settings are keyed by the server's ids: a folder of the own account may
|
|
// carry no accountId, so the client's stands in.
|
|
let primaryJmapAccountId: string | null = null;
|
|
try { primaryJmapAccountId = jmapClient.accountId; } catch { primaryJmapAccountId = null; }
|
|
const canMute = primaryJmapAccountId !== null && serverSupportsEmailPush();
|
|
const muteAccountOf = (accountId?: string | null): string => accountId || primaryJmapAccountId || '';
|
|
const saveMutes = (next: NotificationMutes) => updateSetting('notificationMutes', next);
|
|
const muteActionsForAccount = (accountId: string): SheetAction[] => {
|
|
const muted = isAccountMuted(notificationMutes, accountId);
|
|
const overrides = mailboxOverridesForAccount(notificationMutes, accountId);
|
|
const actions: SheetAction[] = [{
|
|
key: 'mute',
|
|
label: muted
|
|
? t('mailbox_context_menu.unmute_account_notifications', 'Unmute notifications for this account')
|
|
: t('mailbox_context_menu.mute_account_notifications', 'Mute notifications for this account'),
|
|
icon: muted ? Bell : BellOff,
|
|
onPress: () => saveMutes(setAccountMuted(notificationMutes, accountId, !muted)),
|
|
}];
|
|
if (overrides.muted.length + overrides.unmuted.length > 0) {
|
|
actions.push({
|
|
key: 'reset-mutes',
|
|
label: t('mailbox_context_menu.reset_folder_notifications', 'Reset folder notification settings'),
|
|
icon: RotateCcw,
|
|
onPress: () => saveMutes(resetMailboxMutes(notificationMutes, accountId)),
|
|
});
|
|
}
|
|
return actions;
|
|
};
|
|
const openOwnAccountSheet = () => {
|
|
if (!canMute) return;
|
|
setSheet({ title: username ?? t('sidebar.folders', 'Folders'), actions: muteActionsForAccount(muteAccountOf()) });
|
|
};
|
|
const runFolderAction = async (label: string, work: () => Promise<unknown>, affectsCurrent: boolean) => {
|
|
setBusy(true);
|
|
try {
|
|
await work();
|
|
await fetchMailboxes();
|
|
if (affectsCurrent) await refreshEmails();
|
|
} catch (err) {
|
|
Alert.alert(label, err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
const markRead = (ids: string[]) => {
|
|
const targets = ids
|
|
.map((id) => mailboxes.find((m) => m.id === id))
|
|
.filter((m): m is Mailbox => !!m && m.unreadEmails > 0);
|
|
const affectsCurrent = !!currentMailboxId && ids.includes(currentMailboxId);
|
|
void runFolderAction(
|
|
t('mailbox_context_menu.toast_error_mark_read', 'Failed to mark as read'),
|
|
async () => {
|
|
for (const mb of targets) {
|
|
const ref = refFor(mb);
|
|
await markMailboxAsRead(ref.id, ref.accountId);
|
|
}
|
|
},
|
|
affectsCurrent,
|
|
);
|
|
};
|
|
const openFolderSheet = (node: MailboxNode) => {
|
|
const mb = mailboxes.find((m) => m.id === node.id);
|
|
if (!mb) return;
|
|
const name = localizeMailboxName(mb.role, mb.name, t);
|
|
const ref = refFor(mb);
|
|
const subtree = mailboxSubtreeIds(mailboxes, mb.id);
|
|
const actions: SheetAction[] = [
|
|
{ key: 'read', label: t('mailbox_context_menu.mark_folder_read', 'Mark folder as read'), icon: CheckCheck, onPress: () => markRead([mb.id]) },
|
|
];
|
|
if (subtree.length > 1) {
|
|
actions.push({ key: 'tree', label: t('mailbox_context_menu.mark_folder_tree_read', 'Mark folder & subfolders as read'), icon: CheckCheck, onPress: () => markRead(subtree) });
|
|
}
|
|
if (canMute) {
|
|
const accountId = muteAccountOf(mb.accountId);
|
|
const muted = isMailboxMuted(notificationMutes, accountId, ref.id);
|
|
actions.push({
|
|
key: 'mute',
|
|
label: muted
|
|
? t('mailbox_context_menu.unmute_notifications', 'Unmute notifications')
|
|
: t('mailbox_context_menu.mute_notifications', 'Mute notifications'),
|
|
icon: muted ? Bell : BellOff,
|
|
onPress: () => saveMutes(setMailboxMuted(notificationMutes, accountId, ref.id, !muted)),
|
|
});
|
|
}
|
|
if (!mb.isShared) {
|
|
actions.push({
|
|
key: 'all',
|
|
label: t('mailbox_context_menu.mark_all_folders_read', 'Mark all folders as read'),
|
|
icon: CheckCheck,
|
|
onPress: () => Alert.alert(
|
|
t('mailbox_context_menu.mark_all_confirm_title', 'Mark all folders as read'),
|
|
t('mailbox_context_menu.mark_all_confirm_message', 'Mark every unread message in your personal account as read?'),
|
|
[
|
|
{ text: t('common.cancel', 'Cancel'), style: 'cancel' },
|
|
{ text: t('mailbox_context_menu.mark_all_folders_read', 'Mark all folders as read'), onPress: () => markRead(ownMailboxes(mailboxes).map((m) => m.id)) },
|
|
],
|
|
),
|
|
});
|
|
}
|
|
if (mb.role === 'trash' || mb.role === 'junk' || mb.role === 'spam') {
|
|
actions.push({
|
|
key: 'empty',
|
|
label: t('mailbox_context_menu.empty_folder', 'Empty folder'),
|
|
icon: Eraser,
|
|
destructive: true,
|
|
onPress: () => Alert.alert(
|
|
t('email_list.empty_folder.confirm_title', 'Empty folder'),
|
|
t('email_list.empty_folder.confirm_message', 'All emails in this folder will be permanently deleted. This action cannot be undone.'),
|
|
[
|
|
{ text: t('common.cancel', 'Cancel'), style: 'cancel' },
|
|
{
|
|
text: t('email_list.empty_folder.confirm_button', 'Empty folder'),
|
|
style: 'destructive',
|
|
onPress: () => void runFolderAction(
|
|
t('mailbox_context_menu.toast_error_empty', 'Failed to empty folder'),
|
|
() => emptyMailbox(ref.id, ref.accountId),
|
|
currentMailboxId === mb.id,
|
|
),
|
|
},
|
|
],
|
|
),
|
|
});
|
|
}
|
|
if (mb.myRights?.mayCreateChild !== false) {
|
|
actions.push({
|
|
key: 'sub',
|
|
label: t('mailbox_context_menu.new_subfolder', 'New subfolder...'),
|
|
icon: FolderPlus,
|
|
onPress: () => setPrompt({
|
|
title: t('mailbox_context_menu.new_subfolder', 'New subfolder...'),
|
|
message: t('mailbox_context_menu.prompt_new_subfolder', 'Enter a name for the new subfolder.'),
|
|
confirmLabel: t('mailbox_context_menu.create', 'Create'),
|
|
onSubmit: (value) => void runFolderAction(
|
|
t('mailbox_context_menu.toast_error_create', 'Failed to create folder'),
|
|
async () => {
|
|
await createMailbox({ name: value, parentId: ref.id }, ref.accountId);
|
|
setExpandedFolders((prev) => {
|
|
const next = new Set(prev); next.add(mb.id); void persistExpanded(next); return next;
|
|
});
|
|
},
|
|
false,
|
|
),
|
|
}),
|
|
});
|
|
}
|
|
if (!mb.role && mb.myRights?.mayRename !== false) {
|
|
actions.push({
|
|
key: 'rename',
|
|
label: t('mailbox_context_menu.rename', 'Rename...'),
|
|
icon: Pencil,
|
|
onPress: () => setPrompt({
|
|
title: t('mailbox_context_menu.rename', 'Rename...'),
|
|
message: t('mailbox_context_menu.prompt_rename', 'Enter a new name for this folder.'),
|
|
initial: mb.name,
|
|
confirmLabel: t('mailbox_context_menu.rename_confirm', 'Rename'),
|
|
onSubmit: (value) => void runFolderAction(
|
|
t('mailbox_context_menu.toast_error_rename', 'Failed to rename folder'),
|
|
() => updateMailbox(ref.id, { name: value }, ref.accountId),
|
|
false,
|
|
),
|
|
}),
|
|
});
|
|
}
|
|
if (!mb.role && mb.myRights?.mayDelete !== false) {
|
|
actions.push({
|
|
key: 'delete',
|
|
label: t('mailbox_context_menu.delete_folder', 'Delete folder'),
|
|
icon: Trash2,
|
|
destructive: true,
|
|
onPress: () => Alert.alert(
|
|
t('mailbox_context_menu.delete_confirm_title', 'Delete folder'),
|
|
t('mailbox_context_menu.delete_confirm_message', `Permanently delete the folder "${mb.name}"? This action cannot be undone.`, { name: mb.name }),
|
|
[
|
|
{ text: t('common.cancel', 'Cancel'), style: 'cancel' },
|
|
{
|
|
text: t('common.delete', 'Delete'),
|
|
style: 'destructive',
|
|
onPress: () => void runFolderAction(
|
|
t('mailbox_context_menu.toast_error_delete', 'Failed to delete folder'),
|
|
async () => {
|
|
await deleteMailbox(ref.id, ref.accountId, { onDestroyRemoveEmails: mb.totalEmails > 0 });
|
|
if (currentMailboxId === mb.id) {
|
|
const inbox = ownMailboxes(mailboxes).find((m) => m.role === 'inbox');
|
|
if (inbox) void selectMailbox(inbox.id);
|
|
}
|
|
},
|
|
false,
|
|
),
|
|
},
|
|
],
|
|
),
|
|
});
|
|
}
|
|
setSheet({ title: name, actions });
|
|
};
|
|
const openSharedAccountSheet = (node: MailboxNode) => {
|
|
const accountId = node.accountId;
|
|
if (!accountId) return;
|
|
const canCreate = node.children.some((n) => n.myRights?.mayCreateChild !== false);
|
|
const actions: SheetAction[] = [
|
|
{
|
|
key: 'read',
|
|
label: t('mailbox_context_menu.mark_folder_tree_read', 'Mark folder & subfolders as read'),
|
|
icon: CheckCheck,
|
|
onPress: () => markRead(mailboxes.filter((m) => m.isShared && m.accountId === accountId).map((m) => m.id)),
|
|
},
|
|
];
|
|
if (canMute) actions.push(...muteActionsForAccount(accountId));
|
|
if (canCreate) {
|
|
actions.push({
|
|
key: 'new',
|
|
label: t('mailbox_context_menu.new_folder', 'New folder...'),
|
|
icon: FolderPlus,
|
|
onPress: () => setPrompt({
|
|
title: t('mailbox_context_menu.new_folder', 'New folder...'),
|
|
message: t('mailbox_context_menu.prompt_new_folder', 'Enter a name for the new folder.'),
|
|
confirmLabel: t('mailbox_context_menu.create', 'Create'),
|
|
onSubmit: (value) => void runFolderAction(
|
|
t('mailbox_context_menu.toast_error_create', 'Failed to create folder'),
|
|
() => createMailbox({ name: value }, accountId),
|
|
false,
|
|
),
|
|
}),
|
|
});
|
|
}
|
|
setSheet({ title: node.name, actions });
|
|
};
|
|
|
|
// ── Unified rows ──────────────────────────────────────────────────────
|
|
// Shown only when the views hold more than this account's own folders:
|
|
// cross-account with several accounts, or group inboxes to merge (#843).
|
|
// Counts are projected from the mailbox lists we hold: the active
|
|
// account's (own + shared) plus, cross-account, the tucked-away snapshots
|
|
// of the other accounts.
|
|
const hasSharedInbox = mailboxes.some((m) => m.isShared && m.role === 'inbox');
|
|
const showUnified = showUnifiedSection({
|
|
accountCount: accounts.length, unifiedCrossAccount, includeGroupInUnified, hasSharedInbox,
|
|
});
|
|
const unifiedCounts = React.useMemo(() => {
|
|
const pools: Mailbox[][] = [mailboxes];
|
|
if (unifiedCrossAccount) {
|
|
for (const [id, snap] of Object.entries(accountSnapshots)) {
|
|
if (id !== activeAccountId) pools.push(snap.mailboxes);
|
|
}
|
|
}
|
|
const roleCount = (role: string): { unread: number; total: number; present: boolean } => {
|
|
let unread = 0; let total = 0; let present = false;
|
|
for (const pool of pools) {
|
|
for (const m of pool) {
|
|
if (m.isShared && !includeGroupInUnified) continue;
|
|
if (m.role === role || (role === 'junk' && m.role === 'spam')) {
|
|
present = true; unread += m.unreadEmails ?? 0; total += m.totalEmails ?? 0;
|
|
}
|
|
}
|
|
}
|
|
return { unread, total, present };
|
|
};
|
|
let crossUnread = 0;
|
|
for (const pool of pools) {
|
|
for (const m of pool) {
|
|
if (m.isShared && !includeGroupInUnified) continue;
|
|
if (!['junk', 'spam', 'sent', 'archive', 'trash', 'drafts'].includes(m.role ?? '')) crossUnread += m.unreadEmails ?? 0;
|
|
}
|
|
}
|
|
return {
|
|
inbox: roleCount('inbox'),
|
|
roles: UNIFIED_ROLES.map((role) => ({ role, ...roleCount(role) })).filter((r) => r.present),
|
|
crossUnread,
|
|
};
|
|
}, [mailboxes, accountSnapshots, activeAccountId, unifiedCrossAccount, includeGroupInUnified]);
|
|
|
|
const unifiedIcon = (role: UnifiedRole): AppIcon => iconFor(role, undefined, false, false);
|
|
|
|
const slideX = React.useRef(new Animated.Value(-Dimensions.get('window').width)).current;
|
|
const overlayOpacity = React.useRef(new Animated.Value(0)).current;
|
|
const openDuration = useAnimDuration(240);
|
|
const closeDuration = useAnimDuration(200);
|
|
|
|
// Runs the slide-in. Kicked from both the visible effect and the Modal's
|
|
// onShow: on the very first open the modal's native view is not attached yet
|
|
// when the effect fires, so that first animation is dropped and the drawer
|
|
// stays parked off-screen. Re-running it once the modal is on screen commits
|
|
// the final offset (a no-op on every subsequent open).
|
|
const runOpen = React.useCallback(() => {
|
|
Animated.parallel([
|
|
Animated.timing(slideX, { toValue: 0, duration: openDuration, easing: Easing.out(Easing.cubic), useNativeDriver: true }),
|
|
Animated.timing(overlayOpacity, { toValue: 1, duration: openDuration, useNativeDriver: true }),
|
|
]).start();
|
|
}, [slideX, overlayOpacity, openDuration]);
|
|
|
|
React.useEffect(() => {
|
|
if (visible) {
|
|
runOpen();
|
|
} else {
|
|
Animated.parallel([
|
|
Animated.timing(slideX, { toValue: -Dimensions.get('window').width, duration: closeDuration, easing: Easing.in(Easing.cubic), useNativeDriver: true }),
|
|
Animated.timing(overlayOpacity, { toValue: 0, duration: closeDuration, useNativeDriver: true }),
|
|
]).start();
|
|
}
|
|
}, [visible, runOpen, slideX, overlayOpacity, closeDuration]);
|
|
|
|
const accountEmail = username || '';
|
|
const initials = React.useMemo(
|
|
() => getAccountInitials('', accountEmail),
|
|
[accountEmail],
|
|
);
|
|
const avatarBg = React.useMemo(
|
|
() => (accountEmail ? generateAvatarColor(accountEmail) : c.primary),
|
|
[accountEmail],
|
|
);
|
|
const hostname = React.useMemo(() => {
|
|
if (!serverUrl) return '';
|
|
try { return new URL(serverUrl).hostname; } catch { return serverUrl; }
|
|
}, [serverUrl]);
|
|
const accountEmailFull = React.useMemo(() => {
|
|
if (!username) return hostname;
|
|
if (username.includes('@')) return username;
|
|
return hostname ? `${username}@${hostname}` : username;
|
|
}, [username, hostname]);
|
|
|
|
const confirmRemoveAccount = (acc: { id: string; email: string; username: string }) => {
|
|
const label = acc.email || acc.username;
|
|
Alert.alert(
|
|
t('sidebar.remove_account', 'Remove account'),
|
|
t('sidebar.remove_account_confirm', `Remove ${label} from this device? You can add it back later.`, { account: label }),
|
|
[
|
|
{ text: t('common.cancel', 'Cancel'), style: 'cancel' },
|
|
{
|
|
text: t('sidebar.remove_account', 'Remove account'),
|
|
style: 'destructive',
|
|
onPress: () => { void useAuthStore.getState().removeAccount(acc.id); },
|
|
},
|
|
],
|
|
);
|
|
};
|
|
|
|
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);
|
|
confirmSignOut(t, { account: accountEmailFull || undefined }, () => { 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);
|
|
confirmSignOut(t, { all: true }, () => { 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)}
|
|
// The own account's notification mutes (#78), like the webmail's
|
|
// menu on this header.
|
|
onLongPress={canMute ? openOwnAccountSheet : undefined}
|
|
delayLongPress={350}
|
|
>
|
|
{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}
|
|
// Opens the folder settings; it used to navigate to the screen
|
|
// already shown, which only closed the drawer.
|
|
onPress={() => {
|
|
onClose();
|
|
setPendingSettingsTab('folders');
|
|
navigation.navigate('MainTabs', { screen: 'Settings' } as never);
|
|
}}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t('settings.tabs.folders', 'Folders')}
|
|
>
|
|
<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}
|
|
muted={canMute && (node.isAccountNode
|
|
? isAccountMuted(notificationMutes, muteAccountOf(node.accountId))
|
|
: isMailboxMuted(notificationMutes, muteAccountOf(node.accountId), node.originalId ?? node.id))}
|
|
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}
|
|
transparent
|
|
animationType="none"
|
|
statusBarTranslucent
|
|
onRequestClose={onClose}
|
|
onShow={runOpen}
|
|
>
|
|
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
|
|
<Pressable style={styles.overlayPress} onPress={onClose} />
|
|
</Animated.View>
|
|
|
|
<Animated.View style={[styles.drawer, { transform: [{ translateX: slideX }] }]}>
|
|
{panel}
|
|
</Animated.View>
|
|
|
|
{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)}
|
|
/>
|
|
)}
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
/** 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: {
|
|
...StyleSheet.absoluteFillObject,
|
|
backgroundColor: 'rgba(0,0,0,0.5)',
|
|
},
|
|
overlayPress: { flex: 1 },
|
|
drawer: {
|
|
position: 'absolute',
|
|
top: 0, bottom: 0, left: 0,
|
|
width: '85%',
|
|
maxWidth: 340,
|
|
// Mirror webmail's bg-secondary in both palettes - was hardcoded to the
|
|
// dark-mode value, which left light mode unreadable (dark bg + dark text).
|
|
backgroundColor: c.secondary,
|
|
borderRightWidth: 1,
|
|
borderRightColor: c.border,
|
|
},
|
|
drawerSafe: { flex: 1 },
|
|
docked: {
|
|
width: DOCKED_WIDTH,
|
|
backgroundColor: c.secondary,
|
|
borderRightWidth: 1,
|
|
borderRightColor: c.border,
|
|
},
|
|
|
|
// Header
|
|
header: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.xs,
|
|
paddingHorizontal: spacing.sm,
|
|
paddingVertical: spacing.sm,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: c.border,
|
|
},
|
|
headerClose: {
|
|
width: 36, height: 36,
|
|
alignItems: 'center', justifyContent: 'center',
|
|
borderRadius: radius.sm,
|
|
},
|
|
account: {
|
|
flex: 1,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.sm,
|
|
paddingHorizontal: spacing.sm,
|
|
paddingVertical: 6,
|
|
borderRadius: radius.sm,
|
|
},
|
|
accountPressed: { backgroundColor: c.surfaceHover },
|
|
accountChevronOpen: { transform: [{ rotate: '180deg' }] },
|
|
accountAvatar: {
|
|
width: 36, height: 36,
|
|
borderRadius: 18,
|
|
alignItems: 'center', justifyContent: 'center',
|
|
},
|
|
accountAvatarText: {
|
|
...typography.bodySemibold,
|
|
color: c.primaryForeground,
|
|
},
|
|
accountInfo: { flex: 1, minWidth: 0 },
|
|
accountName: { ...typography.bodyMedium, color: c.text },
|
|
accountEmail: { ...typography.caption, color: c.textMuted, marginTop: 1 },
|
|
|
|
// Account menu - floating popover card under the header
|
|
accountMenu: {
|
|
marginHorizontal: spacing.sm,
|
|
marginTop: spacing.xs,
|
|
marginBottom: spacing.sm,
|
|
backgroundColor: c.popover,
|
|
borderRadius: radius.md,
|
|
borderWidth: 1,
|
|
borderColor: c.border,
|
|
overflow: 'hidden',
|
|
// Elevation / shadow
|
|
shadowColor: '#000',
|
|
shadowOffset: { width: 0, height: 4 },
|
|
shadowOpacity: 0.35,
|
|
shadowRadius: 12,
|
|
elevation: 8,
|
|
},
|
|
accountMenuList: {
|
|
maxHeight: 260,
|
|
},
|
|
accountMenuRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'flex-start',
|
|
gap: spacing.sm,
|
|
paddingHorizontal: spacing.md,
|
|
paddingVertical: 10,
|
|
},
|
|
accountMenuRowActive: {
|
|
backgroundColor: 'rgba(59,130,246,0.08)',
|
|
},
|
|
accountMenuAvatarWrap: {
|
|
position: 'relative',
|
|
width: 36, height: 36,
|
|
},
|
|
accountMenuAvatar: {
|
|
width: 36, height: 36,
|
|
borderRadius: 18,
|
|
alignItems: 'center', justifyContent: 'center',
|
|
},
|
|
accountMenuAvatarText: {
|
|
...typography.caption,
|
|
fontWeight: '600',
|
|
color: c.primaryForeground,
|
|
},
|
|
accountMenuCheckBadge: {
|
|
position: 'absolute',
|
|
right: -3, bottom: -3,
|
|
width: 16, height: 16,
|
|
borderRadius: 8,
|
|
backgroundColor: c.primary,
|
|
alignItems: 'center', justifyContent: 'center',
|
|
borderWidth: 2,
|
|
borderColor: '#1f1f1f',
|
|
},
|
|
accountMenuInfo: { flex: 1, minWidth: 0, paddingTop: 1 },
|
|
accountMenuNameRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 5,
|
|
},
|
|
accountMenuName: {
|
|
...typography.bodyMedium,
|
|
color: c.text,
|
|
flexShrink: 1,
|
|
fontWeight: '600',
|
|
},
|
|
accountMenuHost: {
|
|
...typography.caption,
|
|
color: c.textMuted,
|
|
marginTop: 2,
|
|
},
|
|
accountMenuStatusRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 5,
|
|
marginTop: 4,
|
|
},
|
|
accountMenuStatusDot: {
|
|
width: 6, height: 6,
|
|
borderRadius: 3,
|
|
backgroundColor: '#22c55e',
|
|
},
|
|
accountMenuStatusDotOffline: {
|
|
backgroundColor: c.textMuted,
|
|
},
|
|
accountMenuStatusText: {
|
|
fontSize: 10,
|
|
lineHeight: 12,
|
|
color: c.textMuted,
|
|
flexShrink: 1,
|
|
},
|
|
accountMenuRemove: {
|
|
width: 28, height: 28,
|
|
alignItems: 'center', justifyContent: 'center',
|
|
borderRadius: radius.sm,
|
|
},
|
|
accountMenuDivider: {
|
|
height: StyleSheet.hairlineWidth,
|
|
backgroundColor: c.border,
|
|
},
|
|
accountMenuAction: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 10,
|
|
paddingHorizontal: spacing.md,
|
|
paddingVertical: 10,
|
|
minHeight: 40,
|
|
},
|
|
accountMenuActionPressed: { backgroundColor: c.surfaceHover },
|
|
accountMenuActionText: {
|
|
...typography.body,
|
|
color: c.text,
|
|
flexShrink: 1,
|
|
fontSize: 13,
|
|
},
|
|
|
|
// Scroll
|
|
scroll: { flex: 1 },
|
|
scrollContent: { paddingBottom: spacing.md },
|
|
|
|
// Section header
|
|
sectionHeader: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 6,
|
|
paddingHorizontal: spacing.md,
|
|
paddingTop: spacing.md,
|
|
paddingBottom: 4,
|
|
},
|
|
sectionHeaderText: {
|
|
...typography.bodySemibold,
|
|
color: c.text,
|
|
},
|
|
sectionSettings: { padding: 10 },
|
|
|
|
quickRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.sm,
|
|
minHeight: 44,
|
|
paddingHorizontal: spacing.md,
|
|
borderLeftWidth: 2,
|
|
borderLeftColor: 'transparent',
|
|
},
|
|
quickRowLabel: { ...typography.body, color: c.text },
|
|
|
|
empty: {
|
|
...typography.body,
|
|
color: c.textMuted,
|
|
paddingHorizontal: spacing.lg,
|
|
paddingVertical: spacing.sm,
|
|
},
|
|
|
|
// Row
|
|
row: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
minHeight: 44,
|
|
paddingRight: spacing.sm,
|
|
borderLeftWidth: 2,
|
|
borderLeftColor: 'transparent',
|
|
},
|
|
rowPressed: { backgroundColor: c.surfaceHover },
|
|
rowSelected: {
|
|
backgroundColor: c.accent,
|
|
borderLeftColor: c.primary,
|
|
},
|
|
rowIndent: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
flexShrink: 0,
|
|
},
|
|
chevron: {
|
|
width: CHEVRON_SLOT, height: CHEVRON_SLOT,
|
|
alignItems: 'center', justifyContent: 'center',
|
|
},
|
|
rowIcon: {
|
|
width: 16, height: 16,
|
|
alignItems: 'center', justifyContent: 'center',
|
|
marginRight: spacing.sm,
|
|
},
|
|
rowLabel: {
|
|
flex: 1,
|
|
...typography.body,
|
|
color: c.text,
|
|
},
|
|
rowLabelSelected: {
|
|
...typography.bodySemibold,
|
|
color: c.text,
|
|
},
|
|
rowMuted: { marginLeft: spacing.xs },
|
|
rowLabelAccount: {
|
|
fontSize: 13,
|
|
lineHeight: 18,
|
|
fontWeight: '500',
|
|
color: c.textSecondary,
|
|
},
|
|
counts: {
|
|
flexDirection: 'row',
|
|
alignItems: 'baseline',
|
|
gap: 4,
|
|
marginLeft: spacing.sm,
|
|
},
|
|
countUnread: {
|
|
...typography.caption,
|
|
fontWeight: '600',
|
|
color: c.text,
|
|
},
|
|
countSep: {
|
|
...typography.caption,
|
|
color: c.textMuted,
|
|
opacity: 0.6,
|
|
},
|
|
countTotal: {
|
|
...typography.caption,
|
|
color: c.textMuted,
|
|
},
|
|
|
|
// Long-press action sheet / name prompt
|
|
sheetOverlay: {
|
|
...StyleSheet.absoluteFillObject,
|
|
backgroundColor: 'rgba(0,0,0,0.5)',
|
|
},
|
|
sheet: {
|
|
position: 'absolute',
|
|
left: 0, right: 0, bottom: 0,
|
|
backgroundColor: c.popover,
|
|
borderTopLeftRadius: radius.lg,
|
|
borderTopRightRadius: radius.lg,
|
|
borderTopWidth: 1,
|
|
borderColor: c.border,
|
|
paddingTop: spacing.sm,
|
|
paddingBottom: spacing.xl,
|
|
},
|
|
sheetTitle: {
|
|
...typography.bodySemibold,
|
|
color: c.text,
|
|
paddingHorizontal: spacing.lg,
|
|
paddingVertical: spacing.sm,
|
|
},
|
|
sheetRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.md,
|
|
paddingHorizontal: spacing.lg,
|
|
minHeight: 44,
|
|
},
|
|
sheetRowText: { ...typography.body, color: c.text },
|
|
promptCard: {
|
|
position: 'absolute',
|
|
left: spacing.lg, right: spacing.lg,
|
|
top: '30%',
|
|
backgroundColor: c.popover,
|
|
borderRadius: radius.md,
|
|
borderWidth: 1,
|
|
borderColor: c.border,
|
|
paddingVertical: spacing.sm,
|
|
},
|
|
promptMessage: { ...typography.caption, color: c.textSecondary, paddingHorizontal: spacing.lg },
|
|
promptInput: {
|
|
...typography.body,
|
|
color: c.text,
|
|
marginHorizontal: spacing.lg,
|
|
marginTop: spacing.sm,
|
|
backgroundColor: c.surface,
|
|
borderWidth: 1,
|
|
borderColor: c.border,
|
|
borderRadius: radius.sm,
|
|
paddingHorizontal: spacing.md,
|
|
paddingVertical: 8,
|
|
},
|
|
promptActions: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'flex-end',
|
|
gap: spacing.sm,
|
|
paddingHorizontal: spacing.md,
|
|
paddingTop: spacing.sm,
|
|
},
|
|
promptButton: { paddingHorizontal: spacing.md, paddingVertical: spacing.sm },
|
|
promptButtonText: { ...typography.bodyMedium, color: c.text },
|
|
});
|
|
}
|