feat: lock or hide the settings the organisation pins or hides

This commit is contained in:
Linus Rath
2026-10-31 15:40:05 +01:00
parent efb92593b2
commit f27e2cebb4
6 changed files with 231 additions and 103 deletions
@@ -4,6 +4,8 @@ import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-s
import Button from '../Button'; import Button from '../Button';
import { spacing } from '../../theme/tokens'; import { spacing } from '../../theme/tokens';
import { useLocaleStore } from '../../stores/locale-store'; import { useLocaleStore } from '../../stores/locale-store';
import { usePolicyStore, useSettingHidden, useSettingLocked } from '../../stores/policy-store';
import { policyAllowsValue } from '../../lib/org-policy';
import { APP_FONTS, FONT_SIZES, useSettingsStore, type AppFont, type FontSize } from '../../stores/settings-store'; import { APP_FONTS, FONT_SIZES, useSettingsStore, type AppFont, type FontSize } from '../../stores/settings-store';
import { uiFontsBundled } from '../../theme/reading-fonts'; import { uiFontsBundled } from '../../theme/reading-fonts';
import { toast } from '../../stores/toast-store'; import { toast } from '../../stores/toast-store';
@@ -81,6 +83,16 @@ export function AccessibilitySettings() {
const animationsEnabled = useSettingsStore((s) => s.animationsEnabled); const animationsEnabled = useSettingsStore((s) => s.animationsEnabled);
const toastDuration = useSettingsStore((s) => s.toastDuration); const toastDuration = useSettingsStore((s) => s.toastDuration);
const sendConfirmation = useSettingsStore((s) => s.sendConfirmation); const sendConfirmation = useSettingsStore((s) => s.sendConfirmation);
// What the organisation pins or hides (webmail accessibility-settings.tsx):
// a minimum notification duration leaves out the shorter choices.
const policyRules = usePolicyStore((s) => s.rules);
const toastDurationLocked = useSettingLocked('toastDuration');
const sendConfirmationLocked = useSettingLocked('sendConfirmation');
const sendConfirmationHidden = useSettingHidden('sendConfirmation');
const toastChoices = React.useMemo(
() => TOAST_DURATION_CHOICES.filter((value) => value === toastDuration || policyAllowsValue('toastDuration', value, policyRules)),
[toastDuration, policyRules],
);
const confirmDelete = useSettingsStore((s) => s.confirmDelete); const confirmDelete = useSettingsStore((s) => s.confirmDelete);
const confirmArchive = useSettingsStore((s) => s.confirmArchive); const confirmArchive = useSettingsStore((s) => s.confirmArchive);
@@ -214,23 +226,27 @@ export function AccessibilitySettings() {
<SettingItem <SettingItem
label={t('settings.accessibility.toast_duration.label', 'Notification duration')} label={t('settings.accessibility.toast_duration.label', 'Notification duration')}
description={t('settings.accessibility.toast_duration.description', 'How long messages such as "Moved to Archive" with their Undo button stay on screen.')} description={t('settings.accessibility.toast_duration.description', 'How long messages such as "Moved to Archive" with their Undo button stay on screen.')}
locked={toastDurationLocked}
> >
<Select <Select
value={String(toastDuration)} value={String(toastDuration)}
onChange={(v) => update('toastDuration', Number(v))} onChange={(v) => update('toastDuration', Number(v))}
options={TOAST_DURATION_CHOICES.map((value) => ({ value: String(value), label: durationLabel(value) }))} options={toastChoices.map((value) => ({ value: String(value), label: durationLabel(value) }))}
/>
</SettingItem>
<SettingItem
label={t('settings.accessibility.confirm_send.label', 'Confirm before sending')}
description={t('settings.accessibility.confirm_send.description', 'Asks before a message is sent.')}
>
<ToggleSwitch
checked={sendConfirmation}
onChange={(v) => update('sendConfirmation', v)}
accessibilityLabel={t('settings.accessibility.confirm_send.label', 'Confirm before sending')}
/> />
</SettingItem> </SettingItem>
{!sendConfirmationHidden && (
<SettingItem
label={t('settings.accessibility.confirm_send.label', 'Confirm before sending')}
description={t('settings.accessibility.confirm_send.description', 'Asks before a message is sent.')}
locked={sendConfirmationLocked}
>
<ToggleSwitch
checked={sendConfirmation}
onChange={(v) => update('sendConfirmation', v)}
accessibilityLabel={t('settings.accessibility.confirm_send.label', 'Confirm before sending')}
/>
</SettingItem>
)}
<SettingItem <SettingItem
label={t('settings.accessibility.confirm_delete.label', 'Confirm before deleting')} label={t('settings.accessibility.confirm_delete.label', 'Confirm before deleting')}
description={t('settings.accessibility.confirm_delete.description', 'Asks before a message is deleted, also for single messages.')} description={t('settings.accessibility.confirm_delete.description', 'Asks before a message is deleted, also for single messages.')}
+23 -16
View File
@@ -7,7 +7,7 @@ import { useSettingsStore, type ThemeMode, type Density } from '../../stores/set
import { useLocaleStore } from '../../stores/locale-store'; import { useLocaleStore } from '../../stores/locale-store';
import { setPendingSettingsTab } from '../../navigation/pending-settings-tab'; import { setPendingSettingsTab } from '../../navigation/pending-settings-tab';
import { densityPreviewConfig } from './density-preview'; import { densityPreviewConfig } from './density-preview';
import { useSettingLocked } from '../../stores/policy-store'; import { useSettingHidden, useSettingLocked } from '../../stores/policy-store';
type Theme = ThemeMode; type Theme = ThemeMode;
@@ -84,6 +84,8 @@ export function AppearanceSettings() {
const setTheme = useSettingsStore((s) => s.setTheme); const setTheme = useSettingsStore((s) => s.setTheme);
// The organisation's theme policy can pin the colour mode (#1280). // The organisation's theme policy can pin the colour mode (#1280).
const themeLocked = useSettingLocked('theme'); const themeLocked = useSettingLocked('theme');
const densityLocked = useSettingLocked('density');
const densityHidden = useSettingHidden('density');
const density = useSettingsStore((s) => s.density); const density = useSettingsStore((s) => s.density);
const setDensity = useSettingsStore((s) => s.setDensity); const setDensity = useSettingsStore((s) => s.setDensity);
const showToolbarLabels = useSettingsStore((s) => s.showToolbarLabels); const showToolbarLabels = useSettingsStore((s) => s.showToolbarLabels);
@@ -122,21 +124,25 @@ export function AppearanceSettings() {
link="page" link="page"
/> />
<View> {!densityHidden && (
<SettingItem label={t('settings.appearance.list_density.label', "Density")} description={t('settings.appearance.list_density.description', "Control spacing and padding across the UI")} noBorder /> <View>
<RadioGroup <SettingItem label={t('settings.appearance.list_density.label', "Density")} description={t('settings.appearance.list_density.description', "Control spacing and padding across the UI")} noBorder locked={densityLocked} />
value={density} <View pointerEvents={densityLocked ? 'none' : 'auto'} style={densityLocked && styles.lockedControl}>
onChange={(v) => setDensity(v as Density)} <RadioGroup
options={[ value={density}
{ value: 'extra-compact', label: t('settings.appearance.list_density.extra_compact', "Extra Compact") }, onChange={(v) => setDensity(v as Density)}
{ value: 'compact', label: t('settings.appearance.list_density.compact', "Compact") }, options={[
{ value: 'regular', label: t('settings.appearance.list_density.regular', "Regular") }, { value: 'extra-compact', label: t('settings.appearance.list_density.extra_compact', "Extra Compact") },
{ value: 'comfortable', label: t('settings.appearance.list_density.comfortable', "Comfortable") }, { value: 'compact', label: t('settings.appearance.list_density.compact', "Compact") },
]} { value: 'regular', label: t('settings.appearance.list_density.regular', "Regular") },
/> { value: 'comfortable', label: t('settings.appearance.list_density.comfortable', "Comfortable") },
<DensityPreview density={density} /> ]}
<View style={styles.divider} /> />
</View> </View>
<DensityPreview density={density} />
<View style={styles.divider} />
</View>
)}
<SettingItem label={t('settings.appearance.toolbar_labels.label', "Show Toolbar Labels")} description={t('settings.appearance.toolbar_labels.description_mobile', "Display text labels next to toolbar icons.")}> <SettingItem label={t('settings.appearance.toolbar_labels.label', "Show Toolbar Labels")} description={t('settings.appearance.toolbar_labels.description_mobile', "Display text labels next to toolbar icons.")}>
<ToggleSwitch checked={showToolbarLabels} onChange={setShowToolbarLabels} /> <ToggleSwitch checked={showToolbarLabels} onChange={setShowToolbarLabels} />
@@ -194,6 +200,7 @@ function makeStyles(c: ThemePalette) {
fontSize: 11, fontSize: 11,
color: c.textMuted, color: c.textMuted,
}, },
lockedControl: { opacity: 0.6 },
divider: { divider: {
height: 1, height: 1,
backgroundColor: c.border, backgroundColor: c.border,
+12 -1
View File
@@ -7,6 +7,7 @@ import { spacing, radius, typography, type ThemePalette } from '../../theme/toke
import { useColors } from '../../theme/colors'; import { useColors } from '../../theme/colors';
import { useSettingsStore, type ReplyIdentityMatch, type SignaturePosition } from '../../stores/settings-store'; import { useSettingsStore, type ReplyIdentityMatch, type SignaturePosition } from '../../stores/settings-store';
import { useLocaleStore } from '../../stores/locale-store'; import { useLocaleStore } from '../../stores/locale-store';
import { useSettingLocked } from '../../stores/policy-store';
import { SUPPORTED_SUB_ADDRESS_DELIMITERS } from '../../lib/sub-addressing'; import { SUPPORTED_SUB_ADDRESS_DELIMITERS } from '../../lib/sub-addressing';
import { RADIO_GROUP_A11Y, radioOptionA11y } from '../radio-a11y'; import { RADIO_GROUP_A11Y, radioOptionA11y } from '../radio-a11y';
@@ -24,6 +25,10 @@ export function ComposingSettings() {
const setAttachmentReminderKeywords = useSettingsStore((s) => s.setAttachmentReminderKeywords); const setAttachmentReminderKeywords = useSettingsStore((s) => s.setAttachmentReminderKeywords);
const sendDelaySeconds = useSettingsStore((s) => s.sendDelaySeconds); const sendDelaySeconds = useSettingsStore((s) => s.sendDelaySeconds);
const plainTextMode = useSettingsStore((s) => s.plainTextMode); const plainTextMode = useSettingsStore((s) => s.plainTextMode);
// Settings the organisation can pin (webmail lib/admin/restriction-keys.ts).
const autoSelectReplyIdentityLocked = useSettingLocked('autoSelectReplyIdentity');
const replyIdentityMatchLocked = useSettingLocked('replyIdentityMatch');
const plainTextModeLocked = useSettingLocked('plainTextMode');
const signaturePosition = useSettingsStore((s) => s.signaturePosition); const signaturePosition = useSettingsStore((s) => s.signaturePosition);
const signatureSeparatorEnabled = useSettingsStore((s) => s.signatureSeparatorEnabled); const signatureSeparatorEnabled = useSettingsStore((s) => s.signatureSeparatorEnabled);
const requestReadReceiptDefault = useSettingsStore((s) => s.requestReadReceiptDefault); const requestReadReceiptDefault = useSettingsStore((s) => s.requestReadReceiptDefault);
@@ -117,6 +122,7 @@ export function ComposingSettings() {
<SettingItem <SettingItem
label={t('settings.email_behavior.auto_select_reply_identity.label', "Reply From Received Address")} label={t('settings.email_behavior.auto_select_reply_identity.label', "Reply From Received Address")}
description={t('settings.email_behavior.auto_select_reply_identity.description_mobile', "Replies always come from the identity a message was sent to. With this on, a reply to another address on your domains (a catch-all alias) is sent from that address too.")} description={t('settings.email_behavior.auto_select_reply_identity.description_mobile', "Replies always come from the identity a message was sent to. With this on, a reply to another address on your domains (a catch-all alias) is sent from that address too.")}
locked={autoSelectReplyIdentityLocked}
> >
<ToggleSwitch checked={autoSelectReplyIdentity} onChange={setAutoSelectReplyIdentity} /> <ToggleSwitch checked={autoSelectReplyIdentity} onChange={setAutoSelectReplyIdentity} />
</SettingItem> </SettingItem>
@@ -127,7 +133,10 @@ export function ComposingSettings() {
<Text style={styles.subDescription}> <Text style={styles.subDescription}>
{t('settings.email_behavior.reply_identity_match.description', 'Which received addresses count as yours. Exact address only picks one of your configured identities. Same domain also treats any other address on one of your identity domains as a catch-all alias and rewrites the From header to it. Choose exact address if those addresses are distribution lists rather than aliases.')} {t('settings.email_behavior.reply_identity_match.description', 'Which received addresses count as yours. Exact address only picks one of your configured identities. Same domain also treats any other address on one of your identity domains as a catch-all alias and rewrites the From header to it. Choose exact address if those addresses are distribution lists rather than aliases.')}
</Text> </Text>
{segmented(REPLY_IDENTITY_MATCHES, replyIdentityMatch, (v) => updateSetting('replyIdentityMatch', v))} {replyIdentityMatchLocked && <Text style={styles.subDescription}>{t('settings.org_locked', 'Set by your organisation')}</Text>}
<View pointerEvents={replyIdentityMatchLocked ? 'none' : 'auto'} style={replyIdentityMatchLocked && styles.lockedControl}>
{segmented(REPLY_IDENTITY_MATCHES, replyIdentityMatch, (v) => updateSetting('replyIdentityMatch', v))}
</View>
</View> </View>
)} )}
@@ -137,6 +146,7 @@ export function ComposingSettings() {
// the webmail's description (sending only) leaves out; same text as // the webmail's description (sending only) leaves out; same text as
// under Reading. // under Reading.
description={t('settings.email_behavior.plain_text_mode.description_mobile', 'Compose and read in plain text only.')} description={t('settings.email_behavior.plain_text_mode.description_mobile', 'Compose and read in plain text only.')}
locked={plainTextModeLocked}
> >
<ToggleSwitch checked={plainTextMode} onChange={(v) => updateSetting('plainTextMode', v)} /> <ToggleSwitch checked={plainTextMode} onChange={(v) => updateSetting('plainTextMode', v)} />
</SettingItem> </SettingItem>
@@ -271,6 +281,7 @@ function makeStyles(c: ThemePalette) {
subBlock: { paddingVertical: spacing.md, gap: spacing.sm }, subBlock: { paddingVertical: spacing.md, gap: spacing.sm },
subLabel: { ...typography.bodyMedium, color: c.text }, subLabel: { ...typography.bodyMedium, color: c.text },
subDescription: { ...typography.caption, color: c.mutedForeground }, subDescription: { ...typography.caption, color: c.mutedForeground },
lockedControl: { opacity: 0.6 },
segmentRow: { flexDirection: 'row', gap: 6, marginTop: 4 }, segmentRow: { flexDirection: 'row', gap: 6, marginTop: 4 },
segment: { segment: {
flex: 1, flex: 1,
@@ -19,6 +19,7 @@ import { useAuthStore } from '../../stores/auth-store';
import { useEmailStore } from '../../stores/email-store'; import { useEmailStore } from '../../stores/email-store';
import { useLocaleStore } from '../../stores/locale-store'; import { useLocaleStore } from '../../stores/locale-store';
import { useSettingsStore } from '../../stores/settings-store'; import { useSettingsStore } from '../../stores/settings-store';
import { useSettingHidden, useSettingLocked } from '../../stores/policy-store';
import { import {
DEFAULT_RELAY_BASE_URL, DEFAULT_RELAY_BASE_URL,
disablePushForAccount, disablePushForAccount,
@@ -99,6 +100,13 @@ export function NotificationSettings() {
// The Inbox-only filter needs the server's emailPush filter too (webmail #983). // The Inbox-only filter needs the server's emailPush filter too (webmail #983).
const inboxOnly = useSettingsStore((s) => s.pushNotifyInboxOnly); const inboxOnly = useSettingsStore((s) => s.pushNotifyInboxOnly);
const calEnabled = useSettingsStore((s) => s.calendarNotificationsEnabled); const calEnabled = useSettingsStore((s) => s.calendarNotificationsEnabled);
// Settings the organisation pins or hides (webmail notification-settings.tsx).
const emailEnabledLocked = useSettingLocked('emailNotificationsEnabled');
const emailEnabledHidden = useSettingHidden('emailNotificationsEnabled');
const inboxOnlyLocked = useSettingLocked('pushNotifyInboxOnly');
const inboxOnlyHidden = useSettingHidden('pushNotifyInboxOnly');
const calEnabledLocked = useSettingLocked('calendarNotificationsEnabled');
const calEnabledHidden = useSettingHidden('calendarNotificationsEnabled');
const invitationParsing = useSettingsStore((s) => s.calendarInvitationParsingEnabled); const invitationParsing = useSettingsStore((s) => s.calendarInvitationParsingEnabled);
const appIconBadge = useSettingsStore((s) => s.appIconUnreadBadge); const appIconBadge = useSettingsStore((s) => s.appIconUnreadBadge);
@@ -570,16 +578,20 @@ export function NotificationSettings() {
title={t('settings.notifications.email.title', 'Email Notifications')} title={t('settings.notifications.email.title', 'Email Notifications')}
description={t('settings.notifications.email.description', 'Configure notifications for incoming emails')} description={t('settings.notifications.email.description', 'Configure notifications for incoming emails')}
> >
<SettingItem {!emailEnabledHidden && (
label={t('settings.notifications.email.enabled', 'Email notifications')} <SettingItem
description={t('settings.notifications.email.enabled_desc', 'Show notifications when new emails arrive')} label={t('settings.notifications.email.enabled', 'Email notifications')}
> description={t('settings.notifications.email.enabled_desc', 'Show notifications when new emails arrive')}
<ToggleSwitch checked={emailEnabled} onChange={(v) => update('emailNotificationsEnabled', v)} /> locked={emailEnabledLocked}
</SettingItem> >
{inboxOnlyAvailable && ( <ToggleSwitch checked={emailEnabled} onChange={(v) => update('emailNotificationsEnabled', v)} />
</SettingItem>
)}
{inboxOnlyAvailable && !inboxOnlyHidden && (
<SettingItem <SettingItem
label={t('settings.notifications.email.inbox_only', 'Inbox only')} label={t('settings.notifications.email.inbox_only', 'Inbox only')}
description={t('settings.notifications.email.inbox_only_desc', 'Only notify for mail that lands in the Inbox; skip mail a filter files into another folder')} description={t('settings.notifications.email.inbox_only_desc', 'Only notify for mail that lands in the Inbox; skip mail a filter files into another folder')}
locked={inboxOnlyLocked}
> >
<ToggleSwitch <ToggleSwitch
checked={inboxOnly} checked={inboxOnly}
@@ -629,12 +641,15 @@ export function NotificationSettings() {
title={t('settings.notifications.calendar.title', 'Calendar Notifications')} title={t('settings.notifications.calendar.title', 'Calendar Notifications')}
description={t('settings.notifications.calendar.description', 'Configure notifications for calendar events')} description={t('settings.notifications.calendar.description', 'Configure notifications for calendar events')}
> >
<SettingItem {!calEnabledHidden && (
label={t('settings.notifications.calendar.enabled', 'Event notifications')} <SettingItem
description={t('settings.notifications.calendar.enabled_desc', 'Show alerts for upcoming calendar events')} label={t('settings.notifications.calendar.enabled', 'Event notifications')}
> description={t('settings.notifications.calendar.enabled_desc', 'Show alerts for upcoming calendar events')}
<ToggleSwitch checked={calEnabled} onChange={(v) => update('calendarNotificationsEnabled', v)} /> locked={calEnabledLocked}
</SettingItem> >
<ToggleSwitch checked={calEnabled} onChange={(v) => update('calendarNotificationsEnabled', v)} />
</SettingItem>
)}
<SettingItem <SettingItem
label={t('settings.notifications.calendar.invitation_parsing', 'Parse email invitations')} label={t('settings.notifications.calendar.invitation_parsing', 'Parse email invitations')}
description={t( description={t(
+93 -62
View File
@@ -18,7 +18,7 @@ import { useEmailStore } from '../../stores/email-store';
import { archiveEmails, queryEmails, getEmails } from '../../api/email'; import { archiveEmails, queryEmails, getEmails } from '../../api/email';
import { ownMailboxes } from '../../lib/mailbox-tree'; import { ownMailboxes } from '../../lib/mailbox-tree';
import { useLocaleStore } from '../../stores/locale-store'; import { useLocaleStore } from '../../stores/locale-store';
import { usePolicyFeature } from '../../stores/policy-store'; import { usePolicyFeature, useSettingHidden, useSettingLocked } from '../../stores/policy-store';
export function ReadingSettings() { export function ReadingSettings() {
const c = useColors(); const c = useColors();
@@ -51,6 +51,22 @@ export function ReadingSettings() {
const plainTextFont = useSettingsStore((s) => s.plainTextFont); const plainTextFont = useSettingsStore((s) => s.plainTextFont);
const messageSpacing = useSettingsStore((s) => s.messageSpacing); const messageSpacing = useSettingsStore((s) => s.messageSpacing);
const readReceiptResponse = useSettingsStore((s) => s.readReceiptResponse); const readReceiptResponse = useSettingsStore((s) => s.readReceiptResponse);
// Settings the organisation pins or hides (webmail reading-settings.tsx,
// message-list-settings.tsx, layout-settings.tsx).
const markAsReadLocked = useSettingLocked('markAsReadDelay');
const markAsReadHidden = useSettingHidden('markAsReadDelay');
const deleteActionLocked = useSettingLocked('deleteAction');
const deleteActionHidden = useSettingHidden('deleteAction');
const showPreviewLocked = useSettingLocked('showPreview');
const showPreviewHidden = useSettingHidden('showPreview');
const includeGroupLocked = useSettingLocked('includeGroupInUnified');
const includeGroupHidden = useSettingHidden('includeGroupInUnified');
const plainTextModeLocked = useSettingLocked('plainTextMode');
const autoSelectReplyIdentityLocked = useSettingLocked('autoSelectReplyIdentity');
const messageSpacingLocked = useSettingLocked('messageSpacing');
const messageSpacingHidden = useSettingHidden('messageSpacing');
const emailsPerPageLocked = useSettingLocked('emailsPerPage');
const emailsPerPageHidden = useSettingHidden('emailsPerPage');
const [newKeyword, setNewKeyword] = useState(''); const [newKeyword, setNewKeyword] = useState('');
const [reorganizing, setReorganizing] = useState(false); const [reorganizing, setReorganizing] = useState(false);
@@ -118,38 +134,44 @@ export function ReadingSettings() {
return ( return (
<SettingsSection title={t('settings.email_behavior.title', "Email Behavior")} description={t('settings.email_behavior.description', "Configure how emails are handled")}> <SettingsSection title={t('settings.email_behavior.title', "Email Behavior")} description={t('settings.email_behavior.description', "Configure how emails are handled")}>
<SettingItem label={t('settings.email_behavior.mark_read.label', "Mark as Read")} description={t('settings.email_behavior.mark_read.description', "When to mark emails as read when opened")}> {!markAsReadHidden && (
<Select <SettingItem label={t('settings.email_behavior.mark_read.label', "Mark as Read")} description={t('settings.email_behavior.mark_read.description', "When to mark emails as read when opened")} locked={markAsReadLocked}>
value={String(markAsReadDelay)} <Select
onChange={(v) => update('markAsReadDelay', Number(v))} value={String(markAsReadDelay)}
options={[ onChange={(v) => update('markAsReadDelay', Number(v))}
{ value: '0', label: t('settings.email_behavior.mark_read.instant', "Instantly") }, options={[
{ value: '3000', label: t('settings.email_behavior.mark_read.delay_3s', "After 3 seconds") }, { value: '0', label: t('settings.email_behavior.mark_read.instant', "Instantly") },
{ value: '5000', label: t('settings.email_behavior.mark_read.delay_5s', "After 5 seconds") }, { value: '3000', label: t('settings.email_behavior.mark_read.delay_3s', "After 3 seconds") },
{ value: '-1', label: t('settings.email_behavior.mark_read.never', "Never") }, { value: '5000', label: t('settings.email_behavior.mark_read.delay_5s', "After 5 seconds") },
]} { value: '-1', label: t('settings.email_behavior.mark_read.never', "Never") },
/> ]}
</SettingItem> />
</SettingItem>
)}
<View style={styles.group}> {!deleteActionHidden && (
<SettingItem label={t('settings.email_behavior.delete_action.label', "Delete Action")} description={t('settings.email_behavior.delete_action.description', "What happens when you delete an email")} noBorder /> <View style={styles.group}>
<Select <SettingItem label={t('settings.email_behavior.delete_action.label', "Delete Action")} description={t('settings.email_behavior.delete_action.description', "What happens when you delete an email")} noBorder locked={deleteActionLocked} />
value={deleteAction} <View pointerEvents={deleteActionLocked ? 'none' : 'auto'} style={deleteActionLocked && styles.lockedControl}>
onChange={(v) => update('deleteAction', v as DeleteAction)} <Select
options={[ value={deleteAction}
{ value: 'trash', label: t('settings.email_behavior.delete_action.trash', "Move to Trash") }, onChange={(v) => update('deleteAction', v as DeleteAction)}
{ value: 'trash-and-read', label: t('settings.email_behavior.delete_action.trash_and_read', "Move to Trash and mark as read") }, options={[
{ value: 'permanent', label: t('settings.email_behavior.delete_action.permanent', "Delete Permanently") }, { value: 'trash', label: t('settings.email_behavior.delete_action.trash', "Move to Trash") },
]} { value: 'trash-and-read', label: t('settings.email_behavior.delete_action.trash_and_read', "Move to Trash and mark as read") },
/> { value: 'permanent', label: t('settings.email_behavior.delete_action.permanent', "Delete Permanently") },
{deleteAction === 'permanent' && ( ]}
<View style={styles.warning}> />
<AlertTriangle size={14} color={c.error} />
<Text style={styles.warningText}>{t('settings.email_behavior.delete_action.warning', "Emails will be permanently deleted and cannot be recovered. This action is irreversible.")}</Text>
</View> </View>
)} {deleteAction === 'permanent' && (
<View style={styles.divider} /> <View style={styles.warning}>
</View> <AlertTriangle size={14} color={c.error} />
<Text style={styles.warningText}>{t('settings.email_behavior.delete_action.warning', "Emails will be permanently deleted and cannot be recovered. This action is irreversible.")}</Text>
</View>
)}
<View style={styles.divider} />
</View>
)}
{archiveEnabled && ( {archiveEnabled && (
<View style={styles.group}> <View style={styles.group}>
@@ -196,9 +218,11 @@ export function ReadingSettings() {
<ToggleSwitch checked={clearSearchOnFolderChange} onChange={(v) => update('clearSearchOnFolderChange', v)} /> <ToggleSwitch checked={clearSearchOnFolderChange} onChange={(v) => update('clearSearchOnFolderChange', v)} />
</SettingItem> </SettingItem>
<SettingItem label={t('settings.email_behavior.show_preview.label', "Show Preview Text")} description={t('settings.email_behavior.show_preview.description', "Display email preview in the list")}> {!showPreviewHidden && (
<ToggleSwitch checked={showPreview} onChange={(v) => update('showPreview', v)} /> <SettingItem label={t('settings.email_behavior.show_preview.label', "Show Preview Text")} description={t('settings.email_behavior.show_preview.description', "Display email preview in the list")} locked={showPreviewLocked}>
</SettingItem> <ToggleSwitch checked={showPreview} onChange={(v) => update('showPreview', v)} />
</SettingItem>
)}
<SettingItem label={t('settings.email_behavior.show_verification_codes.label', "Show Verification Codes")} description={t('settings.email_behavior.show_verification_codes.description', "Offer the code from sign-in and confirmation emails for one-click copying, in the list for a day and in the message")}> <SettingItem label={t('settings.email_behavior.show_verification_codes.label', "Show Verification Codes")} description={t('settings.email_behavior.show_verification_codes.description', "Offer the code from sign-in and confirmation emails for one-click copying, in the list for a day and in the message")}>
<ToggleSwitch checked={showVerificationCodes} onChange={(v) => update('showVerificationCodes', v)} /> <ToggleSwitch checked={showVerificationCodes} onChange={(v) => update('showVerificationCodes', v)} />
@@ -208,15 +232,17 @@ export function ReadingSettings() {
<ToggleSwitch checked={disableThreading} onChange={(v) => update('disableThreading', v)} /> <ToggleSwitch checked={disableThreading} onChange={(v) => update('disableThreading', v)} />
</SettingItem> </SettingItem>
<SettingItem label={t('settings.appearance.unified_mailbox.include_group.label', "Include group inboxes")} description={t('settings.appearance.unified_mailbox.include_group.description', "Also merge shared/group inboxes into the unified view.")}> {!includeGroupHidden && (
<ToggleSwitch checked={includeGroupInUnified} onChange={(v) => update('includeGroupInUnified', v)} /> <SettingItem label={t('settings.appearance.unified_mailbox.include_group.label', "Include group inboxes")} description={t('settings.appearance.unified_mailbox.include_group.description', "Also merge shared/group inboxes into the unified view.")} locked={includeGroupLocked}>
</SettingItem> <ToggleSwitch checked={includeGroupInUnified} onChange={(v) => update('includeGroupInUnified', v)} />
</SettingItem>
)}
<SettingItem label={t('settings.email_behavior.plain_text_mode.label', "Plain Text Only")} description={t('settings.email_behavior.plain_text_mode.description_mobile', "Compose and read in plain text only.")}> <SettingItem label={t('settings.email_behavior.plain_text_mode.label', "Plain Text Only")} description={t('settings.email_behavior.plain_text_mode.description_mobile', "Compose and read in plain text only.")} locked={plainTextModeLocked}>
<ToggleSwitch checked={plainTextMode} onChange={(v) => update('plainTextMode', v)} /> <ToggleSwitch checked={plainTextMode} onChange={(v) => update('plainTextMode', v)} />
</SettingItem> </SettingItem>
<SettingItem label={t('settings.email_behavior.auto_select_reply_identity.label', "Reply From Received Address")} description={t('settings.email_behavior.auto_select_reply_identity.description_mobile', "When replying, send from the address the message was originally sent to.")}> <SettingItem label={t('settings.email_behavior.auto_select_reply_identity.label', "Reply From Received Address")} description={t('settings.email_behavior.auto_select_reply_identity.description_mobile', "When replying, send from the address the message was originally sent to.")} locked={autoSelectReplyIdentityLocked}>
<ToggleSwitch checked={autoSelectReplyIdentity} onChange={(v) => update('autoSelectReplyIdentity', v)} /> <ToggleSwitch checked={autoSelectReplyIdentity} onChange={(v) => update('autoSelectReplyIdentity', v)} />
</SettingItem> </SettingItem>
@@ -320,17 +346,19 @@ export function ReadingSettings() {
/> />
</SettingItem> </SettingItem>
<SettingItem label={t('settings.email_behavior.message_spacing.label', "Message Spacing")} description={t('settings.email_behavior.message_spacing.description', "Padding around the message body in the reader")}> {!messageSpacingHidden && (
<Select <SettingItem label={t('settings.email_behavior.message_spacing.label', "Message Spacing")} description={t('settings.email_behavior.message_spacing.description', "Padding around the message body in the reader")} locked={messageSpacingLocked}>
value={messageSpacing} <Select
onChange={(v) => update('messageSpacing', v as MessageSpacing)} value={messageSpacing}
options={[ onChange={(v) => update('messageSpacing', v as MessageSpacing)}
{ value: 'auto', label: t('settings.email_behavior.message_spacing.auto', "Auto (recommended)") }, options={[
{ value: 'always', label: t('settings.email_behavior.message_spacing.always', "Always add spacing") }, { value: 'auto', label: t('settings.email_behavior.message_spacing.auto', "Auto (recommended)") },
{ value: 'edge', label: t('settings.email_behavior.message_spacing.edge', "Edge to edge") }, { value: 'always', label: t('settings.email_behavior.message_spacing.always', "Always add spacing") },
]} { value: 'edge', label: t('settings.email_behavior.message_spacing.edge', "Edge to edge") },
/> ]}
</SettingItem> />
</SettingItem>
)}
<SettingItem label={t('settings.email_behavior.read_receipt_response.label', "Respond to read-receipt requests")} description={t('settings.email_behavior.read_receipt_response.description', "What to do when an incoming message asks for a read receipt.")}> <SettingItem label={t('settings.email_behavior.read_receipt_response.label', "Respond to read-receipt requests")} description={t('settings.email_behavior.read_receipt_response.description', "What to do when an incoming message asks for a read receipt.")}>
<Select <Select
@@ -344,18 +372,20 @@ export function ReadingSettings() {
/> />
</SettingItem> </SettingItem>
<SettingItem label={t('settings.email_behavior.emails_per_page.label', "Emails Per Page")} description={t('settings.email_behavior.emails_per_page.description', "Number of emails to load at once")}> {!emailsPerPageHidden && (
<Select <SettingItem label={t('settings.email_behavior.emails_per_page.label', "Emails Per Page")} description={t('settings.email_behavior.emails_per_page.description', "Number of emails to load at once")} locked={emailsPerPageLocked}>
value={String(emailsPerPage)} <Select
onChange={(v) => update('emailsPerPage', Number(v))} value={String(emailsPerPage)}
options={[ onChange={(v) => update('emailsPerPage', Number(v))}
{ value: '10', label: '10' }, options={[
{ value: '25', label: '25' }, { value: '10', label: '10' },
{ value: '50', label: '50' }, { value: '25', label: '25' },
{ value: '100', label: '100' }, { value: '50', label: '50' },
]} { value: '100', label: '100' },
/> ]}
</SettingItem> />
</SettingItem>
)}
</SettingsSection> </SettingsSection>
); );
@@ -364,6 +394,7 @@ export function ReadingSettings() {
function makeStyles(c: ThemePalette) { function makeStyles(c: ThemePalette) {
return StyleSheet.create({ return StyleSheet.create({
group: {}, group: {},
lockedControl: { opacity: 0.6 },
warning: { warning: {
flexDirection: 'row', flexDirection: 'row',
gap: spacing.sm, gap: spacing.sm,
@@ -63,4 +63,52 @@ describe('organisation policy on the settings pages', () => {
expect(reading).toContain("const archiveEnabled = usePolicyFeature('archiveActionEnabled');"); expect(reading).toContain("const archiveEnabled = usePolicyFeature('archiveActionEnabled');");
expect(reading).toMatch(/\{archiveEnabled && \(\s*<View style=\{styles\.group\}>\s*<SettingItem label=\{t\('settings\.email_behavior\.archive_mode\.label'/); expect(reading).toMatch(/\{archiveEnabled && \(\s*<View style=\{styles\.group\}>\s*<SettingItem label=\{t\('settings\.email_behavior\.archive_mode\.label'/);
}); });
// The webmail's lockable settings (lib/admin/restriction-keys.ts, page
// `policy`) the app has a row for, and whether the webmail also hides the
// row when the restriction says so.
const LOCKABLE_ROWS: ReadonlyArray<[page: string, key: string, hides: boolean]> = [
['AppearanceSettings', 'density', true],
['AccessibilitySettings', 'toastDuration', false],
['AccessibilitySettings', 'sendConfirmation', true],
['ReadingSettings', 'markAsReadDelay', true],
['ReadingSettings', 'deleteAction', true],
['ReadingSettings', 'showPreview', true],
['ReadingSettings', 'includeGroupInUnified', true],
['ReadingSettings', 'plainTextMode', false],
['ReadingSettings', 'autoSelectReplyIdentity', false],
['ReadingSettings', 'messageSpacing', true],
['ReadingSettings', 'emailsPerPage', true],
['ComposingSettings', 'autoSelectReplyIdentity', false],
['ComposingSettings', 'replyIdentityMatch', false],
['ComposingSettings', 'plainTextMode', false],
['NotificationSettings', 'emailNotificationsEnabled', true],
['NotificationSettings', 'pushNotifyInboxOnly', true],
['NotificationSettings', 'calendarNotificationsEnabled', true],
];
it.each(LOCKABLE_ROWS)('%s marks %s as set by the organisation when locked', (page, key, hides) => {
const source = settings(page);
const locked = new RegExp(`const (\\w+) = useSettingLocked\\('${key}'\\);`).exec(source);
expect(locked, `useSettingLocked('${key}')`).not.toBeNull();
// On the row, or on the control of a row whose control sits outside it.
expect(source).toMatch(new RegExp(`locked=\\{${locked![1]}\\}|pointerEvents=\\{${locked![1]} \\? 'none' : 'auto'\\}`));
if (hides) {
const hidden = new RegExp(`const (\\w+) = useSettingHidden\\('${key}'\\);`).exec(source);
expect(hidden, `useSettingHidden('${key}')`).not.toBeNull();
expect(source).toMatch(new RegExp(`\\{!${hidden![1]} && \\(|&& !${hidden![1]} && \\(`));
}
});
it('keeps notification durations under the organisation minimum out of the choices', () => {
const page = settings('AccessibilitySettings');
expect(page).toContain("policyAllowsValue('toastDuration', value, policyRules)");
expect(page).toContain('options={toastChoices.map(');
});
it('keeps the controls of locked rows that sit outside their row still', () => {
expect(settings('AppearanceSettings')).toMatch(/<View pointerEvents=\{densityLocked \? 'none' : 'auto'\}[^>]*>\s*<RadioGroup\s+value=\{density\}/);
expect(settings('ReadingSettings')).toMatch(/<View pointerEvents=\{deleteActionLocked \? 'none' : 'auto'\}[^>]*>\s*<Select\s+value=\{deleteAction\}/);
expect(settings('ComposingSettings')).toMatch(/<View pointerEvents=\{replyIdentityMatchLocked \? 'none' : 'auto'\}[^>]*>\s*\{segmented\(REPLY_IDENTITY_MATCHES/);
});
}); });