From 2547c6f199d6b9cb45eec0be2ff76ae5ca1c607d Mon Sep 17 00:00:00 2001
From: arnauda-gh <59512940+arnauda-gh@users.noreply.github.com>
Date: Wed, 15 Jul 2026 19:31:54 +0200
Subject: [PATCH 1/2] fix: replace identity alert picker with scrollable bottom
sheet modal
---
src/components/IdentitySheet.tsx | 205 +++++++++++++++++++++++++++++++
src/components/index.ts | 2 +
src/screens/ComposeScreen.tsx | 23 ++--
3 files changed, 218 insertions(+), 12 deletions(-)
create mode 100644 src/components/IdentitySheet.tsx
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 1dfda9ac..bc1ac81c 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,
@@ -260,6 +260,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.
@@ -422,17 +423,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
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 = () => {
@@ -1293,6 +1284,14 @@ export default function ComposeScreen({ route, navigation }: Props) {
onChange={onCustomPickerChange}
/>
)}
+
+ setIdentitySheetOpen(false)}
+ identities={identities}
+ selectedIdentityId={selectedIdentityId}
+ onPick={(identity) => setSelectedIdentityId(identity.id)}
+ />
);
}
From 1bf5920b0433e5c3ebc1c66b96616e8d9344384f Mon Sep 17 00:00:00 2001
From: arnauda-gh <59512940+arnauda-gh@users.noreply.github.com>
Date: Wed, 15 Jul 2026 19:43:34 +0200
Subject: [PATCH 2/2] fix: pre-select primary sender identity matching active
account email
---
src/screens/ComposeScreen.tsx | 20 ++++++++++++++++----
1 file changed, 16 insertions(+), 4 deletions(-)
diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx
index bc1ac81c..c747ec53 100644
--- a/src/screens/ComposeScreen.tsx
+++ b/src/screens/ComposeScreen.tsx
@@ -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,
@@ -406,20 +407,31 @@ 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;