mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 08:07:11 +00:00
284 lines
13 KiB
TypeScript
284 lines
13 KiB
TypeScript
import React, { useEffect } from 'react';
|
|
import { View, Text, StyleSheet } from 'react-native';
|
|
import { ArrowRight, ArrowLeft, MoveVertical, ArrowDownWideNarrow } from '../icons';
|
|
import { SettingsSection, SettingItem, ToggleSwitch, RadioGroup, Select } from './settings-section';
|
|
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
|
|
import { useColors } from '../../theme/colors';
|
|
import { useLocaleStore } from '../../stores/locale-store';
|
|
import {
|
|
useSettingsStore,
|
|
normalizeBottomQuickActions,
|
|
REPLY_QUICK_ACTIONS,
|
|
ALL_QUICK_ACTIONS,
|
|
type SwipeAction,
|
|
type SwipeMode,
|
|
type QuickAction,
|
|
} from '../../stores/settings-store';
|
|
import { useEmailStore } from '../../stores/email-store';
|
|
import { useKeywordsStore } from '../../stores/keywords-store';
|
|
import {
|
|
ORDER_PRESETS, detectPreset, presetLevels, sanitizeSortLevels,
|
|
type OrderPreset, type MessageListOrderScope,
|
|
} from '../../lib/message-list-order';
|
|
|
|
type Translate = (key: string, fallback?: string) => string;
|
|
|
|
const swipeOptions = (t: Translate): { value: SwipeAction; label: string }[] => [
|
|
{ value: 'none', label: t('settings.email_behavior.swipe_actions.none', 'None') },
|
|
{ value: 'archive', label: t('settings.email_behavior.swipe_actions.archive', 'Archive') },
|
|
{ value: 'delete', label: t('settings.email_behavior.swipe_actions.delete', 'Delete') },
|
|
{ value: 'spam', label: t('settings.email_behavior.swipe_actions.spam', 'Mark as spam') },
|
|
{ value: 'read', label: t('settings.email_behavior.swipe_actions.mark_read', 'Toggle read') },
|
|
{ value: 'star', label: t('settings.email_behavior.swipe_actions.star', 'Toggle star') },
|
|
{ value: 'pin', label: t('settings.email_behavior.swipe_actions.pin', 'Pin') },
|
|
{ value: 'move', label: t('settings.email_behavior.swipe_actions.move', 'Move to folder') },
|
|
];
|
|
|
|
const swipeModeOptions = (t: Translate): { value: SwipeMode; label: string }[] => [
|
|
{ value: 'instant', label: t('settings.layout.swipe_mode.instant', 'Instant (swipe = action)') },
|
|
{ value: 'reveal', label: t('settings.layout.swipe_mode.reveal', 'Reveal (swipe, then tap)') },
|
|
];
|
|
|
|
const quickActionLabels = (t: Translate): Record<QuickAction, string> => ({
|
|
reply: t('email_viewer.reply', 'Reply'),
|
|
replyAll: t('email_viewer.reply_all', 'Reply All'),
|
|
forward: t('email_viewer.forward', 'Forward'),
|
|
delete: t('email_viewer.delete', 'Delete'),
|
|
archive: t('email_viewer.archive', 'Archive'),
|
|
markUnread: t('settings.layout.quick_actions.mark_unread', 'Mark Read/Unread'),
|
|
star: t('settings.layout.quick_actions.star', 'Star'),
|
|
move: t('settings.email_behavior.swipe_actions.move', 'Move to folder'),
|
|
spam: t('settings.layout.quick_actions.spam', 'Spam'),
|
|
tag: t('settings.layout.quick_actions.tag', 'Tag'),
|
|
});
|
|
|
|
const PRESET_FALLBACKS: Partial<Record<OrderPreset, string>> = {
|
|
chronological: 'Newest first',
|
|
unread_first: 'Unread first',
|
|
starred_first: 'Starred first',
|
|
tagged_first: 'Tagged first',
|
|
};
|
|
|
|
export function LayoutSettings() {
|
|
const c = useColors();
|
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
|
const swipeLeftAction = useSettingsStore((s) => s.swipeLeftAction);
|
|
const setSwipeLeftAction = useSettingsStore((s) => s.setSwipeLeftAction);
|
|
const swipeRightAction = useSettingsStore((s) => s.swipeRightAction);
|
|
const setSwipeRightAction = useSettingsStore((s) => s.setSwipeRightAction);
|
|
const swipeMode = useSettingsStore((s) => s.swipeMode);
|
|
const setSwipeMode = useSettingsStore((s) => s.setSwipeMode);
|
|
const bottomQuickActionsRaw = useSettingsStore((s) => s.bottomQuickActions);
|
|
const update = useSettingsStore((s) => s.updateSetting);
|
|
const hydrated = useSettingsStore((s) => s.hydrated);
|
|
const hydrate = useSettingsStore((s) => s.hydrate);
|
|
const t = useLocaleStore((s) => s.t);
|
|
const SWIPE_OPTIONS = React.useMemo(() => swipeOptions(t), [t]);
|
|
const SWIPE_MODE_OPTIONS = React.useMemo(() => swipeModeOptions(t), [t]);
|
|
const QUICK_ACTION_LABELS = React.useMemo(() => quickActionLabels(t), [t]);
|
|
const QUICK_ACTION_OPTIONS = React.useMemo(
|
|
() => ALL_QUICK_ACTIONS.map((value) => ({ value, label: QUICK_ACTION_LABELS[value] })),
|
|
[QUICK_ACTION_LABELS],
|
|
);
|
|
|
|
useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]);
|
|
|
|
// Message-list order presets (#718). Any change drops the cached query
|
|
// windows so the list is re-queried under the new order.
|
|
const messageListOrderRaw = useSettingsStore((s) => s.messageListOrder);
|
|
const messageListOrderScope = useSettingsStore((s) => s.messageListOrderScope);
|
|
const showAvatarsInJunk = useSettingsStore((s) => s.showAvatarsInJunk);
|
|
const showFolderTotalCount = useSettingsStore((s) => s.showFolderTotalCount);
|
|
const unifiedCrossAccount = useSettingsStore((s) => s.unifiedCrossAccount);
|
|
const keywordDefs = useKeywordsStore((s) => s.keywords);
|
|
const invalidateListOrder = useEmailStore((s) => s.invalidateListOrder);
|
|
const order = React.useMemo(() => sanitizeSortLevels(messageListOrderRaw), [messageListOrderRaw]);
|
|
const preset = detectPreset(order);
|
|
const orderTagId = order.find((l) => l.criterion === 'tag')?.tagId ?? keywordDefs[0]?.id;
|
|
const setPreset = (next: OrderPreset) => {
|
|
if (next === 'custom') return;
|
|
update('messageListOrder', presetLevels(next, orderTagId));
|
|
invalidateListOrder();
|
|
};
|
|
const setOrderTag = (tagId: string) => {
|
|
update('messageListOrder', presetLevels('tagged_first', tagId));
|
|
invalidateListOrder();
|
|
};
|
|
const setScope = (scope: MessageListOrderScope) => {
|
|
update('messageListOrderScope', scope);
|
|
invalidateListOrder();
|
|
};
|
|
const PRESET_OPTIONS = ORDER_PRESETS.map((value) => ({
|
|
value,
|
|
label: t(`settings.message_list_order.preset_${value}`, PRESET_FALLBACKS[value] ?? value),
|
|
}));
|
|
const SCOPE_OPTIONS: { value: MessageListOrderScope; label: string }[] = [
|
|
{ value: 'inbox', label: t('settings.message_list_order.scope_inbox', 'Inbox only') },
|
|
{ value: 'all', label: t('settings.message_list_order.scope_all', 'All folders') },
|
|
];
|
|
|
|
const bottomActions = normalizeBottomQuickActions(bottomQuickActionsRaw);
|
|
// Pick an action for a bar slot. If the chosen action already occupies
|
|
// another slot, swap the two so the three slots stay unique.
|
|
const setQuickActionSlot = (index: number, value: QuickAction) => {
|
|
const next = [...bottomActions];
|
|
const existing = next.indexOf(value);
|
|
if (existing !== -1 && existing !== index) {
|
|
next[existing] = next[index];
|
|
}
|
|
next[index] = value;
|
|
update('bottomQuickActions', next);
|
|
};
|
|
const relocated = REPLY_QUICK_ACTIONS.filter((a) => !bottomActions.includes(a));
|
|
|
|
return (
|
|
<SettingsSection title={t('settings.tabs.layout', 'Layout')} description={t('settings.layout.description', 'Tune the email list interactions for mobile.')}>
|
|
<View style={{ gap: spacing.sm }}>
|
|
<View style={styles.row}>
|
|
<ArrowDownWideNarrow size={14} color={c.mutedForeground} />
|
|
<Text style={styles.rowLabel}>{t('settings.message_list_order.title', 'Message list order')}</Text>
|
|
</View>
|
|
<Text style={styles.rowDescription}>
|
|
{t('settings.message_list_order.description', 'Which messages come first in the list. Newest first is the tie-breaker within each group; the header toggle still flips oldest/newest.')}
|
|
</Text>
|
|
<RadioGroup
|
|
value={preset === 'custom' ? 'chronological' : preset}
|
|
onChange={(v) => setPreset(v as OrderPreset)}
|
|
options={PRESET_OPTIONS}
|
|
/>
|
|
{preset === 'tagged_first' && keywordDefs.length > 0 && (
|
|
<View style={styles.slotRow}>
|
|
<Text style={styles.slotLabel}>{t('settings.message_list_order.tag', 'Tag')}</Text>
|
|
<Select
|
|
value={orderTagId ?? ''}
|
|
onChange={setOrderTag}
|
|
options={keywordDefs.map((k) => ({ value: k.id, label: k.label }))}
|
|
/>
|
|
</View>
|
|
)}
|
|
<Text style={styles.rowDescription}>
|
|
{t('settings.message_list_order.scope', 'Apply to')}
|
|
</Text>
|
|
<RadioGroup
|
|
value={messageListOrderScope}
|
|
onChange={(v) => setScope(v as MessageListOrderScope)}
|
|
options={SCOPE_OPTIONS}
|
|
/>
|
|
</View>
|
|
|
|
<View style={{ marginTop: spacing.lg }}>
|
|
<SettingItem
|
|
label={t('settings.layout.show_folder_total_count', 'Show total message count on folders')}
|
|
description={t('settings.layout.show_folder_total_count_description', 'Show the total next to the unread count in the folder list.')}
|
|
>
|
|
<ToggleSwitch checked={showFolderTotalCount} onChange={(v) => update('showFolderTotalCount', v)} />
|
|
</SettingItem>
|
|
<SettingItem
|
|
label={t('settings.layout.show_avatars_in_junk', 'Show sender avatars in Junk')}
|
|
description={t('settings.layout.show_avatars_in_junk_description', 'Load sender logos for messages in the Junk folder. Off keeps spam senders from being contacted.')}
|
|
>
|
|
<ToggleSwitch checked={showAvatarsInJunk} onChange={(v) => update('showAvatarsInJunk', v)} />
|
|
</SettingItem>
|
|
<SettingItem
|
|
label={t('settings.layout.unified_cross_account', 'Unified views across accounts')}
|
|
description={t('settings.layout.unified_cross_account_description', 'Combine every signed-in account in "All inboxes". Off keeps the unified views inside the active account and its shared folders.')}
|
|
noBorder
|
|
>
|
|
<ToggleSwitch checked={unifiedCrossAccount} onChange={(v) => update('unifiedCrossAccount', v)} />
|
|
</SettingItem>
|
|
</View>
|
|
|
|
<View style={{ gap: spacing.sm, marginTop: spacing.lg }}>
|
|
<Text style={styles.rowLabel}>{t('settings.layout.swipe_mode.label', 'Swipe behavior')}</Text>
|
|
<Text style={styles.rowDescription}>
|
|
{t(
|
|
'settings.layout.swipe_mode.description',
|
|
'Pick instant (swipe past the threshold to fire the action) or reveal (swipe to expose an action band, then tap to confirm).',
|
|
)}
|
|
</Text>
|
|
<RadioGroup
|
|
value={swipeMode}
|
|
onChange={(v) => setSwipeMode(v as SwipeMode)}
|
|
options={SWIPE_MODE_OPTIONS}
|
|
/>
|
|
</View>
|
|
|
|
<View style={{ gap: spacing.sm, marginTop: spacing.lg }}>
|
|
<View style={styles.row}>
|
|
<ArrowRight size={14} color={c.mutedForeground} />
|
|
<Text style={styles.rowLabel}>{t('settings.email_behavior.swipe_right_action.label_mobile', "Swipe right (left → right)")}</Text>
|
|
</View>
|
|
<Text style={styles.rowDescription}>
|
|
{t('settings.email_behavior.swipe_right_action.description_mobile', "Action when you drag a row from its left edge towards the right.")}
|
|
</Text>
|
|
<RadioGroup
|
|
value={swipeRightAction}
|
|
onChange={(v) => setSwipeRightAction(v as SwipeAction)}
|
|
options={SWIPE_OPTIONS}
|
|
/>
|
|
</View>
|
|
|
|
<View style={{ gap: spacing.sm, marginTop: spacing.lg }}>
|
|
<View style={styles.row}>
|
|
<ArrowLeft size={14} color={c.mutedForeground} />
|
|
<Text style={styles.rowLabel}>{t('settings.email_behavior.swipe_left_action.label_mobile', "Swipe left (right → left)")}</Text>
|
|
</View>
|
|
<Text style={styles.rowDescription}>
|
|
{t('settings.email_behavior.swipe_left_action.description_mobile', "Action when you drag a row from its right edge towards the left.")}
|
|
</Text>
|
|
<RadioGroup
|
|
value={swipeLeftAction}
|
|
onChange={(v) => setSwipeLeftAction(v as SwipeAction)}
|
|
options={SWIPE_OPTIONS}
|
|
/>
|
|
</View>
|
|
|
|
<View style={{ gap: spacing.sm, marginTop: spacing.lg }}>
|
|
<View style={styles.row}>
|
|
<MoveVertical size={14} color={c.mutedForeground} />
|
|
<Text style={styles.rowLabel}>{t('settings.layout.quick_actions.label', "Reader quick actions")}</Text>
|
|
</View>
|
|
<Text style={styles.rowDescription}>
|
|
{t('settings.layout.quick_actions.description', "The three buttons shown between Prev/Next at the bottom of an open email. Defaults to Reply, Reply All and Forward.")}
|
|
</Text>
|
|
{bottomActions.map((action, index) => (
|
|
<View key={index} style={styles.slotRow}>
|
|
<Text style={styles.slotLabel}>{t('settings.layout.quick_actions.slot', 'Slot {n}', { n: index + 1 })}</Text>
|
|
<Select
|
|
value={action}
|
|
onChange={(v) => setQuickActionSlot(index, v as QuickAction)}
|
|
options={QUICK_ACTION_OPTIONS}
|
|
/>
|
|
</View>
|
|
))}
|
|
{relocated.length > 0 && (
|
|
<Text style={styles.rowDescription}>
|
|
{t(
|
|
'settings.layout.quick_actions.relocated',
|
|
'{count, plural, one {{actions} is moved to the top toolbar so you can still use it.} other {{actions} are moved to the top toolbar so you can still use them.}}',
|
|
{ count: relocated.length, actions: relocated.map((a) => QUICK_ACTION_LABELS[a]).join(', ') },
|
|
)}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
|
|
</SettingsSection>
|
|
);
|
|
}
|
|
|
|
function makeStyles(c: ThemePalette) {
|
|
return StyleSheet.create({
|
|
row: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
|
rowLabel: { ...typography.bodyMedium, color: c.text },
|
|
rowDescription: { ...typography.caption, color: c.mutedForeground },
|
|
slotRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
gap: spacing.md,
|
|
marginTop: spacing.xs,
|
|
},
|
|
slotLabel: { ...typography.body, color: c.text },
|
|
});
|
|
}
|