feat: offer the code of sign-in and verification mails for copying in the list and the reader

This commit is contained in:
Linus Rath
2026-10-29 03:15:09 +01:00
parent cc21317b5a
commit b1550f143b
8 changed files with 1107 additions and 0 deletions
+14
View File
@@ -19,6 +19,8 @@ import {
import { formatSize } from '../../lib/attachment-display';
import { isSmimeEmail } from '../../lib/smime';
import { resolveEmailTags } from '../../lib/thread-utils';
import { findVerificationCode, verificationCodeBodyText } from '../../lib/verification-code';
import { VerificationCodeChip } from './VerificationCodeChip';
interface Props {
email: Email;
@@ -99,6 +101,7 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA
const locale = useLocaleStore((s) => s.locale);
const timeFormat = useSettingsStore((s) => s.timeFormat);
const keywordDefs = useKeywordsStore((s) => s.keywords);
const showVerificationCodes = useSettingsStore((s) => s.showVerificationCodes);
const [showDetails, setShowDetails] = React.useState(false);
React.useEffect(() => { setShowDetails(false); }, [email.id]);
@@ -114,6 +117,15 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA
[info.auth, from?.email],
);
const verificationText = verification ? describeSenderVerification(verification, t) : null;
// The sign-in code of the message, offered for copying. Looked up again
// only when the message or its loaded body changes, not on a keyword change.
const verificationCode = React.useMemo(
() => (showVerificationCodes && !compact
? findVerificationCode(email.subject, verificationCodeBodyText(email))
: null),
// eslint-disable-next-line react-hooks/exhaustive-deps
[showVerificationCodes, compact, email.id, email.subject, email.bodyValues],
);
const verificationColor = verification?.status === 'failed' ? c.error : c.warning;
const verificationBg = verification?.status === 'failed' ? c.errorBg : c.warningBg;
@@ -253,6 +265,8 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA
</View>
)}
{verificationCode && <VerificationCodeChip code={verificationCode} />}
{!compact && verificationText && (
<View style={[styles.verificationNotice, { borderColor: verificationColor }]} accessibilityRole="alert">
<ShieldAlert size={18} color={verificationColor} />
@@ -0,0 +1,92 @@
import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import * as Clipboard from 'expo-clipboard';
import { Check, Key } from '../icons';
import type { Email } from '../../api/types';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { useSettingsStore } from '../../stores/settings-store';
import { toast } from '../../stores/toast-store';
import { listVerificationCode } from '../../lib/verification-code';
/** The code a list row offers: found in the subject and preview of a fresh mail. */
export function useListVerificationCode(email: Pick<Email, 'subject' | 'preview' | 'receivedAt'>): string | null {
const enabled = useSettingsStore((s) => s.showVerificationCodes);
const { subject, preview, receivedAt } = email;
return React.useMemo(
() => (enabled ? listVerificationCode({ subject, preview, receivedAt }) : null),
[enabled, subject, preview, receivedAt],
);
}
interface Props {
code: string;
/** A list row in selection mode: the tap selects the row instead. */
disabled?: boolean;
/** Spacing for a list row (below the preview). */
inList?: boolean;
}
/** The one-time code of a sign-in mail (lib/verification-code.ts); a tap copies it. */
export function VerificationCodeChip({ code, disabled, inList }: Props) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const [copied, setCopied] = React.useState(false);
React.useEffect(() => {
if (!copied) return undefined;
const timer = setTimeout(() => setCopied(false), 2000);
return () => clearTimeout(timer);
}, [copied]);
const copy = () => {
Clipboard.setStringAsync(code).then(
() => {
setCopied(true);
toast.success(t('email_viewer.verification_code.copied', 'Code copied'));
},
() => toast.error(t('email_viewer.verification_code.copy_failed', 'Could not copy the code')),
);
};
const Icon = copied ? Check : Key;
const label = t('email_viewer.verification_code.copy', 'Copy code {code}', { code });
return (
<View style={[styles.row, inList && styles.rowInList]}>
<Pressable
onPress={copy}
disabled={disabled}
hitSlop={6}
style={({ pressed }) => [styles.chip, pressed && styles.chipPressed]}
accessibilityRole="button"
accessibilityLabel={label}
testID="verification-code-chip"
>
<Icon size={12} color={copied ? c.success : c.warning} />
<Text style={styles.code} numberOfLines={1}>{code}</Text>
</Pressable>
</View>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
row: { flexDirection: 'row' },
rowInList: { marginTop: spacing.xs },
chip: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: spacing.sm,
paddingVertical: 3,
borderWidth: 1,
borderColor: c.border,
borderRadius: radius.sm,
backgroundColor: c.surface,
},
chipPressed: { backgroundColor: c.surfaceHover },
code: { ...typography.caption, color: c.text, fontWeight: '600', fontFamily: 'monospace', letterSpacing: 0.5 },
});
}
@@ -32,6 +32,7 @@ export function ReadingSettings() {
const deleteAction = useSettingsStore((s) => s.deleteAction);
const permanentlyDeleteJunk = useSettingsStore((s) => s.permanentlyDeleteJunk);
const showPreview = useSettingsStore((s) => s.showPreview);
const showVerificationCodes = useSettingsStore((s) => s.showVerificationCodes);
const clearSearchOnFolderChange = useSettingsStore((s) => s.clearSearchOnFolderChange);
const disableThreading = useSettingsStore((s) => s.disableThreading);
const includeGroupInUnified = useSettingsStore((s) => s.includeGroupInUnified);
@@ -193,6 +194,10 @@ export function ReadingSettings() {
<ToggleSwitch checked={showPreview} onChange={(v) => update('showPreview', v)} />
</SettingItem>
<SettingItem label={t('settings.email_behavior.show_verification_codes.label', "Show Verification Codes")} description={t('settings.email_behavior.show_verification_codes.description', "Offer the code from sign-in and confirmation emails for one-click copying, in the list for a day and in the message")}>
<ToggleSwitch checked={showVerificationCodes} onChange={(v) => update('showVerificationCodes', v)} />
</SettingItem>
<SettingItem label={t('settings.email_behavior.disable_threading.label', "Disable Conversation Grouping")} description={t('settings.email_behavior.disable_threading.description', "Show emails as individual messages instead of grouped by conversation")}>
<ToggleSwitch checked={disableThreading} onChange={(v) => update('disableThreading', v)} />
</SettingItem>