mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-04 00:06:53 +00:00
feat: add a toast host, prompt dialog, haptics helper and reduce-motion aware animations
This commit is contained in:
+63
-13
@@ -1,9 +1,39 @@
|
|||||||
{
|
{
|
||||||
|
"advanced_search": {
|
||||||
|
"after": "After",
|
||||||
|
"before": "Before",
|
||||||
|
"no_attachment": "No attachment",
|
||||||
|
"not_starred": "Not starred"
|
||||||
|
},
|
||||||
"calendar": {
|
"calendar": {
|
||||||
"event_modal": {
|
"event_modal": {
|
||||||
"participant_placeholder": "Add participant by email"
|
"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": {
|
"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",
|
"description_placeholder": "Optional",
|
||||||
"edit_calendar": "Edit calendar",
|
"edit_calendar": "Edit calendar",
|
||||||
"new_calendar": "New calendar"
|
"new_calendar": "New calendar"
|
||||||
@@ -22,10 +52,13 @@
|
|||||||
"common": {
|
"common": {
|
||||||
"add": "Add",
|
"add": "Add",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
|
"confirm": "Confirm",
|
||||||
"create": "Create",
|
"create": "Create",
|
||||||
"dismiss": "Dismiss",
|
"dismiss": "Dismiss",
|
||||||
"done": "Done",
|
"done": "Done",
|
||||||
"remove": "Remove"
|
"remove": "Remove",
|
||||||
|
"retry": "Retry",
|
||||||
|
"undo": "Undo"
|
||||||
},
|
},
|
||||||
"context_menu": {
|
"context_menu": {
|
||||||
"color_tag": "Tag",
|
"color_tag": "Tag",
|
||||||
@@ -48,27 +81,41 @@
|
|||||||
"schedule_unsupported_title": "Scheduling unavailable",
|
"schedule_unsupported_title": "Scheduling unavailable",
|
||||||
"scheduled_body": "Your message will be sent at {time}.",
|
"scheduled_body": "Your message will be sent at {time}.",
|
||||||
"scheduled_title": "Scheduled",
|
"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_body": "The message may already have gone out. Check your Sent folder before sending it again.",
|
||||||
"send_timeout_title": "No answer from the server",
|
"send_timeout_title": "No answer from the server",
|
||||||
|
"undo_send_label": "Sending in {seconds}s…",
|
||||||
"validation": {
|
"validation": {
|
||||||
"subject_required": "Add a subject"
|
"subject_required": "Add a subject"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"email_list": {
|
"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_archived_count": "{count, plural, one {# email} other {# emails}} archived",
|
||||||
"emails_moved_to": "{count, plural, one {# email} other {# emails}} moved to {mailbox}",
|
"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": {
|
"email_viewer": {
|
||||||
"color_tag": {
|
"color_tag": {
|
||||||
@@ -231,6 +278,9 @@
|
|||||||
"not_implemented": "Not implemented",
|
"not_implemented": "Not implemented",
|
||||||
"unavailable": "Unavailable"
|
"unavailable": "Unavailable"
|
||||||
},
|
},
|
||||||
|
"experimental": {
|
||||||
|
"title": "Experimental Feature"
|
||||||
|
},
|
||||||
"keywords": {
|
"keywords": {
|
||||||
"reset_defaults": "Reset to Defaults"
|
"reset_defaults": "Reset to Defaults"
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+29
@@ -18,9 +18,11 @@
|
|||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"expo": "~54.0.33",
|
"expo": "~54.0.33",
|
||||||
"expo-camera": "~17.0.10",
|
"expo-camera": "~17.0.10",
|
||||||
|
"expo-clipboard": "^8.0.7",
|
||||||
"expo-crypto": "~15.0.9",
|
"expo-crypto": "~15.0.9",
|
||||||
"expo-document-picker": "~14.0.8",
|
"expo-document-picker": "~14.0.8",
|
||||||
"expo-file-system": "~19.0.21",
|
"expo-file-system": "~19.0.21",
|
||||||
|
"expo-haptics": "^15.0.8",
|
||||||
"expo-image-manipulator": "~14.0.8",
|
"expo-image-manipulator": "~14.0.8",
|
||||||
"expo-image-picker": "~17.0.10",
|
"expo-image-picker": "~17.0.10",
|
||||||
"expo-intent-launcher": "~13.0.8",
|
"expo-intent-launcher": "~13.0.8",
|
||||||
@@ -31,6 +33,7 @@
|
|||||||
"expo-web-browser": "~15.0.8",
|
"expo-web-browser": "~15.0.8",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"lucide-react-native": "^1.7.0",
|
"lucide-react-native": "^1.7.0",
|
||||||
|
"postal-mime": "^3.0.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
"react-native-safe-area-context": "~5.6.0",
|
"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": {
|
"node_modules/expo-constants": {
|
||||||
"version": "18.0.13",
|
"version": "18.0.13",
|
||||||
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz",
|
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz",
|
||||||
@@ -5279,6 +5293,15 @@
|
|||||||
"react-native": "*"
|
"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": {
|
"node_modules/expo-image-loader": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-6.0.0.tgz",
|
||||||
@@ -8235,6 +8258,12 @@
|
|||||||
"node": ">=4.0.0"
|
"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": {
|
"node_modules/postcss": {
|
||||||
"version": "8.4.49",
|
"version": "8.4.49",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz",
|
||||||
|
|||||||
@@ -26,9 +26,11 @@
|
|||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"expo": "~54.0.33",
|
"expo": "~54.0.33",
|
||||||
"expo-camera": "~17.0.10",
|
"expo-camera": "~17.0.10",
|
||||||
|
"expo-clipboard": "^8.0.7",
|
||||||
"expo-crypto": "~15.0.9",
|
"expo-crypto": "~15.0.9",
|
||||||
"expo-document-picker": "~14.0.8",
|
"expo-document-picker": "~14.0.8",
|
||||||
"expo-file-system": "~19.0.21",
|
"expo-file-system": "~19.0.21",
|
||||||
|
"expo-haptics": "^15.0.8",
|
||||||
"expo-image-manipulator": "~14.0.8",
|
"expo-image-manipulator": "~14.0.8",
|
||||||
"expo-image-picker": "~17.0.10",
|
"expo-image-picker": "~17.0.10",
|
||||||
"expo-intent-launcher": "~13.0.8",
|
"expo-intent-launcher": "~13.0.8",
|
||||||
@@ -39,6 +41,7 @@
|
|||||||
"expo-web-browser": "~15.0.8",
|
"expo-web-browser": "~15.0.8",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"lucide-react-native": "^1.7.0",
|
"lucide-react-native": "^1.7.0",
|
||||||
|
"postal-mime": "^3.0.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
"react-native-safe-area-context": "~5.6.0",
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
|
|||||||
@@ -1,17 +1,29 @@
|
|||||||
import React from 'react';
|
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 { AlertTriangle } from 'lucide-react-native';
|
||||||
import Button from './Button';
|
import Button from './Button';
|
||||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||||
import { useColors } from '../theme/colors';
|
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 {
|
interface DialogProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
title: string;
|
title: string;
|
||||||
message: string;
|
message?: string;
|
||||||
variant?: 'default' | 'destructive';
|
variant?: 'default' | 'destructive';
|
||||||
confirmText?: string;
|
confirmText?: string;
|
||||||
cancelText?: string;
|
cancelText?: string;
|
||||||
|
// Turns the confirm dialog into a prompt (webmail prompt-dialog.tsx).
|
||||||
|
input?: DialogInput;
|
||||||
|
confirmDisabled?: boolean;
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
@@ -30,13 +42,18 @@ export default function Dialog({
|
|||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
variant = 'default',
|
variant = 'default',
|
||||||
confirmText = 'Confirm',
|
confirmText,
|
||||||
cancelText = 'Cancel',
|
cancelText,
|
||||||
|
input,
|
||||||
|
confirmDisabled,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: DialogProps) {
|
}: DialogProps) {
|
||||||
const c = useColors();
|
const c = useColors();
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
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 (
|
return (
|
||||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onCancel}>
|
<Modal visible={visible} transparent animationType="fade" onRequestClose={onCancel}>
|
||||||
<View style={styles.backdrop}>
|
<View style={styles.backdrop}>
|
||||||
@@ -47,15 +64,33 @@ export default function Dialog({
|
|||||||
<AlertTriangle size={20} color={c.error} />
|
<AlertTriangle size={20} color={c.error} />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
<Text style={styles.title}>{title}</Text>
|
<Text style={styles.title} accessibilityRole="header">{title}</Text>
|
||||||
<Text style={styles.message}>{message}</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>
|
||||||
<View style={styles.footer}>
|
<View style={styles.footer}>
|
||||||
<Button variant="outline" size="sm" onPress={onCancel}>
|
<Button variant="outline" size="sm" onPress={onCancel}>
|
||||||
{cancelText}
|
{cancelLabel}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant={variant === 'destructive' ? 'destructive' : 'default'} size="sm" onPress={onConfirm}>
|
<Button
|
||||||
{confirmText}
|
variant={variant === 'destructive' ? 'destructive' : 'default'}
|
||||||
|
size="sm"
|
||||||
|
onPress={onConfirm}
|
||||||
|
disabled={confirmDisabled}
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -107,6 +142,17 @@ function makeStyles(c: ThemePalette) {
|
|||||||
color: c.mutedForeground, // text-muted-foreground
|
color: c.mutedForeground, // text-muted-foreground
|
||||||
marginTop: spacing.sm,
|
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: {
|
footer: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'flex-end',
|
justifyContent: 'flex-end',
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export default function RadioGroup({ options, value, onChange, style }: RadioGro
|
|||||||
const c = useColors();
|
const c = useColors();
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, style]}>
|
<View style={[styles.container, style]} accessibilityRole="radiogroup">
|
||||||
{options.map((opt) => {
|
{options.map((opt) => {
|
||||||
const selected = opt.value === value;
|
const selected = opt.value === value;
|
||||||
return (
|
return (
|
||||||
@@ -34,6 +34,9 @@ export default function RadioGroup({ options, value, onChange, style }: RadioGro
|
|||||||
key={opt.value}
|
key={opt.value}
|
||||||
style={[styles.option, selected ? styles.optionSelected : styles.optionUnselected]}
|
style={[styles.option, selected ? styles.optionSelected : styles.optionUnselected]}
|
||||||
onPress={() => onChange(opt.value)}
|
onPress={() => onChange(opt.value)}
|
||||||
|
accessibilityRole="radio"
|
||||||
|
accessibilityState={{ selected, checked: selected }}
|
||||||
|
accessibilityLabel={opt.label}
|
||||||
>
|
>
|
||||||
<Text style={[styles.optionText, selected ? styles.textSelected : styles.textUnselected]}>
|
<Text style={[styles.optionText, selected ? styles.textSelected : styles.textUnselected]}>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { FlaskConical, Lock, ChevronDown, Check } from 'lucide-react-native';
|
|||||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||||
import { useColors } from '../../theme/colors';
|
import { useColors } from '../../theme/colors';
|
||||||
import ToggleSwitchComponent from '../ToggleSwitch';
|
import ToggleSwitchComponent from '../ToggleSwitch';
|
||||||
|
import { useLocaleStore } from '../../stores/locale-store';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mirrors webmail settings-section.tsx:
|
* Mirrors webmail settings-section.tsx:
|
||||||
@@ -39,13 +40,14 @@ export function SettingsSection({
|
|||||||
}: SettingsSectionProps) {
|
}: SettingsSectionProps) {
|
||||||
const c = useColors();
|
const c = useColors();
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
|
const t = useLocaleStore((s) => s.t);
|
||||||
return (
|
return (
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
{experimental && (
|
{experimental && (
|
||||||
<View style={styles.experimentalBanner}>
|
<View style={styles.experimentalBanner}>
|
||||||
<FlaskConical size={20} color="#fbbf24" />
|
<FlaskConical size={20} color={c.warning} />
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<Text style={styles.experimentalTitle}>Experimental Feature</Text>
|
<Text style={styles.experimentalTitle}>{t('settings.experimental.title', 'Experimental Feature')}</Text>
|
||||||
{experimentalDescription && (
|
{experimentalDescription && (
|
||||||
<Text style={styles.experimentalDesc}>{experimentalDescription}</Text>
|
<Text style={styles.experimentalDesc}>{experimentalDescription}</Text>
|
||||||
)}
|
)}
|
||||||
@@ -119,13 +121,16 @@ export function RadioGroup({ value, onChange, options, style }: RadioGroupProps)
|
|||||||
const c = useColors();
|
const c = useColors();
|
||||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||||
return (
|
return (
|
||||||
<View style={[styles.radioGroup, style]}>
|
<View style={[styles.radioGroup, style]} accessibilityRole="radiogroup">
|
||||||
{options.map((opt) => {
|
{options.map((opt) => {
|
||||||
const selected = opt.value === value;
|
const selected = opt.value === value;
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
onPress={() => onChange(opt.value)}
|
onPress={() => onChange(opt.value)}
|
||||||
|
accessibilityRole="radio"
|
||||||
|
accessibilityState={{ selected, checked: selected }}
|
||||||
|
accessibilityLabel={opt.label}
|
||||||
style={[
|
style={[
|
||||||
styles.radioOption,
|
styles.radioOption,
|
||||||
selected ? styles.radioSelected : styles.radioUnselected,
|
selected ? styles.radioSelected : styles.radioUnselected,
|
||||||
@@ -159,7 +164,13 @@ export function Select({ value, onChange, options, style }: SelectProps) {
|
|||||||
|
|
||||||
return (
|
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>
|
<Text style={styles.selectText}>{current?.label ?? ''}</Text>
|
||||||
<ChevronDown size={14} color={c.mutedForeground} />
|
<ChevronDown size={14} color={c.mutedForeground} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -177,6 +188,8 @@ export function Select({ value, onChange, options, style }: SelectProps) {
|
|||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
|
accessibilityRole="menuitem"
|
||||||
|
accessibilityState={{ selected }}
|
||||||
style={({ pressed }) => [
|
style={({ pressed }) => [
|
||||||
styles.selectItem,
|
styles.selectItem,
|
||||||
pressed && styles.selectItemPressed,
|
pressed && styles.selectItemPressed,
|
||||||
@@ -220,18 +233,18 @@ function makeStyles(c: ThemePalette) {
|
|||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
gap: spacing.md,
|
gap: spacing.md,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: 'rgba(252, 211, 77, 0.4)',
|
borderColor: c.warning,
|
||||||
backgroundColor: 'rgba(120, 53, 15, 0.25)',
|
backgroundColor: c.warningBg,
|
||||||
padding: spacing.md,
|
padding: spacing.md,
|
||||||
borderRadius: radius.md,
|
borderRadius: radius.md,
|
||||||
},
|
},
|
||||||
experimentalTitle: {
|
experimentalTitle: {
|
||||||
...typography.bodyMedium,
|
...typography.bodyMedium,
|
||||||
color: '#fcd34d',
|
color: c.text,
|
||||||
},
|
},
|
||||||
experimentalDesc: {
|
experimentalDesc: {
|
||||||
...typography.caption,
|
...typography.caption,
|
||||||
color: '#fde68a',
|
color: c.textSecondary,
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
},
|
},
|
||||||
settingItem: {
|
settingItem: {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -3,7 +3,8 @@
|
|||||||
// scaled / preference-driven variants so screens can react to settings
|
// scaled / preference-driven variants so screens can react to settings
|
||||||
// without a relaunch.
|
// 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 { useSettingsStore } from '../stores/settings-store';
|
||||||
import { typography as baseTypography, spacing as baseSpacing } from './tokens';
|
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,
|
// Returns whether the user has opted into in-app animations. When false,
|
||||||
// callers should pass duration=0 to `Animated.timing` so transitions snap.
|
// 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() {
|
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.
|
// Convenience: returns `requested` when animations are on, otherwise 0.
|
||||||
|
|||||||
Reference in New Issue
Block a user