Update email format switcher logic and UI in mobile app (#150)

This commit is contained in:
Leendert de Borst
2026-05-31 17:07:38 +02:00
committed by Leendert de Borst
parent 896e91285a
commit ccd0113f3b
4 changed files with 96 additions and 38 deletions
+87 -37
View File
@@ -3,7 +3,7 @@ import { Buffer } from 'buffer';
import { Ionicons } from '@expo/vector-icons';
import * as FileSystem from 'expo-file-system';
import { useLocalSearchParams, useRouter, useNavigation, Stack } from 'expo-router';
import React, { useEffect, useState, useCallback } from 'react';
import React, { useEffect, useMemo, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { StyleSheet, View, ActivityIndicator, useColorScheme, Linking, Text, TextInput, Platform } from 'react-native';
import { WebView } from 'react-native-webview';
@@ -42,7 +42,7 @@ export default function EmailDetailsScreen() : React.ReactNode {
const [email, setEmail] = useState<Email | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isMetadataMaximized, setMetadataMaximized] = useState(false);
const [isHtmlView, setHtmlView] = useState(true);
const [viewMode, setViewMode] = useState<'html' | 'plain' | 'source'>('html');
const isDarkMode = useColorScheme() === 'dark';
const [associatedItem, setAssociatedItem] = useState<Item | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
@@ -80,11 +80,13 @@ export default function EmailDetailsScreen() : React.ReactNode {
setAssociatedItem(item);
}
// Set initial view mode based on content
// Set initial view mode based on content — prefer HTML, fall back to plain, then source.
if (decryptedEmail.messageHtml && decryptedEmail.messageHtml.length > 0) {
setHtmlView(true);
} else {
setHtmlView(false);
setViewMode('html');
} else if (decryptedEmail.messagePlain) {
setViewMode('plain');
} else if (decryptedEmail.messageSource) {
setViewMode('source');
}
} catch (err) {
/*
@@ -197,31 +199,65 @@ export default function EmailDetailsScreen() : React.ReactNode {
}
};
// Only offer formats the server actually provided. Source comes from the raw MessageSource column.
const availableModes = useMemo<Array<'html' | 'plain' | 'source'>>(() => {
if (!email) {
return [];
}
const modes: Array<'html' | 'plain' | 'source'> = [];
if (email.messageHtml) {
modes.push('html');
}
if (email.messagePlain) {
modes.push('plain');
}
if (email.messageSource) {
modes.push('source');
}
return modes;
}, [email]);
const formatLabels = useMemo<Record<'html' | 'plain' | 'source', string>>(() => ({
html: t('emails.formatHtml'),
plain: t('emails.formatPlain'),
source: t('emails.formatSource'),
}), [t]);
const cycleViewMode = useCallback((): void => {
if (availableModes.length <= 1) {
return;
}
const idx = availableModes.indexOf(viewMode);
const next = availableModes[(idx + 1) % availableModes.length];
setViewMode(next);
}, [availableModes, viewMode]);
const styles = StyleSheet.create({
attachment: {
alignItems: 'center',
backgroundColor: colors.accentBackground,
borderRadius: 8,
borderRadius: 6,
flexDirection: 'row',
marginBottom: 8,
padding: 12,
paddingHorizontal: 10,
paddingVertical: 6,
},
attachmentName: {
color: colors.textMuted,
fontSize: 14,
marginLeft: 8,
fontSize: 13,
marginLeft: 6,
},
attachments: {
borderTopColor: colors.accentBorder,
borderTopWidth: 1,
padding: 16,
paddingBottom: 100,
gap: 4,
paddingHorizontal: 12,
paddingVertical: 8,
},
attachmentsTitle: {
color: colors.text,
fontSize: 18,
fontWeight: 'bold',
marginBottom: 12,
fontSize: 13,
fontWeight: '600',
marginBottom: 6,
},
centerContainer: {
alignItems: 'center',
@@ -254,8 +290,13 @@ export default function EmailDetailsScreen() : React.ReactNode {
paddingRight: Platform.OS === 'ios' ? 0 : 10,
},
headerRightContainer: {
alignItems: 'center',
flexDirection: 'row',
},
headerRightFormatLabel: {
fontSize: 14,
fontWeight: '600',
},
metadataContainer: {
padding: 2,
},
@@ -314,6 +355,10 @@ export default function EmailDetailsScreen() : React.ReactNode {
fontSize: 15,
padding: 16,
},
sourceText: {
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
fontSize: 12,
},
subject: {
color: colors.text,
fontSize: 14,
@@ -351,18 +396,19 @@ export default function EmailDetailsScreen() : React.ReactNode {
*/
headerRight: () => (
<View style={styles.headerRightContainer}>
<RobustPressable
onPress={() => setHtmlView(!isHtmlView)}
style={styles.headerRightButton}
pressRetentionOffset={5}
hitSlop={5}
>
<Ionicons
name={isHtmlView ? 'text-outline' : 'document-outline'}
size={Platform.OS === 'android' ? 24 : 22}
color={colors.primary}
/>
</RobustPressable>
{availableModes.length > 1 && (
<RobustPressable
onPress={cycleViewMode}
style={styles.headerRightButton}
accessibilityLabel={t('emails.formatSwitchTitle')}
pressRetentionOffset={5}
hitSlop={5}
>
<Text style={[styles.headerRightFormatLabel, { color: colors.primary }]}>
{formatLabels[viewMode]}
</Text>
</RobustPressable>
)}
<RobustPressable
onPress={handleDelete}
style={[styles.headerRightButton, styles.headerRightButtonDelete]}
@@ -378,7 +424,7 @@ export default function EmailDetailsScreen() : React.ReactNode {
</View>
),
});
}, [isHtmlView, navigation, handleDelete, colors.primary, styles.headerRightButton, styles.headerRightButtonDelete, styles.headerRightContainer]);
}, [navigation, handleDelete, colors.primary, styles.headerRightButton, styles.headerRightButtonDelete, styles.headerRightContainer, styles.headerRightFormatLabel, availableModes, cycleViewMode, formatLabels, viewMode, t]);
if (isLoading) {
return (
@@ -487,7 +533,7 @@ export default function EmailDetailsScreen() : React.ReactNode {
}
let emailView = null;
if (isHtmlView && email.messageHtml) {
if (viewMode === 'html' && email.messageHtml) {
// Sanitize HTML
const sanitizedHtml = ConversionUtility.sanitizeHtmlForEmailViewing(email.messageHtml);
emailView = (
@@ -505,20 +551,24 @@ export default function EmailDetailsScreen() : React.ReactNode {
/>
);
} else {
const isSource = viewMode === 'source';
const text = (isSource ? email.messageSource : email.messagePlain) ?? '';
const textStyle = [
styles.plainText,
isDarkMode ? styles.textDark : styles.textLight,
isSource ? styles.sourceText : null,
];
emailView = Platform.OS === 'ios' ? (
<TextInput
multiline
editable={false}
selectTextOnFocus={true}
style={[styles.plainText, isDarkMode ? styles.textDark : styles.textLight]}
value={email.messagePlain || t('emails.noPlainText')}
style={textStyle}
value={text}
/>
) : (
<Text
selectable
style={[styles.plainText, isDarkMode ? styles.textDark : styles.textLight]}
>
{email.messagePlain || t('emails.noPlainText')}
<Text selectable style={textStyle}>
{text}
</Text>
);
}
+4 -1
View File
@@ -568,7 +568,10 @@
"deleteEmail": "Delete Email",
"deleteEmailConfirm": "Are you sure you want to delete this email? This action is permanent and cannot be undone.",
"emailNotFound": "Email not found",
"noPlainText": "This email does not contain any plain-text.",
"formatHtml": "HTML",
"formatPlain": "Text",
"formatSource": "Source",
"formatSwitchTitle": "Switch email format",
"sizeKB": "KB",
"offlineMessage": "You are offline. Please connect to the internet to load your emails.",
"emptyMessage": "You have not received any emails at your private email addresses yet. When you receive a new email, it will appear here.",
@@ -296,6 +296,9 @@ class EncryptionUtility {
if (email.messagePlain) {
decryptedEmail.messagePlain = await EncryptionUtility.symmetricDecrypt(email.messagePlain, symmetricKeyBase64);
}
if (email.messageSource) {
decryptedEmail.messageSource = await EncryptionUtility.symmetricDecrypt(email.messageSource, symmetricKeyBase64);
}
return decryptedEmail;
} catch (err) {
@@ -194,6 +194,8 @@ type Email = {
messageHtml: string;
/** The plain text body of the email message */
messagePlain: string;
/** The raw RFC 822 source of the email message */
messageSource: string;
/** The ID of the email */
id: number;
/** The subject of the email */