feat: add a toast host, prompt dialog, haptics helper and reduce-motion aware animations

This commit is contained in:
Linus Rath
2026-08-29 18:20:01 +02:00
parent 5802caeea9
commit 3ae9bbe702
10 changed files with 471 additions and 33 deletions
+63 -13
View File
@@ -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"
},
+29
View File
@@ -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",
+3
View File
@@ -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",
+55 -9
View File
@@ -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<TextInputProps, 'value' | 'onChangeText' | 'placeholder' | 'style'>;
}
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 (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onCancel}>
<View style={styles.backdrop}>
@@ -47,15 +64,33 @@ export default function Dialog({
<AlertTriangle size={20} color={c.error} />
</View>
)}
<Text style={styles.title}>{title}</Text>
<Text style={styles.message}>{message}</Text>
<Text style={styles.title} accessibilityRole="header">{title}</Text>
{message ? <Text style={styles.message}>{message}</Text> : null}
{input ? (
<TextInput
value={input.value}
onChangeText={input.onChangeText}
placeholder={input.placeholder}
placeholderTextColor={c.textMuted}
autoFocus
onSubmitEditing={confirmDisabled ? undefined : onConfirm}
accessibilityLabel={input.placeholder ?? title}
style={styles.input}
{...input.props}
/>
) : null}
</View>
<View style={styles.footer}>
<Button variant="outline" size="sm" onPress={onCancel}>
{cancelText}
{cancelLabel}
</Button>
<Button variant={variant === 'destructive' ? 'destructive' : 'default'} size="sm" onPress={onConfirm}>
{confirmText}
<Button
variant={variant === 'destructive' ? 'destructive' : 'default'}
size="sm"
onPress={onConfirm}
disabled={confirmDisabled}
>
{confirmLabel}
</Button>
</View>
</View>
@@ -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',
+4 -1
View File
@@ -26,7 +26,7 @@ export default function RadioGroup({ options, value, onChange, style }: RadioGro
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<View style={[styles.container, style]}>
<View style={[styles.container, style]} accessibilityRole="radiogroup">
{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}
>
<Text style={[styles.optionText, selected ? styles.textSelected : styles.textUnselected]}>
{opt.label}
+132
View File
@@ -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 (
<View pointerEvents="box-none" style={[styles.wrap, { bottom: insets.bottom + 64 }]}>
{toasts.map((t) => <ToastCard key={t.id} toast={t} />)}
</View>
);
}
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 (
<Animated.View
style={[cardStyles.card, { borderLeftColor: accent, opacity, transform: [{ translateY }] }]}
accessibilityRole="alert"
accessibilityLiveRegion="polite"
>
<Icon size={18} color={accent} />
<View style={{ flex: 1 }}>
<Text style={cardStyles.title} numberOfLines={2}>{toast.title}</Text>
{toast.message ? <Text style={cardStyles.message} numberOfLines={3}>{toast.message}</Text> : null}
</View>
{toast.action ? (
<Pressable
onPress={() => { toast.action?.onPress(); removeToast(toast.id); }}
hitSlop={8}
accessibilityRole="button"
style={({ pressed }) => [cardStyles.actionBtn, pressed && cardStyles.actionBtnPressed]}
>
<Text style={[cardStyles.actionText, { color: accent }]}>{toast.action.label}</Text>
</Pressable>
) : null}
<Pressable
onPress={() => removeToast(toast.id)}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('common.dismiss', 'Dismiss')}
style={cardStyles.close}
>
<X size={14} color={c.mutedForeground} />
</Pressable>
</Animated.View>
);
}
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 },
});
}
+21 -8
View File
@@ -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 (
<View style={styles.section}>
{experimental && (
<View style={styles.experimentalBanner}>
<FlaskConical size={20} color="#fbbf24" />
<FlaskConical size={20} color={c.warning} />
<View style={{ flex: 1 }}>
<Text style={styles.experimentalTitle}>Experimental Feature</Text>
<Text style={styles.experimentalTitle}>{t('settings.experimental.title', 'Experimental Feature')}</Text>
{experimentalDescription && (
<Text style={styles.experimentalDesc}>{experimentalDescription}</Text>
)}
@@ -119,13 +121,16 @@ export function RadioGroup({ value, onChange, options, style }: RadioGroupProps)
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
return (
<View style={[styles.radioGroup, style]}>
<View style={[styles.radioGroup, style]} accessibilityRole="radiogroup">
{options.map((opt) => {
const selected = opt.value === value;
return (
<Pressable
key={opt.value}
onPress={() => 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 (
<>
<Pressable style={[styles.select, style]} onPress={() => setOpen(true)}>
<Pressable
style={[styles.select, style]}
onPress={() => setOpen(true)}
accessibilityRole="combobox"
accessibilityLabel={current?.label ?? ''}
accessibilityState={{ expanded: open }}
>
<Text style={styles.selectText}>{current?.label ?? ''}</Text>
<ChevronDown size={14} color={c.mutedForeground} />
</Pressable>
@@ -177,6 +188,8 @@ export function Select({ value, onChange, options, style }: SelectProps) {
return (
<Pressable
key={opt.value}
accessibilityRole="menuitem"
accessibilityState={{ selected }}
style={({ pressed }) => [
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: {
+50
View File
@@ -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<void>;
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)
}
}
+81
View File
@@ -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<Toast, 'id' | 'createdAt' | 'duration'> & { duration?: number }) => string;
removeToast: (id: string) => void;
clearToasts: () => void;
}
let counter = 0;
export const useToastStore = create<ToastState>((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),
};
+33 -2
View File
@@ -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<boolean>(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.