diff --git a/locales/rn/en.json b/locales/rn/en.json index 773c58d5..bd0d76e0 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -1,9 +1,39 @@ { + "advanced_search": { + "after": "After", + "before": "Before", + "no_attachment": "No attachment", + "not_starred": "Not starred" + }, "calendar": { "event_modal": { "participant_placeholder": "Add participant by email" }, + "invitation": { + "accept": "Yes", + "add_error": "Could not add the event", + "add_to_calendar": "Add to calendar", + "added": "Added to calendar", + "already_in_calendar": "Already in your calendar", + "cancelled": "This event was cancelled", + "decline": "No", + "join_video": "Join video call", + "no_writable_calendar": "No writable calendar available", + "organizer": "Organizer", + "reading": "Reading invitation…", + "reply": "A participant replied to this invitation", + "response_error": "Could not send your response", + "response_sent": "Response sent", + "tentative": "Maybe", + "title": "Calendar invitation", + "trust_verified": "Sender verified" + }, "management": { + "clear_confirm": "Remove all", + "clear_description": "Every event in this calendar will be deleted. Events that also belong to another calendar are only unlinked.", + "clear_title": "Remove all events?", + "delete_description": "The calendar and all of its events will be deleted. This cannot be undone.", + "delete_title": "Delete calendar?", "description_placeholder": "Optional", "edit_calendar": "Edit calendar", "new_calendar": "New calendar" @@ -22,10 +52,13 @@ "common": { "add": "Add", "back": "Back", + "confirm": "Confirm", "create": "Create", "dismiss": "Dismiss", "done": "Done", - "remove": "Remove" + "remove": "Remove", + "retry": "Retry", + "undo": "Undo" }, "context_menu": { "color_tag": "Tag", @@ -48,27 +81,41 @@ "schedule_unsupported_title": "Scheduling unavailable", "scheduled_body": "Your message will be sent at {time}.", "scheduled_title": "Scheduled", + "send_now": "Send now", "send_timeout_body": "The message may already have gone out. Check your Sent folder before sending it again.", "send_timeout_title": "No answer from the server", + "undo_send_label": "Sending in {seconds}s…", "validation": { "subject_required": "Add a subject" } }, "email_list": { - "error": "Error", - "marked_as_spam": "Marked as spam", - "marked_not_spam": "Marked as not spam", - "move_same_account": "Messages can only be moved within the same account", - "moved_to_trash": "Email moved to Trash", - "no_archive_folder": "Could not find an Archive folder on the server.", - "no_junk_folder": "Could not find a Spam/Junk folder on the server.", - "no_trash_folder": "Could not find a Trash folder on the server. Please check your mailbox configuration.", - "undo_failed": "Undo failed", - "marked_as_spam_count": "{count, plural, one {# email} other {# emails}} marked as spam", - "marked_not_spam_count": "{count, plural, one {# email} other {# emails}} marked as not spam", "emails_archived_count": "{count, plural, one {# email} other {# emails}} archived", "emails_moved_to": "{count, plural, one {# email} other {# emails}} moved to {mailbox}", - "moved_to_trash_count": "{count, plural, one {# email} other {# emails}} moved to Trash" + "error": "Error", + "marked_as_spam": "Marked as spam", + "marked_as_spam_count": "{count, plural, one {# email} other {# emails}} marked as spam", + "marked_not_spam": "Marked as not spam", + "marked_not_spam_count": "{count, plural, one {# email} other {# emails}} marked as not spam", + "move_same_account": "Messages can only be moved within the same account", + "moved_to_trash": "Email moved to Trash", + "moved_to_trash_count": "{count, plural, one {# email} other {# emails}} moved to Trash", + "no_archive_folder": "Could not find an Archive folder on the server.", + "no_junk_folder": "Could not find a Spam/Junk folder on the server.", + "no_mailboxes": "No mailboxes found", + "no_mailboxes_hint": "Check that your JMAP account has mail capability.", + "no_trash_folder": "Could not find a Trash folder on the server. Please check your mailbox configuration.", + "offline_nothing_cached": "No connection. Showing nothing because no mail has been cached yet.", + "open_draft_failed": "Could not open the draft.", + "recent_searches": "Recent searches", + "scope_all_folders": "All folders", + "scope_this_folder": "This folder", + "search_placeholder": "Search mail...", + "showing_cached": "Showing cached mail", + "swipe_move": "Move", + "swipe_spam": "Spam", + "total": "total", + "undo_failed": "Undo failed" }, "email_viewer": { "color_tag": { @@ -231,6 +278,9 @@ "not_implemented": "Not implemented", "unavailable": "Unavailable" }, + "experimental": { + "title": "Experimental Feature" + }, "keywords": { "reset_defaults": "Reset to Defaults" }, diff --git a/package-lock.json b/package-lock.json index 6a9e95bf..5c4a2aed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,9 +18,11 @@ "date-fns": "^4.1.0", "expo": "~54.0.33", "expo-camera": "~17.0.10", + "expo-clipboard": "^8.0.7", "expo-crypto": "~15.0.9", "expo-document-picker": "~14.0.8", "expo-file-system": "~19.0.21", + "expo-haptics": "^15.0.8", "expo-image-manipulator": "~14.0.8", "expo-image-picker": "~17.0.10", "expo-intent-launcher": "~13.0.8", @@ -31,6 +33,7 @@ "expo-web-browser": "~15.0.8", "jszip": "^3.10.1", "lucide-react-native": "^1.7.0", + "postal-mime": "^3.0.0", "react": "19.1.0", "react-native": "0.81.5", "react-native-safe-area-context": "~5.6.0", @@ -5234,6 +5237,17 @@ } } }, + "node_modules/expo-clipboard": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-8.0.7.tgz", + "integrity": "sha512-zvlfFV+wB2QQrQnHWlo0EKHAkdi2tycLtE+EXFUWTPZYkgu1XcH+aiKfd4ul7Z0SDF+1IuwoiW9AA9eO35aj3Q==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-constants": { "version": "18.0.13", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz", @@ -5279,6 +5293,15 @@ "react-native": "*" } }, + "node_modules/expo-haptics": { + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/expo-haptics/-/expo-haptics-15.0.8.tgz", + "integrity": "sha512-lftutojy8Qs8zaDzzjwM3gKHFZ8bOOEZDCkmh2Ddpe95Ra6kt2izeOfOfKuP/QEh0MZ1j9TfqippyHdRd1ZM9g==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-image-loader": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-6.0.0.tgz", @@ -8235,6 +8258,12 @@ "node": ">=4.0.0" } }, + "node_modules/postal-mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-3.0.0.tgz", + "integrity": "sha512-Z4a9ar2Bv3YpK3IXag+Yda30k7bMZfpRuUGyqtHnZ2pjHG8Bl62EhZIk4n1dzv00gfzP9g+94e9kd8+XmjVWLA==", + "license": "MIT-0" + }, "node_modules/postcss": { "version": "8.4.49", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", diff --git a/package.json b/package.json index 5886d0cf..f26e4e2b 100644 --- a/package.json +++ b/package.json @@ -26,9 +26,11 @@ "date-fns": "^4.1.0", "expo": "~54.0.33", "expo-camera": "~17.0.10", + "expo-clipboard": "^8.0.7", "expo-crypto": "~15.0.9", "expo-document-picker": "~14.0.8", "expo-file-system": "~19.0.21", + "expo-haptics": "^15.0.8", "expo-image-manipulator": "~14.0.8", "expo-image-picker": "~17.0.10", "expo-intent-launcher": "~13.0.8", @@ -39,6 +41,7 @@ "expo-web-browser": "~15.0.8", "jszip": "^3.10.1", "lucide-react-native": "^1.7.0", + "postal-mime": "^3.0.0", "react": "19.1.0", "react-native": "0.81.5", "react-native-safe-area-context": "~5.6.0", diff --git a/src/components/Dialog.tsx b/src/components/Dialog.tsx index 15763014..816ddc66 100644 --- a/src/components/Dialog.tsx +++ b/src/components/Dialog.tsx @@ -1,17 +1,29 @@ import React from 'react'; -import { View, Text, Pressable, StyleSheet, Modal } from 'react-native'; +import { View, Text, StyleSheet, Modal, TextInput, type TextInputProps } from 'react-native'; import { AlertTriangle } from 'lucide-react-native'; import Button from './Button'; import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'; import { useColors } from '../theme/colors'; +import { useLocaleStore } from '../stores/locale-store'; + +interface DialogInput { + value: string; + onChangeText: (value: string) => void; + placeholder?: string; + // Extra TextInput props (keyboardType, autoCapitalize, secureTextEntry…). + props?: Omit; +} interface DialogProps { visible: boolean; title: string; - message: string; + message?: string; variant?: 'default' | 'destructive'; confirmText?: string; cancelText?: string; + // Turns the confirm dialog into a prompt (webmail prompt-dialog.tsx). + input?: DialogInput; + confirmDisabled?: boolean; onConfirm: () => void; onCancel: () => void; } @@ -30,13 +42,18 @@ export default function Dialog({ title, message, variant = 'default', - confirmText = 'Confirm', - cancelText = 'Cancel', + confirmText, + cancelText, + input, + confirmDisabled, onConfirm, onCancel, }: DialogProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); + const t = useLocaleStore((s) => s.t); + const confirmLabel = confirmText ?? t('common.confirm', 'Confirm'); + const cancelLabel = cancelText ?? t('common.cancel', 'Cancel'); return ( @@ -47,15 +64,33 @@ export default function Dialog({ )} - {title} - {message} + {title} + {message ? {message} : null} + {input ? ( + + ) : null} - @@ -107,6 +142,17 @@ function makeStyles(c: ThemePalette) { color: c.mutedForeground, // text-muted-foreground marginTop: spacing.sm, }, + input: { + ...typography.body, + color: c.text, + backgroundColor: c.surface, + borderWidth: 1, + borderColor: c.border, + borderRadius: radius.sm, + paddingHorizontal: spacing.md, + paddingVertical: 10, + marginTop: spacing.md, + }, footer: { flexDirection: 'row', justifyContent: 'flex-end', diff --git a/src/components/RadioGroup.tsx b/src/components/RadioGroup.tsx index 4183d619..e2767811 100644 --- a/src/components/RadioGroup.tsx +++ b/src/components/RadioGroup.tsx @@ -26,7 +26,7 @@ export default function RadioGroup({ options, value, onChange, style }: RadioGro const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); return ( - + {options.map((opt) => { const selected = opt.value === value; return ( @@ -34,6 +34,9 @@ export default function RadioGroup({ options, value, onChange, style }: RadioGro key={opt.value} style={[styles.option, selected ? styles.optionSelected : styles.optionUnselected]} onPress={() => onChange(opt.value)} + accessibilityRole="radio" + accessibilityState={{ selected, checked: selected }} + accessibilityLabel={opt.label} > {opt.label} diff --git a/src/components/ToastHost.tsx b/src/components/ToastHost.tsx new file mode 100644 index 00000000..4fe0aae0 --- /dev/null +++ b/src/components/ToastHost.tsx @@ -0,0 +1,132 @@ +import React from 'react'; +import { View, Text, StyleSheet, Pressable, Animated, Easing } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { AlertTriangle, CheckCircle2, Info, X, XCircle } from 'lucide-react-native'; +import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'; +import { useColors } from '../theme/colors'; +import { useAnimDuration } from '../theme/dynamic'; +import { useToastStore, type Toast } from '../stores/toast-store'; +import { useLocaleStore } from '../stores/locale-store'; + +/** + * Renders the toast queue above the tab bar. One host is mounted in App.tsx; + * the email undo snackbar keeps its own component because it is bound to + * the email store's pending-undo entry. + */ +export function ToastHost(): React.ReactElement | null { + const toasts = useToastStore((s) => s.toasts); + const insets = useSafeAreaInsets(); + if (toasts.length === 0) return null; + return ( + + {toasts.map((t) => )} + + ); +} + +function ToastCard({ toast }: { toast: Toast }) { + const c = useColors(); + const cardStyles = React.useMemo(() => makeStyles(c), [c]); + const t = useLocaleStore((s) => s.t); + const removeToast = useToastStore((s) => s.removeToast); + const duration = useAnimDuration(200); + const opacity = React.useRef(new Animated.Value(0)).current; + const translateY = React.useRef(new Animated.Value(24)).current; + + React.useEffect(() => { + Animated.parallel([ + Animated.timing(opacity, { toValue: 1, duration, useNativeDriver: true }), + Animated.timing(translateY, { toValue: 0, duration, easing: Easing.out(Easing.cubic), useNativeDriver: true }), + ]).start(); + const remaining = Math.max(0, toast.duration - (Date.now() - toast.createdAt)); + const timer = setTimeout(() => removeToast(toast.id), remaining); + return () => clearTimeout(timer); + }, [toast, opacity, translateY, duration, removeToast]); + + const accent = toast.type === 'success' + ? c.success + : toast.type === 'error' + ? c.error + : toast.type === 'warning' + ? c.warning + : c.info; + const Icon = toast.type === 'success' + ? CheckCircle2 + : toast.type === 'error' + ? XCircle + : toast.type === 'warning' + ? AlertTriangle + : Info; + + return ( + + + + {toast.title} + {toast.message ? {toast.message} : null} + + {toast.action ? ( + { toast.action?.onPress(); removeToast(toast.id); }} + hitSlop={8} + accessibilityRole="button" + style={({ pressed }) => [cardStyles.actionBtn, pressed && cardStyles.actionBtnPressed]} + > + {toast.action.label} + + ) : null} + removeToast(toast.id)} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel={t('common.dismiss', 'Dismiss')} + style={cardStyles.close} + > + + + + ); +} + +const styles = StyleSheet.create({ + wrap: { + position: 'absolute', + left: spacing.md, + right: spacing.md, + gap: spacing.sm, + zIndex: 60, + }, +}); + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + card: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingVertical: spacing.sm + 2, + paddingLeft: spacing.md, + paddingRight: spacing.sm, + borderRadius: radius.md, + borderLeftWidth: 3, + backgroundColor: c.popover, + borderWidth: 1, + borderColor: c.border, + shadowColor: '#000', + shadowOpacity: 0.25, + shadowRadius: 10, + shadowOffset: { width: 0, height: 4 }, + elevation: 6, + }, + title: { ...typography.bodyMedium, color: c.text }, + message: { ...typography.caption, color: c.mutedForeground, marginTop: 2 }, + actionBtn: { paddingHorizontal: spacing.sm, paddingVertical: 6, borderRadius: radius.sm }, + actionBtnPressed: { backgroundColor: c.muted }, + actionText: { ...typography.captionMedium }, + close: { padding: 4 }, + }); +} diff --git a/src/components/settings/settings-section.tsx b/src/components/settings/settings-section.tsx index ca152644..82f0f756 100644 --- a/src/components/settings/settings-section.tsx +++ b/src/components/settings/settings-section.tsx @@ -12,6 +12,7 @@ import { FlaskConical, Lock, ChevronDown, Check } from 'lucide-react-native'; import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; import ToggleSwitchComponent from '../ToggleSwitch'; +import { useLocaleStore } from '../../stores/locale-store'; /** * Mirrors webmail settings-section.tsx: @@ -39,13 +40,14 @@ export function SettingsSection({ }: SettingsSectionProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); + const t = useLocaleStore((s) => s.t); return ( {experimental && ( - + - Experimental Feature + {t('settings.experimental.title', 'Experimental Feature')} {experimentalDescription && ( {experimentalDescription} )} @@ -119,13 +121,16 @@ export function RadioGroup({ value, onChange, options, style }: RadioGroupProps) const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); return ( - + {options.map((opt) => { const selected = opt.value === value; return ( onChange(opt.value)} + accessibilityRole="radio" + accessibilityState={{ selected, checked: selected }} + accessibilityLabel={opt.label} style={[ styles.radioOption, selected ? styles.radioSelected : styles.radioUnselected, @@ -159,7 +164,13 @@ export function Select({ value, onChange, options, style }: SelectProps) { return ( <> - setOpen(true)}> + setOpen(true)} + accessibilityRole="combobox" + accessibilityLabel={current?.label ?? ''} + accessibilityState={{ expanded: open }} + > {current?.label ?? ''} @@ -177,6 +188,8 @@ export function Select({ value, onChange, options, style }: SelectProps) { return ( [ styles.selectItem, pressed && styles.selectItemPressed, @@ -220,18 +233,18 @@ function makeStyles(c: ThemePalette) { flexDirection: 'row', gap: spacing.md, borderWidth: 1, - borderColor: 'rgba(252, 211, 77, 0.4)', - backgroundColor: 'rgba(120, 53, 15, 0.25)', + borderColor: c.warning, + backgroundColor: c.warningBg, padding: spacing.md, borderRadius: radius.md, }, experimentalTitle: { ...typography.bodyMedium, - color: '#fcd34d', + color: c.text, }, experimentalDesc: { ...typography.caption, - color: '#fde68a', + color: c.textSecondary, marginTop: 2, }, settingItem: { diff --git a/src/lib/haptics.ts b/src/lib/haptics.ts new file mode 100644 index 00000000..ae2e0ab2 --- /dev/null +++ b/src/lib/haptics.ts @@ -0,0 +1,50 @@ +import { Platform } from 'react-native'; +import * as Haptics from 'expo-haptics'; + +// Tactile feedback helpers. Every call is fire-and-forget and swallows +// errors: haptics are a nicety, never a dependency. Callers outside this +// area (SwipeableRow, destructive confirms) should use these rather than +// importing expo-haptics directly so the "off" switch stays in one place. + +export type HapticKind = 'selection' | 'light' | 'medium' | 'heavy' | 'success' | 'warning' | 'error'; + +let enabled = true; + +/** Global kill switch (e.g. when the user disables animations). */ +export function setHapticsEnabled(value: boolean): void { + enabled = value; +} + +export function haptic(kind: HapticKind = 'light'): void { + if (!enabled || Platform.OS === 'web') return; + let promise: Promise; + try { + switch (kind) { + case 'selection': + promise = Haptics.selectionAsync(); + break; + case 'medium': + promise = Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + break; + case 'heavy': + promise = Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy); + break; + case 'success': + promise = Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + break; + case 'warning': + promise = Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning); + break; + case 'error': + promise = Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); + break; + case 'light': + default: + promise = Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + break; + } + void promise.catch(() => undefined); + } catch { + // native module missing (e.g. web / tests) + } +} diff --git a/src/stores/toast-store.ts b/src/stores/toast-store.ts new file mode 100644 index 00000000..82abf79e --- /dev/null +++ b/src/stores/toast-store.ts @@ -0,0 +1,81 @@ +import { create } from 'zustand'; + +// Lightweight port of the webmail's stores/toast-store.ts: a queue of typed +// toasts with an optional action, rendered by components/ToastHost. Use it +// for post-action acknowledgements and non-blocking errors instead of a +// modal Alert. + +export type ToastType = 'success' | 'error' | 'info' | 'warning'; + +export interface ToastAction { + label: string; + onPress: () => void; +} + +export interface Toast { + id: string; + type: ToastType; + title: string; + message?: string; + action?: ToastAction; + // ms; errors default to 10 s like the webmail, everything else to 5 s. + duration: number; + createdAt: number; +} + +interface ToastState { + toasts: Toast[]; + addToast: (toast: Omit & { duration?: number }) => string; + removeToast: (id: string) => void; + clearToasts: () => void; +} + +let counter = 0; + +export const useToastStore = create((set) => ({ + toasts: [], + + addToast: (toast) => { + const id = `toast-${Date.now().toString(36)}-${(counter++).toString(36)}`; + const entry: Toast = { + ...toast, + id, + createdAt: Date.now(), + duration: toast.duration ?? (toast.type === 'error' ? 10_000 : 5_000), + }; + // Keep the queue short: a burst of errors should not stack up forever. + set((state) => ({ toasts: [...state.toasts.slice(-2), entry] })); + return id; + }, + + removeToast: (id) => { + set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) })); + }, + + clearToasts: () => set({ toasts: [] }), +})); + +interface ToastOptions { + message?: string; + action?: ToastAction; + duration?: number; +} + +function show(type: ToastType, title: string, options?: string | ToastOptions): string { + const opts = typeof options === 'string' ? { message: options } : options; + return useToastStore.getState().addToast({ + type, + title, + message: opts?.message, + action: opts?.action, + duration: opts?.duration, + }); +} + +export const toast = { + success: (title: string, options?: string | ToastOptions) => show('success', title, options), + error: (title: string, options?: string | ToastOptions) => show('error', title, options), + info: (title: string, options?: string | ToastOptions) => show('info', title, options), + warning: (title: string, options?: string | ToastOptions) => show('warning', title, options), + dismiss: (id: string) => useToastStore.getState().removeToast(id), +}; diff --git a/src/theme/dynamic.ts b/src/theme/dynamic.ts index 733ad1fe..705da87e 100644 --- a/src/theme/dynamic.ts +++ b/src/theme/dynamic.ts @@ -3,7 +3,8 @@ // scaled / preference-driven variants so screens can react to settings // without a relaunch. -import { useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import { AccessibilityInfo } from 'react-native'; import { useSettingsStore } from '../stores/settings-store'; import { typography as baseTypography, spacing as baseSpacing } from './tokens'; @@ -84,10 +85,40 @@ export function useDensity() { ); } +// OS-level "reduce motion" (Android: Remove animations / iOS: Reduce Motion). +// Read once and kept fresh via the change event; defaults to false until the +// first answer arrives. +let reduceMotionCache: boolean | null = null; +function useReduceMotion(): boolean { + const [reduce, setReduce] = useState(reduceMotionCache ?? false); + useEffect(() => { + let mounted = true; + const info = AccessibilityInfo as typeof AccessibilityInfo | undefined; + if (!info?.isReduceMotionEnabled) return undefined; + void info.isReduceMotionEnabled().then((value) => { + reduceMotionCache = value; + if (mounted) setReduce(value); + }).catch(() => undefined); + const sub = info.addEventListener?.('reduceMotionChanged', (value: boolean) => { + reduceMotionCache = value; + if (mounted) setReduce(value); + }); + return () => { + mounted = false; + sub?.remove?.(); + }; + }, []); + return reduce; +} + // Returns whether the user has opted into in-app animations. When false, // callers should pass duration=0 to `Animated.timing` so transitions snap. +// Honours the OS reduce-motion preference as well (webmail: +// prefers-reduced-motion). export function useShouldAnimate() { - return useSettingsStore((s) => s.animationsEnabled); + const enabled = useSettingsStore((s) => s.animationsEnabled); + const reduceMotion = useReduceMotion(); + return enabled && !reduceMotion; } // Convenience: returns `requested` when animations are on, otherwise 0.