mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 00:07:01 +00:00
Merge branch 'cu-policy2' into catch-up-2026-11
This commit is contained in:
@@ -12,10 +12,12 @@ import {
|
||||
hasFaviconFailed,
|
||||
markFaviconFailed,
|
||||
} from '../lib/avatar-utils';
|
||||
import { bimiLogoUrl, isNoLogoImage, markBrandLogoFailed, showBrandLogo } from '../lib/avatar-bimi';
|
||||
import type { ContactPhotoRef } from '../lib/contact-utils';
|
||||
import { useContactPhotoSource } from '../lib/use-contact-photo';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import { useContactsStore } from '../stores/contacts-store';
|
||||
import { useAccountStore } from '../stores/account-store';
|
||||
import { useColors } from '../theme/colors';
|
||||
import { ShieldAlert } from './icons';
|
||||
|
||||
@@ -43,12 +45,19 @@ interface SenderAvatarProps {
|
||||
* or photo (webmail #1439).
|
||||
*/
|
||||
senderWarning?: string | null;
|
||||
/**
|
||||
* The From domain of a message that passed DMARC for it (see
|
||||
* brandIndicatorDomain in lib/avatar-bimi): its BIMI logo, from the
|
||||
* account's webmail, comes before the favicon, under the same switch.
|
||||
*/
|
||||
brandDomain?: string | null;
|
||||
}
|
||||
|
||||
// Mirrors the webmail `Avatar` priority for the subset available on native:
|
||||
// sender warning → address-book photo → company favicon (if sender domain is
|
||||
// sender warning → address-book photo → BIMI logo (from the account's
|
||||
// webmail, `senderFavicons` on) → company favicon (if sender domain is
|
||||
// non-personal and `senderFavicons` is on) → initials over HSL-hashed color.
|
||||
export default function SenderAvatar({ name, email, size = 40, style, disableImages, photo, senderWarning }: SenderAvatarProps) {
|
||||
export default function SenderAvatar({ name, email, size = 40, style, disableImages, photo, senderWarning, brandDomain }: SenderAvatarProps) {
|
||||
const c = useColors();
|
||||
const warned = !!senderWarning;
|
||||
const senderFavicons = useSettingsStore((s) => s.senderFavicons) && !disableImages && !warned;
|
||||
@@ -63,9 +72,13 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma
|
||||
const { source: photoSource, onLoad: onPhotoLoad, onError: onPhotoError } =
|
||||
useContactPhotoSource(disableImages || warned ? null : contactPhoto);
|
||||
const [imgError, setImgError] = React.useState(false);
|
||||
// BIMI logos come from the webmail the active account signed in through.
|
||||
const webmailUrl = useAccountStore((s) => s.accounts.find((a) => a.id === s.activeAccountId)?.webmailUrl ?? null);
|
||||
const [failedBrand, setFailedBrand] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
setImgError(false);
|
||||
setFailedBrand(null);
|
||||
}, [email]);
|
||||
|
||||
const primaryName = (name ?? '').split(',')[0].trim();
|
||||
@@ -74,9 +87,13 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma
|
||||
|
||||
const faviconDomain = getFaviconDomain(email ?? undefined);
|
||||
const domainFailed = faviconDomain ? hasFaviconFailed(faviconDomain) : false;
|
||||
const brandUrl = brandDomain ? bimiLogoUrl(webmailUrl, brandDomain) : null;
|
||||
const showBrand = !!brandUrl && brandUrl !== failedBrand
|
||||
&& showBrandLogo({ senderFavicons, email, brandDomain, webmailUrl });
|
||||
const content = avatarContent({
|
||||
senderWarning,
|
||||
hasPhoto: !disableImages && !!photoSource,
|
||||
showBrand,
|
||||
showFavicon: senderFavicons && !!faviconDomain && !imgError && !domainFailed,
|
||||
});
|
||||
|
||||
@@ -84,6 +101,12 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma
|
||||
setImgError(true);
|
||||
if (faviconDomain) markFaviconFailed(faviconDomain);
|
||||
}, [faviconDomain]);
|
||||
// No logo (or it didn't load): the favicon or initials take over.
|
||||
const handleBrandFailed = React.useCallback(() => {
|
||||
if (!brandUrl) return;
|
||||
markBrandLogoFailed(brandUrl);
|
||||
setFailedBrand(brandUrl);
|
||||
}, [brandUrl]);
|
||||
|
||||
const containerStyle = [
|
||||
styles.container,
|
||||
@@ -93,7 +116,7 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma
|
||||
borderRadius: size / 2,
|
||||
backgroundColor: content === 'warning'
|
||||
? c.errorBg
|
||||
: content === 'photo' ? 'transparent' : content === 'favicon' ? '#ffffff' : bgColor,
|
||||
: content === 'photo' ? 'transparent' : content === 'favicon' || content === 'brand' ? '#ffffff' : bgColor,
|
||||
},
|
||||
style,
|
||||
];
|
||||
@@ -127,6 +150,22 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma
|
||||
);
|
||||
}
|
||||
|
||||
if (content === 'brand' && brandUrl) {
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<Image
|
||||
source={{ uri: brandUrl }}
|
||||
style={{ width: size, height: size, borderRadius: size / 2 }}
|
||||
resizeMode="cover"
|
||||
// The webmail answers a domain without a logo with a 1x1 PNG.
|
||||
onLoad={(e) => { if (isNoLogoImage(e.nativeEvent?.source)) handleBrandFailed(); }}
|
||||
onError={handleBrandFailed}
|
||||
testID="sender-brand-avatar"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (content === 'favicon' && faviconDomain) {
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-s
|
||||
import Button from '../Button';
|
||||
import { spacing } from '../../theme/tokens';
|
||||
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 { uiFontsBundled } from '../../theme/reading-fonts';
|
||||
import { toast } from '../../stores/toast-store';
|
||||
@@ -81,6 +83,16 @@ export function AccessibilitySettings() {
|
||||
const animationsEnabled = useSettingsStore((s) => s.animationsEnabled);
|
||||
const toastDuration = useSettingsStore((s) => s.toastDuration);
|
||||
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 confirmArchive = useSettingsStore((s) => s.confirmArchive);
|
||||
|
||||
@@ -214,23 +226,27 @@ export function AccessibilitySettings() {
|
||||
<SettingItem
|
||||
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.')}
|
||||
locked={toastDurationLocked}
|
||||
>
|
||||
<Select
|
||||
value={String(toastDuration)}
|
||||
onChange={(v) => update('toastDuration', Number(v))}
|
||||
options={TOAST_DURATION_CHOICES.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')}
|
||||
options={toastChoices.map((value) => ({ value: String(value), label: durationLabel(value) }))}
|
||||
/>
|
||||
</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
|
||||
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.')}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useSettingsStore, type ThemeMode, type Density } from '../../stores/set
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { setPendingSettingsTab } from '../../navigation/pending-settings-tab';
|
||||
import { densityPreviewConfig } from './density-preview';
|
||||
import { useSettingHidden, useSettingLocked } from '../../stores/policy-store';
|
||||
|
||||
type Theme = ThemeMode;
|
||||
|
||||
@@ -81,6 +82,10 @@ export function AppearanceSettings() {
|
||||
const theme = useSettingsStore((s) => s.theme);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const setTheme = useSettingsStore((s) => s.setTheme);
|
||||
// The organisation's theme policy can pin the colour mode (#1280).
|
||||
const themeLocked = useSettingLocked('theme');
|
||||
const densityLocked = useSettingLocked('density');
|
||||
const densityHidden = useSettingHidden('density');
|
||||
const density = useSettingsStore((s) => s.density);
|
||||
const setDensity = useSettingsStore((s) => s.setDensity);
|
||||
const showToolbarLabels = useSettingsStore((s) => s.showToolbarLabels);
|
||||
@@ -99,7 +104,7 @@ export function AppearanceSettings() {
|
||||
title={t('settings.appearance.title', "Appearance")}
|
||||
description={t('settings.appearance.description_mobile', 'Customize how the app looks')}
|
||||
>
|
||||
<SettingItem label={t('settings.appearance.theme.label', "Theme")} description={t('settings.appearance.theme.description', "Choose your preferred color scheme")}>
|
||||
<SettingItem label={t('settings.appearance.theme.label', "Theme")} description={t('settings.appearance.theme.description', "Choose your preferred color scheme")} locked={themeLocked}>
|
||||
<RadioGroup
|
||||
value={theme}
|
||||
onChange={(v) => setTheme(v as Theme)}
|
||||
@@ -119,21 +124,25 @@ export function AppearanceSettings() {
|
||||
link="page"
|
||||
/>
|
||||
|
||||
<View>
|
||||
<SettingItem label={t('settings.appearance.list_density.label', "Density")} description={t('settings.appearance.list_density.description', "Control spacing and padding across the UI")} noBorder />
|
||||
<RadioGroup
|
||||
value={density}
|
||||
onChange={(v) => setDensity(v as Density)}
|
||||
options={[
|
||||
{ value: 'extra-compact', label: t('settings.appearance.list_density.extra_compact', "Extra Compact") },
|
||||
{ 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>
|
||||
{!densityHidden && (
|
||||
<View>
|
||||
<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} />
|
||||
<View pointerEvents={densityLocked ? 'none' : 'auto'} style={densityLocked && styles.lockedControl}>
|
||||
<RadioGroup
|
||||
value={density}
|
||||
onChange={(v) => setDensity(v as Density)}
|
||||
options={[
|
||||
{ value: 'extra-compact', label: t('settings.appearance.list_density.extra_compact', "Extra Compact") },
|
||||
{ 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") },
|
||||
]}
|
||||
/>
|
||||
</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.")}>
|
||||
<ToggleSwitch checked={showToolbarLabels} onChange={setShowToolbarLabels} />
|
||||
@@ -191,6 +200,7 @@ function makeStyles(c: ThemePalette) {
|
||||
fontSize: 11,
|
||||
color: c.textMuted,
|
||||
},
|
||||
lockedControl: { opacity: 0.6 },
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: c.border,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type TimeFormat,
|
||||
} from '../../stores/settings-store';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { usePolicyFeature, useSettingLocked } from '../../stores/policy-store';
|
||||
import { AUTO_TIME_ZONE, getDeviceTimeZone, isValidTimeZone } from '../../lib/calendar-timezone';
|
||||
import { deviceSyncAvailable } from '../../device-sync/app/available';
|
||||
import { CALENDAR_AUTHORITY } from '../../device-sync/types';
|
||||
@@ -49,6 +50,10 @@ export function CalendarSettings() {
|
||||
const birthdayCal = useSettingsStore((s) => s.showBirthdayCalendar);
|
||||
const tasksEnabled = useSettingsStore((s) => s.enableCalendarTasks);
|
||||
const showTasksOnCal = useSettingsStore((s) => s.showTasksOnCalendar);
|
||||
// An organisation can turn tasks off: the option goes, as in the webmail
|
||||
// (calendar-settings.tsx), and the policy pins it off.
|
||||
const tasksAllowed = usePolicyFeature('calendarTasksEnabled');
|
||||
const tasksLocked = useSettingLocked('enableCalendarTasks');
|
||||
|
||||
// Android with the native module only (#34).
|
||||
const deviceSync = React.useMemo(() => deviceSyncAvailable(), []);
|
||||
@@ -185,17 +190,20 @@ export function CalendarSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t('calendar.settings.enable_tasks', 'Enable tasks')}
|
||||
description={t('calendar.settings.enable_tasks_desc', 'Show a tasks view in the calendar for managing to-dos')}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={tasksEnabled}
|
||||
onChange={(v) => update('enableCalendarTasks', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
{tasksAllowed && (
|
||||
<SettingItem
|
||||
label={t('calendar.settings.enable_tasks', 'Enable tasks')}
|
||||
description={t('calendar.settings.enable_tasks_desc', 'Show a tasks view in the calendar for managing to-dos')}
|
||||
locked={tasksLocked}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={tasksEnabled}
|
||||
onChange={(v) => update('enableCalendarTasks', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{tasksEnabled && (
|
||||
{tasksAllowed && tasksEnabled && (
|
||||
<SettingItem
|
||||
label={t('calendar.settings.show_tasks_on_calendar', 'Show tasks on calendar')}
|
||||
description={t(
|
||||
|
||||
@@ -7,6 +7,7 @@ import { spacing, radius, typography, type ThemePalette } from '../../theme/toke
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useSettingsStore, type ReplyIdentityMatch, type SignaturePosition } from '../../stores/settings-store';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { useSettingLocked } from '../../stores/policy-store';
|
||||
import { SUPPORTED_SUB_ADDRESS_DELIMITERS } from '../../lib/sub-addressing';
|
||||
import { RADIO_GROUP_A11Y, radioOptionA11y } from '../radio-a11y';
|
||||
|
||||
@@ -24,6 +25,10 @@ export function ComposingSettings() {
|
||||
const setAttachmentReminderKeywords = useSettingsStore((s) => s.setAttachmentReminderKeywords);
|
||||
const sendDelaySeconds = useSettingsStore((s) => s.sendDelaySeconds);
|
||||
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 signatureSeparatorEnabled = useSettingsStore((s) => s.signatureSeparatorEnabled);
|
||||
const requestReadReceiptDefault = useSettingsStore((s) => s.requestReadReceiptDefault);
|
||||
@@ -117,6 +122,7 @@ export function ComposingSettings() {
|
||||
<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', "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} />
|
||||
</SettingItem>
|
||||
@@ -127,7 +133,10 @@ export function ComposingSettings() {
|
||||
<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.')}
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -137,6 +146,7 @@ export function ComposingSettings() {
|
||||
// the webmail's description (sending only) leaves out; same text as
|
||||
// under Reading.
|
||||
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)} />
|
||||
</SettingItem>
|
||||
@@ -271,6 +281,7 @@ function makeStyles(c: ThemePalette) {
|
||||
subBlock: { paddingVertical: spacing.md, gap: spacing.sm },
|
||||
subLabel: { ...typography.bodyMedium, color: c.text },
|
||||
subDescription: { ...typography.caption, color: c.mutedForeground },
|
||||
lockedControl: { opacity: 0.6 },
|
||||
segmentRow: { flexDirection: 'row', gap: 6, marginTop: 4 },
|
||||
segment: {
|
||||
flex: 1,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useHasContacts } from '../../lib/capabilities';
|
||||
import { isTrustedSendersSyncOn } from '../../lib/trusted-senders';
|
||||
import { removeTrustedSenderWithUndo } from '../../lib/settings-undo';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { usePolicyFeature, useSettingHidden, useSettingLocked } from '../../stores/policy-store';
|
||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||
import { useSheetBottomInset } from '../../lib/sheet-bottom-inset';
|
||||
import { ToastHost } from '../ToastHost';
|
||||
@@ -31,6 +32,12 @@ export function ContentSendersSettings() {
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const externalContentPolicy = useSettingsStore((s) => s.externalContentPolicy);
|
||||
const setExternalContentPolicy = useSettingsStore((s) => s.setExternalContentPolicy);
|
||||
// The organisation can take the option away or pin it (webmail
|
||||
// reading-settings.tsx).
|
||||
const externalContentAllowed = usePolicyFeature('externalContentEnabled');
|
||||
const externalContentHidden = useSettingHidden('externalContentPolicy');
|
||||
const externalContentLocked = useSettingLocked('externalContentPolicy');
|
||||
const showExternalContent = externalContentAllowed && !externalContentHidden;
|
||||
const emailAlwaysLightMode = useSettingsStore((s) => s.emailAlwaysLightMode);
|
||||
const setEmailAlwaysLightMode = useSettingsStore((s) => s.setEmailAlwaysLightMode);
|
||||
const trustedSenders = useSettingsStore((s) => s.trustedSenders);
|
||||
@@ -134,20 +141,23 @@ export function ContentSendersSettings() {
|
||||
title={t('settings.tabs.content_senders', 'Content & Senders')}
|
||||
description={t('settings.email_behavior.content_senders_description_mobile', 'Control how external resources and tracking pixels are handled.')}
|
||||
>
|
||||
<SettingItem
|
||||
label={t('settings.email_behavior.external_content.label', 'External Content')}
|
||||
description={t('settings.email_behavior.external_content.description', 'How to handle images and external content')}
|
||||
>
|
||||
<RadioGroup
|
||||
value={externalContentPolicy}
|
||||
onChange={(v) => setExternalContentPolicy(v as ExternalContentPolicy)}
|
||||
options={[
|
||||
{ value: 'ask', label: t('settings.email_behavior.external_content.ask_mobile', 'Ask') },
|
||||
{ value: 'block', label: t('settings.email_behavior.external_content.block_mobile', 'Block') },
|
||||
{ value: 'allow', label: t('settings.email_behavior.external_content.allow_mobile', 'Allow') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
{showExternalContent && (
|
||||
<SettingItem
|
||||
label={t('settings.email_behavior.external_content.label', 'External Content')}
|
||||
description={t('settings.email_behavior.external_content.description', 'How to handle images and external content')}
|
||||
locked={externalContentLocked}
|
||||
>
|
||||
<RadioGroup
|
||||
value={externalContentPolicy}
|
||||
onChange={(v) => setExternalContentPolicy(v as ExternalContentPolicy)}
|
||||
options={[
|
||||
{ value: 'ask', label: t('settings.email_behavior.external_content.ask_mobile', 'Ask') },
|
||||
{ value: 'block', label: t('settings.email_behavior.external_content.block_mobile', 'Block') },
|
||||
{ value: 'allow', label: t('settings.email_behavior.external_content.allow_mobile', 'Allow') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
<SettingItem
|
||||
label={t('settings.email_behavior.always_light_mode.label', 'Always Show Emails in Light Mode')}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useColors } from '../../theme/colors';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import {
|
||||
useSettingsStore,
|
||||
normalizeBottomQuickActions,
|
||||
REPLY_QUICK_ACTIONS,
|
||||
ALL_QUICK_ACTIONS,
|
||||
type SwipeAction,
|
||||
@@ -16,7 +15,8 @@ import {
|
||||
} from '../../stores/settings-store';
|
||||
import { useEmailStore } from '../../stores/email-store';
|
||||
import { useKeywordsStore } from '../../stores/keywords-store';
|
||||
import { useSettingLocked } from '../../stores/policy-store';
|
||||
import { usePolicyFeature, useSettingLocked } from '../../stores/policy-store';
|
||||
import { bottomQuickActionsFor, swipeActionShown, withoutArchive } from '../../lib/swipe-actions';
|
||||
import {
|
||||
ORDER_PRESETS, detectPreset, presetLevels, sanitizeSortLevels,
|
||||
type OrderPreset, type MessageListOrderScope,
|
||||
@@ -74,12 +74,16 @@ export function LayoutSettings() {
|
||||
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]);
|
||||
// With the organisation's Archive action off, Archive is no swipe or quick
|
||||
// action, and a stored one shows as what it does (webmail
|
||||
// message-list-settings.tsx).
|
||||
const archiveEnabled = usePolicyFeature('archiveActionEnabled');
|
||||
const SWIPE_OPTIONS = React.useMemo(() => withoutArchive(swipeOptions(t), archiveEnabled), [t, archiveEnabled]);
|
||||
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],
|
||||
() => withoutArchive(ALL_QUICK_ACTIONS.map((value) => ({ value, label: QUICK_ACTION_LABELS[value] })), archiveEnabled),
|
||||
[QUICK_ACTION_LABELS, archiveEnabled],
|
||||
);
|
||||
|
||||
useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]);
|
||||
@@ -125,7 +129,7 @@ export function LayoutSettings() {
|
||||
{ value: 'all', label: t('settings.message_list_order.scope_all', 'All folders') },
|
||||
];
|
||||
|
||||
const bottomActions = normalizeBottomQuickActions(bottomQuickActionsRaw);
|
||||
const bottomActions = bottomQuickActionsFor(bottomQuickActionsRaw, archiveEnabled);
|
||||
// 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) => {
|
||||
@@ -242,7 +246,7 @@ export function LayoutSettings() {
|
||||
{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}
|
||||
value={swipeActionShown(swipeRightAction, archiveEnabled)}
|
||||
onChange={(v) => setSwipeRightAction(v as SwipeAction)}
|
||||
options={SWIPE_OPTIONS}
|
||||
/>
|
||||
@@ -257,7 +261,7 @@ export function LayoutSettings() {
|
||||
{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}
|
||||
value={swipeActionShown(swipeLeftAction, archiveEnabled)}
|
||||
onChange={(v) => setSwipeLeftAction(v as SwipeAction)}
|
||||
options={SWIPE_OPTIONS}
|
||||
/>
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useAuthStore } from '../../stores/auth-store';
|
||||
import { useEmailStore } from '../../stores/email-store';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
import { useSettingHidden, useSettingLocked } from '../../stores/policy-store';
|
||||
import {
|
||||
DEFAULT_RELAY_BASE_URL,
|
||||
disablePushForAccount,
|
||||
@@ -99,6 +100,13 @@ export function NotificationSettings() {
|
||||
// The Inbox-only filter needs the server's emailPush filter too (webmail #983).
|
||||
const inboxOnly = useSettingsStore((s) => s.pushNotifyInboxOnly);
|
||||
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 appIconBadge = useSettingsStore((s) => s.appIconUnreadBadge);
|
||||
|
||||
@@ -570,16 +578,20 @@ export function NotificationSettings() {
|
||||
title={t('settings.notifications.email.title', 'Email Notifications')}
|
||||
description={t('settings.notifications.email.description', 'Configure notifications for incoming emails')}
|
||||
>
|
||||
<SettingItem
|
||||
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)} />
|
||||
</SettingItem>
|
||||
{inboxOnlyAvailable && (
|
||||
{!emailEnabledHidden && (
|
||||
<SettingItem
|
||||
label={t('settings.notifications.email.enabled', 'Email notifications')}
|
||||
description={t('settings.notifications.email.enabled_desc', 'Show notifications when new emails arrive')}
|
||||
locked={emailEnabledLocked}
|
||||
>
|
||||
<ToggleSwitch checked={emailEnabled} onChange={(v) => update('emailNotificationsEnabled', v)} />
|
||||
</SettingItem>
|
||||
)}
|
||||
{inboxOnlyAvailable && !inboxOnlyHidden && (
|
||||
<SettingItem
|
||||
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')}
|
||||
locked={inboxOnlyLocked}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={inboxOnly}
|
||||
@@ -629,12 +641,15 @@ export function NotificationSettings() {
|
||||
title={t('settings.notifications.calendar.title', 'Calendar Notifications')}
|
||||
description={t('settings.notifications.calendar.description', 'Configure notifications for calendar events')}
|
||||
>
|
||||
<SettingItem
|
||||
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)} />
|
||||
</SettingItem>
|
||||
{!calEnabledHidden && (
|
||||
<SettingItem
|
||||
label={t('settings.notifications.calendar.enabled', 'Event notifications')}
|
||||
description={t('settings.notifications.calendar.enabled_desc', 'Show alerts for upcoming calendar events')}
|
||||
locked={calEnabledLocked}
|
||||
>
|
||||
<ToggleSwitch checked={calEnabled} onChange={(v) => update('calendarNotificationsEnabled', v)} />
|
||||
</SettingItem>
|
||||
)}
|
||||
<SettingItem
|
||||
label={t('settings.notifications.calendar.invitation_parsing', 'Parse email invitations')}
|
||||
description={t(
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useEmailStore } from '../../stores/email-store';
|
||||
import { archiveEmails, queryEmails, getEmails } from '../../api/email';
|
||||
import { ownMailboxes } from '../../lib/mailbox-tree';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { usePolicyFeature, useSettingHidden, useSettingLocked } from '../../stores/policy-store';
|
||||
|
||||
export function ReadingSettings() {
|
||||
const c = useColors();
|
||||
@@ -28,6 +29,9 @@ export function ReadingSettings() {
|
||||
const update = useSettingsStore((s) => s.updateSetting);
|
||||
|
||||
const archiveMode = useSettingsStore((s) => s.archiveMode);
|
||||
// Where archived mail goes means nothing with the organisation's Archive
|
||||
// action off (webmail message-list-settings.tsx).
|
||||
const archiveEnabled = usePolicyFeature('archiveActionEnabled');
|
||||
const markAsReadDelay = useSettingsStore((s) => s.markAsReadDelay);
|
||||
const deleteAction = useSettingsStore((s) => s.deleteAction);
|
||||
const permanentlyDeleteJunk = useSettingsStore((s) => s.permanentlyDeleteJunk);
|
||||
@@ -47,6 +51,22 @@ export function ReadingSettings() {
|
||||
const plainTextFont = useSettingsStore((s) => s.plainTextFont);
|
||||
const messageSpacing = useSettingsStore((s) => s.messageSpacing);
|
||||
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 [reorganizing, setReorganizing] = useState(false);
|
||||
@@ -114,73 +134,81 @@ export function ReadingSettings() {
|
||||
|
||||
return (
|
||||
<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")}>
|
||||
<Select
|
||||
value={String(markAsReadDelay)}
|
||||
onChange={(v) => update('markAsReadDelay', Number(v))}
|
||||
options={[
|
||||
{ value: '0', label: t('settings.email_behavior.mark_read.instant', "Instantly") },
|
||||
{ value: '3000', label: t('settings.email_behavior.mark_read.delay_3s', "After 3 seconds") },
|
||||
{ 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>
|
||||
{!markAsReadHidden && (
|
||||
<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}>
|
||||
<Select
|
||||
value={String(markAsReadDelay)}
|
||||
onChange={(v) => update('markAsReadDelay', Number(v))}
|
||||
options={[
|
||||
{ value: '0', label: t('settings.email_behavior.mark_read.instant', "Instantly") },
|
||||
{ value: '3000', label: t('settings.email_behavior.mark_read.delay_3s', "After 3 seconds") },
|
||||
{ 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>
|
||||
)}
|
||||
|
||||
<View style={styles.group}>
|
||||
<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 />
|
||||
<Select
|
||||
value={deleteAction}
|
||||
onChange={(v) => update('deleteAction', v as DeleteAction)}
|
||||
options={[
|
||||
{ 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>
|
||||
{!deleteActionHidden && (
|
||||
<View style={styles.group}>
|
||||
<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} />
|
||||
<View pointerEvents={deleteActionLocked ? 'none' : 'auto'} style={deleteActionLocked && styles.lockedControl}>
|
||||
<Select
|
||||
value={deleteAction}
|
||||
onChange={(v) => update('deleteAction', v as DeleteAction)}
|
||||
options={[
|
||||
{ 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") },
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.divider} />
|
||||
</View>
|
||||
{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 style={styles.divider} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.group}>
|
||||
<SettingItem label={t('settings.email_behavior.archive_mode.label', "Archive in")} description={t('settings.email_behavior.archive_mode.description', "How to organize emails when archiving")} noBorder />
|
||||
<Select
|
||||
value={archiveMode}
|
||||
onChange={(v) => update('archiveMode', v as ArchiveMode)}
|
||||
options={[
|
||||
{ value: 'single', label: t('settings.email_behavior.archive_mode.single', "A single folder") },
|
||||
{ value: 'year', label: t('settings.email_behavior.archive_mode.year', "A folder per year") },
|
||||
{ value: 'month', label: t('settings.email_behavior.archive_mode.month', "A folder per month") },
|
||||
]}
|
||||
/>
|
||||
{archiveMode !== 'single' && (
|
||||
<>
|
||||
<Pressable
|
||||
style={[styles.inlineBtn, reorganizing && styles.inlineBtnDisabled]}
|
||||
onPress={reorganizing ? undefined : handleReorganizeArchive}
|
||||
disabled={reorganizing}
|
||||
>
|
||||
{reorganizing ? (
|
||||
<ActivityIndicator size="small" color={c.text} />
|
||||
) : (
|
||||
<FolderSync size={14} color={c.text} />
|
||||
{archiveEnabled && (
|
||||
<View style={styles.group}>
|
||||
<SettingItem label={t('settings.email_behavior.archive_mode.label', "Archive in")} description={t('settings.email_behavior.archive_mode.description', "How to organize emails when archiving")} noBorder />
|
||||
<Select
|
||||
value={archiveMode}
|
||||
onChange={(v) => update('archiveMode', v as ArchiveMode)}
|
||||
options={[
|
||||
{ value: 'single', label: t('settings.email_behavior.archive_mode.single', "A single folder") },
|
||||
{ value: 'year', label: t('settings.email_behavior.archive_mode.year', "A folder per year") },
|
||||
{ value: 'month', label: t('settings.email_behavior.archive_mode.month', "A folder per month") },
|
||||
]}
|
||||
/>
|
||||
{archiveMode !== 'single' && (
|
||||
<>
|
||||
<Pressable
|
||||
style={[styles.inlineBtn, reorganizing && styles.inlineBtnDisabled]}
|
||||
onPress={reorganizing ? undefined : handleReorganizeArchive}
|
||||
disabled={reorganizing}
|
||||
>
|
||||
{reorganizing ? (
|
||||
<ActivityIndicator size="small" color={c.text} />
|
||||
) : (
|
||||
<FolderSync size={14} color={c.text} />
|
||||
)}
|
||||
<Text style={styles.inlineBtnText}>
|
||||
{reorganizing ? t('settings.email_behavior.archive_mode.reorganizing', "Reorganizing…") : t('settings.email_behavior.archive_mode.reorganize', "Reorganize existing archive")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
{reorganizeResult && (
|
||||
<Text style={styles.reorganizeResult}>{reorganizeResult}</Text>
|
||||
)}
|
||||
<Text style={styles.inlineBtnText}>
|
||||
{reorganizing ? t('settings.email_behavior.archive_mode.reorganizing', "Reorganizing…") : t('settings.email_behavior.archive_mode.reorganize', "Reorganize existing archive")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
{reorganizeResult && (
|
||||
<Text style={styles.reorganizeResult}>{reorganizeResult}</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<View style={styles.divider} />
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
<View style={styles.divider} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
<SettingItem label={t('settings.email_behavior.permanently_delete_junk.label', "Permanently Delete Junk")} description={t('settings.email_behavior.permanently_delete_junk.description', "Permanently delete emails from the Junk/Spam folder instead of moving them to Trash")}>
|
||||
<ToggleSwitch checked={permanentlyDeleteJunk} onChange={(v) => update('permanentlyDeleteJunk', v)} />
|
||||
@@ -190,9 +218,11 @@ export function ReadingSettings() {
|
||||
<ToggleSwitch checked={clearSearchOnFolderChange} onChange={(v) => update('clearSearchOnFolderChange', v)} />
|
||||
</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")}>
|
||||
<ToggleSwitch checked={showPreview} onChange={(v) => update('showPreview', v)} />
|
||||
</SettingItem>
|
||||
{!showPreviewHidden && (
|
||||
<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}>
|
||||
<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")}>
|
||||
<ToggleSwitch checked={showVerificationCodes} onChange={(v) => update('showVerificationCodes', v)} />
|
||||
@@ -202,15 +232,17 @@ export function ReadingSettings() {
|
||||
<ToggleSwitch checked={disableThreading} onChange={(v) => update('disableThreading', v)} />
|
||||
</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.")}>
|
||||
<ToggleSwitch checked={includeGroupInUnified} onChange={(v) => update('includeGroupInUnified', v)} />
|
||||
</SettingItem>
|
||||
{!includeGroupHidden && (
|
||||
<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}>
|
||||
<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)} />
|
||||
</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)} />
|
||||
</SettingItem>
|
||||
|
||||
@@ -314,17 +346,19 @@ export function ReadingSettings() {
|
||||
/>
|
||||
</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")}>
|
||||
<Select
|
||||
value={messageSpacing}
|
||||
onChange={(v) => update('messageSpacing', v as MessageSpacing)}
|
||||
options={[
|
||||
{ value: 'auto', label: t('settings.email_behavior.message_spacing.auto', "Auto (recommended)") },
|
||||
{ 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>
|
||||
{!messageSpacingHidden && (
|
||||
<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}>
|
||||
<Select
|
||||
value={messageSpacing}
|
||||
onChange={(v) => update('messageSpacing', v as MessageSpacing)}
|
||||
options={[
|
||||
{ value: 'auto', label: t('settings.email_behavior.message_spacing.auto', "Auto (recommended)") },
|
||||
{ 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 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
|
||||
@@ -338,18 +372,20 @@ export function ReadingSettings() {
|
||||
/>
|
||||
</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")}>
|
||||
<Select
|
||||
value={String(emailsPerPage)}
|
||||
onChange={(v) => update('emailsPerPage', Number(v))}
|
||||
options={[
|
||||
{ value: '10', label: '10' },
|
||||
{ value: '25', label: '25' },
|
||||
{ value: '50', label: '50' },
|
||||
{ value: '100', label: '100' },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
{!emailsPerPageHidden && (
|
||||
<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}>
|
||||
<Select
|
||||
value={String(emailsPerPage)}
|
||||
onChange={(v) => update('emailsPerPage', Number(v))}
|
||||
options={[
|
||||
{ value: '10', label: '10' },
|
||||
{ value: '25', label: '25' },
|
||||
{ value: '50', label: '50' },
|
||||
{ value: '100', label: '100' },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
</SettingsSection>
|
||||
);
|
||||
@@ -358,6 +394,7 @@ export function ReadingSettings() {
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
group: {},
|
||||
lockedControl: { opacity: 0.6 },
|
||||
warning: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { spacing, radius, typography, type ThemePalette } from '../../theme/toke
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { usePolicyFeature } from '../../stores/policy-store';
|
||||
import { useOfflineCacheStore } from '../../stores/offline-cache-store';
|
||||
import { useOutboxStore } from '../../stores/outbox-store';
|
||||
import { runOfflineSync, formatBytes } from '../../lib/offline-sync';
|
||||
@@ -29,6 +30,9 @@ export function StorageSettings({ onOpenTroubleshooting }: StorageSettingsProps)
|
||||
const resetToDefaults = useSettingsStore((s) => s.resetToDefaults);
|
||||
const exportSettings = useSettingsStore((s) => s.exportSettings);
|
||||
const importSettings = useSettingsStore((s) => s.importSettings);
|
||||
// The organisation can turn settings export and import off (webmail
|
||||
// about-data-settings.tsx).
|
||||
const exportAllowed = usePolicyFeature('settingsExportEnabled');
|
||||
|
||||
const [confirmReset, setConfirmReset] = useState(false);
|
||||
|
||||
@@ -229,19 +233,21 @@ export function StorageSettings({ onOpenTroubleshooting }: StorageSettingsProps)
|
||||
title={t('settings.backup.title', 'Settings backup')}
|
||||
description={t('settings.backup.description', 'Move your settings to another phone or the webmail.')}
|
||||
>
|
||||
<SettingItem
|
||||
label={t('settings.backup.export_import', 'Export or import')}
|
||||
description={t('settings.backup.export_import_desc', 'A JSON file, same format as the webmail')}
|
||||
>
|
||||
<View style={styles.buttonPair}>
|
||||
<Button variant="outline" size="sm" onPress={() => void handleExport()}>
|
||||
{t('settings.advanced.export_settings.button', 'Export')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onPress={() => void handleImport()}>
|
||||
{t('settings.advanced.import_settings.button', 'Import')}
|
||||
</Button>
|
||||
</View>
|
||||
</SettingItem>
|
||||
{exportAllowed && (
|
||||
<SettingItem
|
||||
label={t('settings.backup.export_import', 'Export or import')}
|
||||
description={t('settings.backup.export_import_desc', 'A JSON file, same format as the webmail')}
|
||||
>
|
||||
<View style={styles.buttonPair}>
|
||||
<Button variant="outline" size="sm" onPress={() => void handleExport()}>
|
||||
{t('settings.advanced.export_settings.button', 'Export')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onPress={() => void handleImport()}>
|
||||
{t('settings.advanced.import_settings.button', 'Import')}
|
||||
</Button>
|
||||
</View>
|
||||
</SettingItem>
|
||||
)}
|
||||
<SettingItem
|
||||
label={t('settings.advanced.reset_settings.label', 'Reset Settings')}
|
||||
description={t('settings.backup.reset_desc', 'Back to the defaults; mail stays')}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { resolvePalette, useColors, useResolvedTheme } from '../../theme/colors'
|
||||
import { BUILTIN_THEMES } from '../../theme/builtin-themes';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { usePolicyStore, useSettingLocked } from '../../stores/policy-store';
|
||||
import { visibleThemeCards } from './theme-cards';
|
||||
|
||||
interface ThemeCard {
|
||||
id: string | null;
|
||||
@@ -28,19 +30,23 @@ export function ThemesSettings() {
|
||||
const hydrate = useSettingsStore((s) => s.hydrate);
|
||||
const active = useSettingsStore((s) => s.activeThemeId);
|
||||
const update = useSettingsStore((s) => s.updateSetting);
|
||||
// The organisation can turn built-in themes off, and pin one (or the
|
||||
// default, with themes turned off) for everyone (webmail themes-settings.tsx).
|
||||
const policy = usePolicyStore((s) => s.effective);
|
||||
const themeLocked = useSettingLocked('activeThemeId');
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) void hydrate();
|
||||
}, [hydrated, hydrate]);
|
||||
|
||||
const cards: ThemeCard[] = [
|
||||
const cards = React.useMemo<ThemeCard[]>(() => visibleThemeCards([
|
||||
{
|
||||
id: null,
|
||||
name: t('settings.themes.default_name', 'Bulwark'),
|
||||
description: t('settings.themes.default_description', 'The default light and dark palettes.'),
|
||||
},
|
||||
...BUILTIN_THEMES.map((theme) => ({ id: theme.id, name: theme.name, description: theme.description })),
|
||||
];
|
||||
], policy), [t, policy]);
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
@@ -50,6 +56,11 @@ export function ThemesSettings() {
|
||||
'Pick a colour theme. Themes follow the light/dark setting from Appearance; custom theme packages are managed in the webmail.',
|
||||
)}
|
||||
>
|
||||
{themeLocked && (
|
||||
<View style={styles.lockedNote}>
|
||||
<Text style={styles.lockedNoteText}>{t('settings.themes.locked', 'Theme selection is locked by an administrator.')}</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.grid}>
|
||||
{cards.map((card) => {
|
||||
const isActive = (active ?? null) === card.id;
|
||||
@@ -58,10 +69,11 @@ export function ThemesSettings() {
|
||||
<Pressable
|
||||
key={card.id ?? 'default'}
|
||||
onPress={() => update('activeThemeId', card.id)}
|
||||
disabled={themeLocked}
|
||||
accessibilityRole="radio"
|
||||
accessibilityState={{ selected: isActive, checked: isActive }}
|
||||
accessibilityState={{ selected: isActive, checked: isActive, disabled: themeLocked }}
|
||||
accessibilityLabel={card.name}
|
||||
style={[styles.card, isActive && styles.cardActive]}
|
||||
style={[styles.card, isActive && styles.cardActive, themeLocked && !isActive && styles.cardDisabled]}
|
||||
>
|
||||
<View style={[styles.preview, { backgroundColor: preview.background, borderColor: preview.border }]}>
|
||||
<View style={[styles.previewRail, { backgroundColor: preview.surface, borderRightColor: preview.border }]} />
|
||||
@@ -112,6 +124,17 @@ function makeStyles(c: ThemePalette) {
|
||||
borderColor: c.primary,
|
||||
backgroundColor: c.primaryBg,
|
||||
},
|
||||
cardDisabled: { opacity: 0.6 },
|
||||
lockedNote: {
|
||||
marginBottom: spacing.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: c.warning,
|
||||
backgroundColor: c.warningBg,
|
||||
},
|
||||
lockedNoteText: { ...typography.caption, color: c.text },
|
||||
preview: {
|
||||
width: '100%',
|
||||
aspectRatio: 16 / 10,
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
// Where the organisation policy reaches the settings pages. No React Native
|
||||
// renderer in the test env, so this checks the wiring (as
|
||||
// src/components/__tests__/org-policy-wiring.test.ts does); what the policy
|
||||
// does to the settings is tested in org-policy.test.ts and policy-store.test.ts.
|
||||
|
||||
const read = (rel: string) => fs.readFileSync(path.resolve(__dirname, '..', '..', '..', '..', rel), 'utf8');
|
||||
const settings = (name: string) => read(`src/components/settings/${name}.tsx`);
|
||||
|
||||
describe('organisation policy on the settings pages', () => {
|
||||
it('shows the colour mode as set by the organisation when its theme policy locks it', () => {
|
||||
const page = settings('AppearanceSettings');
|
||||
expect(page).toContain("const themeLocked = useSettingLocked('theme');");
|
||||
expect(page).toMatch(/label=\{t\('settings\.appearance\.theme\.label', "Theme"\)\}[^>]*locked=\{themeLocked\}>/);
|
||||
});
|
||||
|
||||
it('hides the themes the policy turns off and keeps the grid still while the theme is locked', () => {
|
||||
const page = settings('ThemesSettings');
|
||||
expect(page).toContain('const policy = usePolicyStore((s) => s.effective);');
|
||||
expect(page).toMatch(/visibleThemeCards\(\[[\s\S]*\], policy\), \[t, policy\]\)/);
|
||||
expect(page).toContain('{cards.map((card) => {');
|
||||
expect(page).toContain("const themeLocked = useSettingLocked('activeThemeId');");
|
||||
expect(page).toContain('disabled={themeLocked}');
|
||||
expect(page).toContain("{themeLocked && (");
|
||||
});
|
||||
|
||||
it('hides the tasks option when the organisation turns tasks off, and marks it locked', () => {
|
||||
const page = settings('CalendarSettings');
|
||||
expect(page).toContain("const tasksAllowed = usePolicyFeature('calendarTasksEnabled');");
|
||||
expect(page).toContain("const tasksLocked = useSettingLocked('enableCalendarTasks');");
|
||||
expect(page).toMatch(/\{tasksAllowed && \(\s*<SettingItem\s+label=\{t\('calendar\.settings\.enable_tasks'[\s\S]*?locked=\{tasksLocked\}/);
|
||||
expect(page).toContain('{tasksAllowed && tasksEnabled && (');
|
||||
});
|
||||
|
||||
it('hides the external content option when the organisation turns it off or hides it', () => {
|
||||
const page = settings('ContentSendersSettings');
|
||||
expect(page).toContain("const externalContentAllowed = usePolicyFeature('externalContentEnabled');");
|
||||
expect(page).toContain("const externalContentHidden = useSettingHidden('externalContentPolicy');");
|
||||
expect(page).toContain('const showExternalContent = externalContentAllowed && !externalContentHidden;');
|
||||
expect(page).toMatch(/\{showExternalContent && \(\s*<SettingItem\s+label=\{t\('settings\.email_behavior\.external_content\.label'[\s\S]*?locked=\{externalContentLocked\}/);
|
||||
expect(page).toContain("const externalContentLocked = useSettingLocked('externalContentPolicy');");
|
||||
});
|
||||
|
||||
it('offers settings export and import only while the organisation allows them', () => {
|
||||
const page = settings('StorageSettings');
|
||||
expect(page).toContain("const exportAllowed = usePolicyFeature('settingsExportEnabled');");
|
||||
expect(page).toMatch(/\{exportAllowed && \(\s*<SettingItem\s+label=\{t\('settings\.backup\.export_import'[\s\S]*?handleExport[\s\S]*?handleImport[\s\S]*?<\/SettingItem>\s*\)\}/);
|
||||
});
|
||||
|
||||
it('takes Archive out of the swipe and quick actions when the organisation turns it off', () => {
|
||||
const layout = settings('LayoutSettings');
|
||||
expect(layout).toContain("const archiveEnabled = usePolicyFeature('archiveActionEnabled');");
|
||||
expect(layout).toContain('withoutArchive(swipeOptions(t), archiveEnabled)');
|
||||
expect(layout).toContain('value={swipeActionShown(swipeRightAction, archiveEnabled)}');
|
||||
expect(layout).toContain('value={swipeActionShown(swipeLeftAction, archiveEnabled)}');
|
||||
expect(layout).toMatch(/withoutArchive\(ALL_QUICK_ACTIONS\.map\([\s\S]*?\), archiveEnabled\)/);
|
||||
expect(layout).toContain('const bottomActions = bottomQuickActionsFor(bottomQuickActionsRaw, archiveEnabled);');
|
||||
|
||||
const reading = settings('ReadingSettings');
|
||||
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'/);
|
||||
});
|
||||
|
||||
// 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/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { policyHiddenPages } from '../policy-pages';
|
||||
import { sanitizePolicy } from '../../../lib/org-policy';
|
||||
|
||||
const screen = fs.readFileSync(path.resolve(__dirname, '..', '..', '..', 'screens', 'SettingsScreen.tsx'), 'utf8');
|
||||
|
||||
describe('policyHiddenPages', () => {
|
||||
it('leaves every page without a policy or with the default gates', () => {
|
||||
expect(policyHiddenPages(null)).toEqual([]);
|
||||
expect(policyHiddenPages(sanitizePolicy({}))).toEqual([]);
|
||||
});
|
||||
|
||||
it('takes away the pages of the gates the policy turns off', () => {
|
||||
const policy = sanitizePolicy({
|
||||
features: {
|
||||
templatesEnabled: false,
|
||||
customKeywordsEnabled: false,
|
||||
sidebarAppsEnabled: false,
|
||||
themesEnabled: false,
|
||||
calendarEnabled: false,
|
||||
contactsEnabled: false,
|
||||
filesEnabled: false,
|
||||
},
|
||||
});
|
||||
expect(policyHiddenPages(policy).sort()).toEqual(
|
||||
['calendar', 'contacts', 'files', 'keywords', 'sidebar_apps', 'templates', 'themes'],
|
||||
);
|
||||
});
|
||||
|
||||
it('takes away Delegates and Recover deleted items when the policy hides them', () => {
|
||||
const policy = sanitizePolicy({
|
||||
restrictions: { delegation: { hidden: true }, deletedItemsRecovery: { hidden: true } },
|
||||
});
|
||||
expect(policyHiddenPages(policy).sort()).toEqual(['recovery', 'sharing']);
|
||||
const locked = sanitizePolicy({ restrictions: { delegation: { locked: true } } });
|
||||
expect(policyHiddenPages(locked)).toEqual([]);
|
||||
});
|
||||
|
||||
it('names pages the Settings list has', () => {
|
||||
const policy = sanitizePolicy({
|
||||
features: Object.fromEntries(['templatesEnabled', 'customKeywordsEnabled', 'sidebarAppsEnabled', 'themesEnabled', 'calendarEnabled', 'contactsEnabled', 'filesEnabled'].map((g) => [g, false])),
|
||||
restrictions: { delegation: { hidden: true }, deletedItemsRecovery: { hidden: true } },
|
||||
});
|
||||
for (const page of policyHiddenPages(policy)) expect(screen).toContain(`{ id: '${page}',`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Settings list', () => {
|
||||
it('leaves out the pages the active policy takes away', () => {
|
||||
expect(screen).toContain('const policy = usePolicyStore((s) => s.effective);');
|
||||
expect(screen).toContain('for (const page of policyHiddenPages(policy)) set.add(page as Tab);');
|
||||
expect(screen).toContain('}, [recoveryAvailable, delegationAvailable, policy]);');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { visibleThemeCards } from '../theme-cards';
|
||||
import { sanitizePolicy } from '../../../lib/org-policy';
|
||||
|
||||
const CARDS = [
|
||||
{ id: null, name: 'Bulwark' },
|
||||
{ id: 'nord', name: 'Nord' },
|
||||
{ id: 'solarized', name: 'Solarized' },
|
||||
];
|
||||
|
||||
describe('visibleThemeCards', () => {
|
||||
it('offers every card without a policy', () => {
|
||||
expect(visibleThemeCards(CARDS, null).map((c) => c.id)).toEqual([null, 'nord', 'solarized']);
|
||||
});
|
||||
|
||||
it('leaves out the built-in themes the policy turns off', () => {
|
||||
const policy = sanitizePolicy({ themePolicy: { disabledBuiltinThemes: ['nord'] } });
|
||||
expect(visibleThemeCards(CARDS, policy).map((c) => c.id)).toEqual([null, 'solarized']);
|
||||
});
|
||||
|
||||
it('keeps the default card whatever the policy lists', () => {
|
||||
const policy = sanitizePolicy({ themePolicy: { disabledBuiltinThemes: ['nord', 'solarized', 'null', ''] } });
|
||||
expect(visibleThemeCards(CARDS, policy).map((c) => c.id)).toEqual([null]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { isFeatureEnabled, type FeatureGate, type OrgPolicy } from '../../lib/org-policy';
|
||||
|
||||
/**
|
||||
* Settings pages a feature gate takes away, as the webmail's settings-app.tsx
|
||||
* leaves their tabs out: Templates (templatesEnabled, in Composing there),
|
||||
* Keywords (customKeywordsEnabled: Organize turns into Folders), Sidebar apps
|
||||
* (sidebarAppsEnabled: the app has no plugins, which keep Add-ons there),
|
||||
* Themes (themesEnabled, appearance-settings.tsx), and Calendar, Contacts and
|
||||
* Files with their apps.
|
||||
*/
|
||||
const GATED_PAGES: ReadonlyArray<[FeatureGate, string]> = [
|
||||
['templatesEnabled', 'templates'],
|
||||
['customKeywordsEnabled', 'keywords'],
|
||||
['sidebarAppsEnabled', 'sidebar_apps'],
|
||||
['themesEnabled', 'themes'],
|
||||
['calendarEnabled', 'calendar'],
|
||||
['contactsEnabled', 'contacts'],
|
||||
['filesEnabled', 'files'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Pages a hidden restriction takes away: Delegates (`delegation`, webmail
|
||||
* settings-app supportsSharing) and Recover deleted items
|
||||
* (`deletedItemsRecovery`, webmail hooks/use-deleted-items-recovery.ts).
|
||||
*/
|
||||
const HIDDEN_PAGES: ReadonlyArray<[string, string]> = [
|
||||
['delegation', 'sharing'],
|
||||
['deletedItemsRecovery', 'recovery'],
|
||||
];
|
||||
|
||||
/** The settings pages the active account's policy leaves out, by page id. */
|
||||
export function policyHiddenPages(policy: OrgPolicy | null | undefined): string[] {
|
||||
if (!policy) return [];
|
||||
const out: string[] = [];
|
||||
for (const [gate, page] of GATED_PAGES) {
|
||||
if (!isFeatureEnabled(policy, gate)) out.push(page);
|
||||
}
|
||||
for (const [key, page] of HIDDEN_PAGES) {
|
||||
if (policy.restrictions[key]?.hidden === true) out.push(page);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { OrgPolicy } from '../../lib/org-policy';
|
||||
|
||||
/**
|
||||
* The theme cards the Themes page offers: every card but the built-in themes
|
||||
* the organisation's policy turns off (webmail themes-settings.tsx filters
|
||||
* them with isThemeDisabled). The default card (id null) always stays, so
|
||||
* there is something to pick.
|
||||
*/
|
||||
export function visibleThemeCards<T extends { id: string | null }>(
|
||||
cards: readonly T[],
|
||||
policy: OrgPolicy | null | undefined,
|
||||
): T[] {
|
||||
const disabled = policy?.themePolicy.disabledBuiltinThemes ?? [];
|
||||
if (disabled.length === 0) return [...cards];
|
||||
return cards.filter((card) => card.id === null || !disabled.includes(card.id));
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
bimiLogoUrl,
|
||||
brandIndicatorDomain,
|
||||
isNoLogoImage,
|
||||
markBrandLogoFailed,
|
||||
resetBrandLogosForTests,
|
||||
showBrandLogo,
|
||||
} from '../avatar-bimi';
|
||||
import { avatarContent } from '../avatar-utils';
|
||||
import { LIST_AUTH_RESULTS_PROPERTY } from '../email-headers';
|
||||
import type { Email } from '../../api/types';
|
||||
|
||||
// BIMI logos as the webmail shows them (components/ui/avatar.tsx,
|
||||
// lib/email-headers.ts brandIndicatorDomain, app/api/bimi).
|
||||
|
||||
const PASS = 'mx.example.org; spf=pass smtp.mailfrom=bank.example; dkim=pass header.d=bank.example; dmarc=pass header.from=bank.example';
|
||||
const FAIL = 'mx.example.org; spf=fail smtp.mailfrom=evil.example; dkim=none; dmarc=fail header.from=bank.example';
|
||||
const OTHER_DOMAIN = 'mx.example.org; spf=pass smtp.mailfrom=other.example; dkim=pass header.d=other.example; dmarc=pass header.from=other.example';
|
||||
const NO_DMARC = 'mx.example.org; spf=pass smtp.mailfrom=bank.example; dkim=pass header.d=bank.example';
|
||||
const SPOOFED_PASS = 'mx.example.org; spf=fail smtp.mailfrom=bank.example; dkim=none; dmarc=pass header.from=bank.example';
|
||||
|
||||
function row(auth?: string[], from = 'billing@bank.example'): Email {
|
||||
return {
|
||||
id: 'm1', threadId: 't1', mailboxIds: {}, keywords: {}, size: 1, receivedAt: '2026-10-31T10:00:00Z',
|
||||
hasAttachment: false, from: [{ name: 'Bank', email: from }],
|
||||
...(auth ? { [LIST_AUTH_RESULTS_PROPERTY]: auth } : {}),
|
||||
} as Email;
|
||||
}
|
||||
|
||||
beforeEach(() => resetBrandLogosForTests());
|
||||
|
||||
describe('brandIndicatorDomain', () => {
|
||||
it('names the From domain of a message that passed DMARC for it, from the list property or the headers', () => {
|
||||
expect(brandIndicatorDomain(row([PASS]))).toBe('bank.example');
|
||||
const full = { ...row(), headers: [{ name: 'Authentication-Results', value: ` ${PASS}` }] };
|
||||
expect(brandIndicatorDomain(full)).toBe('bank.example');
|
||||
expect(brandIndicatorDomain(row([PASS], 'Billing@BANK.example'))).toBe('bank.example');
|
||||
});
|
||||
|
||||
it('names none for a failed, missing or foreign DMARC result, or a spoofed message', () => {
|
||||
expect(brandIndicatorDomain(row([FAIL]))).toBeUndefined();
|
||||
expect(brandIndicatorDomain(row([NO_DMARC]))).toBeUndefined();
|
||||
expect(brandIndicatorDomain(row([OTHER_DOMAIN]))).toBeUndefined();
|
||||
expect(brandIndicatorDomain(row([SPOOFED_PASS]))).toBeUndefined();
|
||||
expect(brandIndicatorDomain(row())).toBeUndefined();
|
||||
expect(brandIndicatorDomain(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('bimiLogoUrl', () => {
|
||||
it('asks the account webmail for the domain', () => {
|
||||
expect(bimiLogoUrl('https://mail.example.org/', 'bank.example')).toBe('https://mail.example.org/api/bimi?domain=bank.example');
|
||||
expect(bimiLogoUrl('https://mail.example.org/webmail', 'a&b.example')).toBe('https://mail.example.org/webmail/api/bimi?domain=a%26b.example');
|
||||
});
|
||||
|
||||
it('has none without a webmail', () => {
|
||||
expect(bimiLogoUrl(null, 'bank.example')).toBeNull();
|
||||
expect(bimiLogoUrl('', 'bank.example')).toBeNull();
|
||||
expect(bimiLogoUrl('javascript:alert(1)', 'bank.example')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('showBrandLogo', () => {
|
||||
const base = { senderFavicons: true, email: 'billing@bank.example', brandDomain: 'bank.example', webmailUrl: 'https://mail.example.org' };
|
||||
|
||||
it('shows the logo for the address whose message passed', () => {
|
||||
expect(showBrandLogo(base)).toBe(true);
|
||||
});
|
||||
|
||||
it('follows the sender favicon switch, which is off by default', () => {
|
||||
expect(showBrandLogo({ ...base, senderFavicons: false })).toBe(false);
|
||||
});
|
||||
|
||||
it('shows none for another address than the one the check was for', () => {
|
||||
expect(showBrandLogo({ ...base, email: 'someone@elsewhere.example' })).toBe(false);
|
||||
expect(showBrandLogo({ ...base, email: 'news@mail.bank.example' })).toBe(false);
|
||||
});
|
||||
|
||||
it('shows none without a webmail or a passing domain', () => {
|
||||
expect(showBrandLogo({ ...base, webmailUrl: undefined })).toBe(false);
|
||||
expect(showBrandLogo({ ...base, brandDomain: undefined })).toBe(false);
|
||||
});
|
||||
|
||||
it('stops asking for a logo that failed this session', () => {
|
||||
markBrandLogoFailed('https://mail.example.org/api/bimi?domain=bank.example');
|
||||
expect(showBrandLogo(base)).toBe(false);
|
||||
});
|
||||
|
||||
it('reads the 1x1 answer as no logo', () => {
|
||||
expect(isNoLogoImage({ width: 1, height: 1 })).toBe(true);
|
||||
expect(isNoLogoImage({ width: 128, height: 128 })).toBe(false);
|
||||
expect(isNoLogoImage(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('avatar order with a BIMI logo', () => {
|
||||
it('keeps the warning shield and the contact photo ahead of the logo, and the logo ahead of the favicon', () => {
|
||||
expect(avatarContent({ senderWarning: 'Sender check failed', hasPhoto: true, showBrand: true, showFavicon: true })).toBe('warning');
|
||||
expect(avatarContent({ hasPhoto: true, showBrand: true, showFavicon: true })).toBe('photo');
|
||||
expect(avatarContent({ hasPhoto: false, showBrand: true, showFavicon: true })).toBe('brand');
|
||||
expect(avatarContent({ hasPhoto: false, showBrand: false, showFavicon: true })).toBe('favicon');
|
||||
});
|
||||
|
||||
it('draws the logo in SenderAvatar under the favicon switch, never for a warned sender', () => {
|
||||
const source = fs.readFileSync(path.resolve(__dirname, '..', '..', 'components', 'SenderAvatar.tsx'), 'utf8');
|
||||
// senderFavicons there is the setting, off for disabled images and a warned sender.
|
||||
expect(source).toContain("const senderFavicons = useSettingsStore((s) => s.senderFavicons) && !disableImages && !warned;");
|
||||
expect(source).toContain('showBrandLogo({ senderFavicons, email, brandDomain, webmailUrl })');
|
||||
expect(source).toContain('const webmailUrl = useAccountStore((s) => s.accounts.find((a) => a.id === s.activeAccountId)?.webmailUrl ?? null);');
|
||||
expect(source).toMatch(/if \(content === 'brand' && brandUrl\) \{[\s\S]*?source=\{\{ uri: brandUrl \}\}[\s\S]*?onError=\{handleBrandFailed\}/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { bottomQuickActionsFor, swipeActionShown, withoutArchive } from '../swipe-actions';
|
||||
|
||||
describe('archive action gate', () => {
|
||||
it('shows a stored Archive swipe as none while archiving is off', () => {
|
||||
expect(swipeActionShown('archive', false)).toBe('none');
|
||||
expect(swipeActionShown('archive', true)).toBe('archive');
|
||||
expect(swipeActionShown('delete', false)).toBe('delete');
|
||||
});
|
||||
|
||||
it('drops Archive from the pickers while archiving is off', () => {
|
||||
const options = [{ value: 'none' }, { value: 'archive' }, { value: 'delete' }];
|
||||
expect(withoutArchive(options, false).map((o) => o.value)).toEqual(['none', 'delete']);
|
||||
expect(withoutArchive(options, true).map((o) => o.value)).toEqual(['none', 'archive', 'delete']);
|
||||
});
|
||||
|
||||
it('fills a stored Archive quick action with a reply action, keeping three', () => {
|
||||
expect(bottomQuickActionsFor(['archive', 'delete', 'reply'], false)).toEqual(['delete', 'reply', 'replyAll']);
|
||||
expect(bottomQuickActionsFor(['reply', 'archive', 'replyAll'], false)).toEqual(['reply', 'replyAll', 'forward']);
|
||||
expect(bottomQuickActionsFor(['archive', 'delete', 'reply'], true)).toEqual(['archive', 'delete', 'reply']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
// BIMI sender logos (webmail components/ui/avatar.tsx, lib/email-headers.ts
|
||||
// brandIndicatorDomain, app/api/bimi/route.ts). The logo comes from the
|
||||
// webmail the account signed in through: GET <webmail>/api/bimi?domain=<From
|
||||
// domain> answers it as a PNG, or a 1x1 transparent PNG when the domain has
|
||||
// none. The webmail caches its answers (6 h for a logo, 1 h for none); a
|
||||
// domain without a logo is also remembered here for the session.
|
||||
|
||||
import type { Email } from '../api/types';
|
||||
import {
|
||||
LIST_AUTH_RESULTS_PROPERTY,
|
||||
headerValues,
|
||||
isAuthenticationSpoofed,
|
||||
parseAuthenticationResults,
|
||||
type AuthenticationResults,
|
||||
} from './email-headers';
|
||||
|
||||
function domainOf(address: string): string | undefined {
|
||||
const domain = address.slice(address.lastIndexOf('@') + 1).trim().toLowerCase().replace(/\.$/, '');
|
||||
return /^[^\s<>@]+\.[^\s<>@]+$/.test(domain) ? domain : undefined;
|
||||
}
|
||||
|
||||
/** The Authentication-Results of a message as the list or the reader holds it. */
|
||||
function authenticationResultsOf(email: Pick<Email, 'headers' | 'from'>): AuthenticationResults | undefined {
|
||||
let values: string[];
|
||||
if (email.headers) {
|
||||
values = headerValues(email.headers, 'Authentication-Results');
|
||||
} else {
|
||||
const raw = (email as { [LIST_AUTH_RESULTS_PROPERTY]?: unknown })[LIST_AUTH_RESULTS_PROPERTY];
|
||||
values = Array.isArray(raw) ? raw.filter((value): value is string => typeof value === 'string') : [];
|
||||
}
|
||||
return values.length > 0 ? parseAuthenticationResults(values) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The From domain of a message that passed DMARC for that domain, whose
|
||||
* BIMI logo may stand for the sender (webmail brandIndicatorDomain). The
|
||||
* logo says who the domain is, not that a message came from it, so a message
|
||||
* without a DMARC pass for its own From domain gets none. Undefined for no
|
||||
* message (rows that show the recipient).
|
||||
*/
|
||||
export function brandIndicatorDomain(email: Pick<Email, 'headers' | 'from'> | undefined): string | undefined {
|
||||
if (!email) return undefined;
|
||||
const fromEmail = email.from?.[0]?.email ?? undefined;
|
||||
const auth = authenticationResultsOf(email);
|
||||
if (!auth?.dmarc || auth.dmarc.result !== 'pass' || !fromEmail) return undefined;
|
||||
const domain = domainOf(fromEmail);
|
||||
if (!domain || isAuthenticationSpoofed(auth)) return undefined;
|
||||
const checked = auth.dmarc.domain ? domainOf(`@${auth.dmarc.domain}`) : domain;
|
||||
return checked === domain ? domain : undefined;
|
||||
}
|
||||
|
||||
/** The logo URL on the account's webmail; null without a usable webmail address. */
|
||||
export function bimiLogoUrl(webmailUrl: string | null | undefined, domain: string): string | null {
|
||||
const base = webmailUrl?.trim().replace(/\/+$/, '');
|
||||
if (!base || !/^https?:\/\/[^/]/i.test(base)) return null;
|
||||
return `${base}/api/bimi?domain=${encodeURIComponent(domain)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the avatar of `email` may show the BIMI logo of `brandDomain`
|
||||
* (webmail Avatar showBrand): only under the sender favicon switch, only for
|
||||
* the address shown (a thread row may show another participant than the
|
||||
* message whose DMARC pass named the domain), and only from a webmail.
|
||||
*/
|
||||
export function showBrandLogo(options: {
|
||||
senderFavicons: boolean;
|
||||
email: string | null | undefined;
|
||||
brandDomain: string | null | undefined;
|
||||
webmailUrl: string | null | undefined;
|
||||
}): boolean {
|
||||
const { senderFavicons, email, brandDomain, webmailUrl } = options;
|
||||
if (!senderFavicons || !brandDomain || !email) return false;
|
||||
if (domainOf(email) !== brandDomain) return false;
|
||||
const url = bimiLogoUrl(webmailUrl, brandDomain);
|
||||
return url !== null && !failedBrandLogos.has(url);
|
||||
}
|
||||
|
||||
// Logos that didn't load or answered "no logo", for the session.
|
||||
const failedBrandLogos = new Set<string>();
|
||||
|
||||
export function markBrandLogoFailed(url: string): void {
|
||||
failedBrandLogos.add(url);
|
||||
}
|
||||
|
||||
export function hasBrandLogoFailed(url: string): boolean {
|
||||
return failedBrandLogos.has(url);
|
||||
}
|
||||
|
||||
/** The webmail's "no logo" answer is a 1x1 transparent PNG. */
|
||||
export function isNoLogoImage(size: { width?: number; height?: number } | null | undefined): boolean {
|
||||
return !!size && typeof size.width === 'number' && size.width <= 1;
|
||||
}
|
||||
|
||||
export function resetBrandLogosForTests(): void {
|
||||
failedBrandLogos.clear();
|
||||
}
|
||||
@@ -238,21 +238,24 @@ export function getContactPhotoForEmail(
|
||||
|
||||
// --- What the avatar shows --------------------------------------------------
|
||||
|
||||
export type AvatarContent = 'warning' | 'photo' | 'favicon' | 'initials';
|
||||
export type AvatarContent = 'warning' | 'photo' | 'brand' | 'favicon' | 'initials';
|
||||
|
||||
/**
|
||||
* What a sender avatar shows, in the webmail `Avatar`'s order. A failed or
|
||||
* unverified sender check comes first: a warning shield instead of any photo,
|
||||
* favicon or initials, so a forged From doesn't get the real sender's logo or
|
||||
* contact photo (webmail 605a3983b, #1439).
|
||||
* logo, favicon or initials, so a forged From doesn't get the real sender's
|
||||
* logo or contact photo (webmail 605a3983b, #1439). Then the contact photo,
|
||||
* the domain's BIMI logo (see avatar-bimi.ts), the favicon.
|
||||
*/
|
||||
export function avatarContent(options: {
|
||||
senderWarning?: string | null;
|
||||
hasPhoto: boolean;
|
||||
showBrand?: boolean;
|
||||
showFavicon: boolean;
|
||||
}): AvatarContent {
|
||||
if (options.senderWarning) return 'warning';
|
||||
if (options.hasPhoto) return 'photo';
|
||||
if (options.showBrand) return 'brand';
|
||||
if (options.showFavicon) return 'favicon';
|
||||
return 'initials';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { normalizeBottomQuickActions, type QuickAction, type SwipeAction } from '../stores/settings-store';
|
||||
|
||||
// The organisation's archiveActionEnabled gate (webmail
|
||||
// message-list-settings.tsx): with it off, Archive is no swipe action, no
|
||||
// row action and no quick action, and a stored one does nothing.
|
||||
|
||||
/** What a swipe does: a stored Archive swipe does nothing while archiving is off. */
|
||||
export function swipeActionShown(action: SwipeAction, archiveEnabled: boolean): SwipeAction {
|
||||
return !archiveEnabled && action === 'archive' ? 'none' : action;
|
||||
}
|
||||
|
||||
/** The actions a picker offers: without Archive while archiving is off. */
|
||||
export function withoutArchive<T extends { value: string }>(options: readonly T[], archiveEnabled: boolean): T[] {
|
||||
return archiveEnabled ? [...options] : options.filter((option) => option.value !== 'archive');
|
||||
}
|
||||
|
||||
/**
|
||||
* The reader's three quick actions: a stored Archive slot gives way to a
|
||||
* reply action while archiving is off, so the bar keeps three buttons.
|
||||
*/
|
||||
export function bottomQuickActionsFor(value: unknown, archiveEnabled: boolean): QuickAction[] {
|
||||
const actions = normalizeBottomQuickActions(value);
|
||||
return archiveEnabled ? actions : normalizeBottomQuickActions(actions.filter((action) => action !== 'archive'));
|
||||
}
|
||||
@@ -54,6 +54,8 @@ import { useAuthStore } from '../stores/auth-store';
|
||||
import { useManagedAccountStore } from '../stores/managed-account-store';
|
||||
import { useAccountStore } from '../stores/account-store';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import { usePolicyStore } from '../stores/policy-store';
|
||||
import { policyHiddenPages } from '../components/settings/policy-pages';
|
||||
import { useUpdatesStore } from '../stores/updates-store';
|
||||
import { SUPPORTED_LOCALES } from '../i18n';
|
||||
import { settingsSummary, type SummaryState } from '../lib/settings-summary';
|
||||
@@ -231,12 +233,15 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
|
||||
// list, not shown as "Not on this server", where the server lacks them.
|
||||
const recoveryAvailable = useDeletedItemsRecovery().available;
|
||||
const delegationAvailable = useDelegationAvailable();
|
||||
// Pages the organisation's policy takes away (feature gates, hidden pages).
|
||||
const policy = usePolicyStore((s) => s.effective);
|
||||
const hiddenTabs = React.useMemo(() => {
|
||||
const set = new Set<Tab>();
|
||||
if (!recoveryAvailable) set.add('recovery');
|
||||
if (!delegationAvailable) set.add('sharing');
|
||||
for (const page of policyHiddenPages(policy)) set.add(page as Tab);
|
||||
return set;
|
||||
}, [recoveryAvailable, delegationAvailable]);
|
||||
}, [recoveryAvailable, delegationAvailable, policy]);
|
||||
|
||||
// A shared/group account picked under "Shared with me" scopes Settings to
|
||||
// the panes that account can be managed in (webmail: scoped settings).
|
||||
|
||||
Reference in New Issue
Block a user