diff --git a/src/components/IdentitySheet.tsx b/src/components/IdentitySheet.tsx new file mode 100644 index 00000000..081e53de --- /dev/null +++ b/src/components/IdentitySheet.tsx @@ -0,0 +1,205 @@ +import React from 'react'; +import { + View, Text, StyleSheet, ScrollView, Pressable, Modal, Animated, Easing, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { X, Check, Mail } from 'lucide-react-native'; +import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'; +import { useColors } from '../theme/colors'; +import { useSheetDrag } from '../lib/use-sheet-drag'; +import type { Identity } from '../api/types'; +import { useLocaleStore } from '../stores/locale-store'; + +interface IdentitySheetProps { + visible: boolean; + onClose: () => void; + identities: Identity[]; + selectedIdentityId: string | null; + onPick: (identity: Identity) => void; +} + +export function IdentitySheet({ + visible, onClose, identities, selectedIdentityId, onPick, +}: IdentitySheetProps) { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + const insets = useSafeAreaInsets(); + const slideY = React.useRef(new Animated.Value(500)).current; + const overlayOpacity = React.useRef(new Animated.Value(0)).current; + const dragHandlers = useSheetDrag({ slideY, closedY: 500, onClose }); + const { t } = useLocaleStore(); + + React.useEffect(() => { + if (visible) { + Animated.parallel([ + Animated.timing(slideY, { toValue: 0, duration: 220, easing: Easing.out(Easing.cubic), useNativeDriver: true }), + Animated.timing(overlayOpacity, { toValue: 1, duration: 220, useNativeDriver: true }), + ]).start(); + } else { + Animated.parallel([ + Animated.timing(slideY, { toValue: 500, duration: 180, easing: Easing.in(Easing.cubic), useNativeDriver: true }), + Animated.timing(overlayOpacity, { toValue: 0, duration: 180, useNativeDriver: true }), + ]).start(); + } + }, [visible, slideY, overlayOpacity]); + + return ( + + + + + + + + + + + {t('email_composer.from', 'From')} + + + + + + + {identities.map((identity) => { + const isSelected = identity.id === selectedIdentityId; + return ( + { + onPick(identity); + onClose(); + }} + style={({ pressed }) => [ + styles.identityRow, + pressed && styles.identityRowPressed, + ]} + > + + + + + {!!identity.name && ( + + {identity.name} + + )} + + {identity.email} + + + {isSelected && ( + + + + )} + + ); + })} + + + + ); +} + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + sheetOverlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0,0,0,0.5)', + }, + sheetOverlayPress: { flex: 1 }, + sheet: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + maxHeight: '60%', + backgroundColor: c.popover, + borderTopLeftRadius: radius.lg, + borderTopRightRadius: radius.lg, + borderTopWidth: 1, + borderColor: c.border, + paddingTop: spacing.sm, + }, + sheetHandleHit: { + alignItems: 'center', + paddingTop: spacing.xs, + paddingBottom: spacing.sm, + }, + sheetHandle: { + width: 36, + height: 4, + borderRadius: 2, + backgroundColor: c.border, + }, + sheetHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.lg, + paddingBottom: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: c.border, + }, + sheetTitle: { + ...typography.bodySemibold, + color: c.text, + }, + sheetClose: { + width: 28, + height: 28, + alignItems: 'center', + justifyContent: 'center', + borderRadius: radius.xs, + }, + scrollList: { + paddingVertical: spacing.xs, + }, + identityRow: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md, + minHeight: 52, + }, + identityRowPressed: { + backgroundColor: c.surfaceHover, + }, + iconWrap: { + marginRight: spacing.md, + width: 28, + height: 28, + borderRadius: radius.xs, + backgroundColor: c.surface, + alignItems: 'center', + justifyContent: 'center', + }, + identityDetails: { + flex: 1, + justifyContent: 'center', + }, + identityName: { + ...typography.bodyMedium, + color: c.text, + }, + identityEmail: { + ...typography.caption, + color: c.textSecondary, + marginTop: 2, + }, + identityEmailOnly: { + ...typography.bodyMedium, + color: c.text, + marginTop: 0, + }, + checkWrap: { + marginLeft: spacing.md, + }, + }); +} diff --git a/src/components/index.ts b/src/components/index.ts index d55f3b40..d033bcb7 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -5,3 +5,5 @@ export { default as Badge } from './Badge'; export { Card, SectionHeader, SettingItemRow } from './Card'; export { default as RadioGroup } from './RadioGroup'; export { default as Dialog } from './Dialog'; +export { IdentitySheet } from './IdentitySheet'; + diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index 01d55f4b..0c4b0445 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -17,7 +17,7 @@ import * as FileSystem from 'expo-file-system/legacy'; import { File as FsFile } from 'expo-file-system'; import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens'; import { useColors } from '../theme/colors'; -import { Button } from '../components'; +import { Button, IdentitySheet } from '../components'; import RichTextEditor, { type RichTextEditorHandle, type RichTextSelectionState, @@ -26,6 +26,7 @@ import { useEmailStore } from '../stores/email-store'; import { useContactsStore } from '../stores/contacts-store'; import { useLocaleStore } from '../stores/locale-store'; import { useSettingsStore } from '../stores/settings-store'; +import { useAccountStore } from '../stores/account-store'; import { isGroup } from '../lib/contact-utils'; import { getContactDisplayName, @@ -260,6 +261,7 @@ export default function ComposeScreen({ route, navigation }: Props) { const [identityError, setIdentityError] = React.useState(null); const [sending, setSending] = React.useState(false); const [selectedIdentityId, setSelectedIdentityId] = React.useState(null); + const [identitySheetOpen, setIdentitySheetOpen] = React.useState(false); const [scheduleSheetOpen, setScheduleSheetOpen] = React.useState(false); // Custom date/time picker stage. iOS shows one 'datetime' spinner; Android // can only show one field at a time, so we walk date → time. @@ -405,34 +407,35 @@ export default function ComposeScreen({ route, navigation }: Props) { // original message (matches webmail's reply-identity behaviour). React.useEffect(() => { if (selectedIdentityId || identities.length === 0) return; + const activeEmail = useAccountStore.getState().getActiveAccount()?.email || jmapClient.username; + const defaultIdentity = identities.find( + (i) => i.email.toLowerCase() === activeEmail?.toLowerCase() + ) ?? identities[0]; + if (autoSelectReplyIdentity && replyTo) { const candidates = [...(replyTo.to ?? []), ...(replyTo.cc ?? [])]; const candidateAddrs = new Set( candidates.map((r) => r.email?.toLowerCase()).filter(Boolean) as string[], ); const matched = identities.find((i) => candidateAddrs.has(i.email.toLowerCase())); - setSelectedIdentityId((matched ?? identities[0]).id); + setSelectedIdentityId((matched ?? defaultIdentity).id); } else { - setSelectedIdentityId(identities[0].id); + setSelectedIdentityId(defaultIdentity.id); } }, [identities, autoSelectReplyIdentity, replyTo, selectedIdentityId]); - const primaryIdentity = - identities.find((i) => i.id === selectedIdentityId) ?? identities[0]; + const primaryIdentity = React.useMemo(() => { + if (identities.length === 0) return null; + const activeEmail = useAccountStore.getState().getActiveAccount()?.email || jmapClient.username; + const defaultIdentity = identities.find( + (i) => i.email.toLowerCase() === activeEmail?.toLowerCase() + ) ?? identities[0]; + return identities.find((i) => i.id === selectedIdentityId) ?? defaultIdentity; + }, [identities, selectedIdentityId]); const openIdentityPicker = () => { if (identities.length <= 1) return; - Alert.alert( - t('email_composer.from', 'From'), - undefined, - [ - ...identities.map((i) => ({ - text: i.name ? `${i.name} <${i.email}>` : i.email, - onPress: () => setSelectedIdentityId(i.id), - })), - { text: t('email_composer.cancel', 'Cancel'), style: 'cancel' as const }, - ], - ); + setIdentitySheetOpen(true); }; const commitTyped = () => { @@ -1315,6 +1318,14 @@ export default function ComposeScreen({ route, navigation }: Props) { onChange={onCustomPickerChange} /> )} + + setIdentitySheetOpen(false)} + identities={identities} + selectedIdentityId={selectedIdentityId} + onPick={(identity) => setSelectedIdentityId(identity.id)} + /> ); }