mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
Merge branch 'cu-reader' into catch-up-2026-11
# Conflicts: # docs/native-sync-2026-11.md # docs/parity/11-webmail-1.19.md # locales/rn/en.json # src/stores/settings-store.ts
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, Linking, Platform, Text, Pressable, useWindowDimensions } from 'react-native';
|
||||
import { View, StyleSheet, Linking, Platform, Text, Pressable, PixelRatio, useWindowDimensions } from 'react-native';
|
||||
import { WebView, type WebViewMessageEvent } from 'react-native-webview';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
@@ -14,11 +14,15 @@ import { parseMailtoUrl } from '../lib/unsubscribe';
|
||||
import { fetchInlineImageDataUri } from '../lib/email-export';
|
||||
import { isSenderContentTrusted, isTrustedSendersSyncOn } from '../lib/trusted-senders';
|
||||
import { senderVerificationOf } from '../lib/email-headers';
|
||||
import { plainTextReading } from '../lib/html-to-readable-text';
|
||||
import { buildMailTextSizeScript, mailTextFactor } from '../lib/mail-reading';
|
||||
import { buildPauseAnimatedImagesScript, withReducedMotion } from '../lib/mail-motion';
|
||||
import { useHasContacts } from '../lib/capabilities';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import { useContactsStore } from '../stores/contacts-store';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
import { spacing, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useShouldAnimate, useTypography } from '../theme/dynamic';
|
||||
import { useColors, useResolvedTheme } from '../theme/colors';
|
||||
import { perfEndAfterPaint, perfLap } from '../lib/perf-marks';
|
||||
import type { RootStackParamList } from '../navigation/types';
|
||||
@@ -54,6 +58,13 @@ interface EmailBodyViewProps {
|
||||
* page below the header) rather than a guessed height.
|
||||
*/
|
||||
fill?: boolean;
|
||||
/**
|
||||
* Per-message "Show as plain text" from the More sheet; null or undefined
|
||||
* follows the "Plain Text Only" setting.
|
||||
*/
|
||||
plainView?: boolean | null;
|
||||
/** "Show formatted" in the plain-text note: turn the per-message view off. */
|
||||
onShowFormatted?: () => void;
|
||||
}
|
||||
|
||||
// Dark-mode re-inversion, mirroring the webmail's handleIframeLoad pass. The
|
||||
@@ -521,6 +532,7 @@ const PINCH_ZOOM = `
|
||||
|
||||
export default function EmailBodyView({
|
||||
email, senderEmail, jmapAccountId, onSwipe, onZoomChange, themeOverride, bodyOverride, onSettled, fill,
|
||||
plainView: plainViewOverride, onShowFormatted,
|
||||
}: EmailBodyViewProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
@@ -535,6 +547,16 @@ export default function EmailBodyView({
|
||||
const emailAlwaysLightMode = useSettingsStore((s) => s.emailAlwaysLightMode);
|
||||
const plainTextFont = useSettingsStore((s) => s.plainTextFont);
|
||||
const messageSpacing = useSettingsStore((s) => s.messageSpacing);
|
||||
const plainTextMode = useSettingsStore((s) => s.plainTextMode);
|
||||
// Body text follows the app's text size and the system font scale (webmail
|
||||
// c07ba1a37); it was fixed at 14 px. The app's factor is read off its
|
||||
// scaled body style, so it follows whatever steps the text size setting has.
|
||||
const dyn = useTypography();
|
||||
const textFactor = mailTextFactor(dyn.body.fontSize / typography.body.fontSize, PixelRatio.getFontScale());
|
||||
// Reduce motion (the system setting, or the app's animations switch) holds
|
||||
// inside mail too: CSS animations stop and animated images pause behind a
|
||||
// Play button (webmail 24a13cc1f, bb7869798).
|
||||
const reduceMotion = !useShouldAnimate();
|
||||
const trustedSenderEmails = useContactsStore((s) => s.trustedSenderEmails);
|
||||
const trustedSendersLoaded = useContactsStore((s) => s.trustedSendersLoaded);
|
||||
const loadTrustedSendersBook = useContactsStore((s) => s.loadTrustedSendersBook);
|
||||
@@ -562,8 +584,19 @@ export default function EmailBodyView({
|
||||
if (bodyOverride) return { html: bodyOverride.html ?? null, text: bodyOverride.text ?? null };
|
||||
return pickEmailBody(email);
|
||||
}, [email, bodyOverride]);
|
||||
const rawHtml = React.useMemo(() => selectRenderableHtml(picked), [picked]);
|
||||
const text = picked.text;
|
||||
const formattedHtml = React.useMemo(() => selectRenderableHtml(picked), [picked]);
|
||||
// HTML mail as plain text: "Plain Text Only" for every message, or the More
|
||||
// sheet's per-message switch. The message's own text part when it has one,
|
||||
// else a readable conversion that keeps link targets and image alt text
|
||||
// (webmail 1e929b0a9). A plain-text view loads nothing remote, so the
|
||||
// external-content banner goes too.
|
||||
const plainView = plainViewOverride ?? plainTextMode;
|
||||
const plainText = React.useMemo(
|
||||
() => (plainView ? plainTextReading({ html: formattedHtml, text: picked.text }) : null),
|
||||
[plainView, formattedHtml, picked.text],
|
||||
);
|
||||
const rawHtml = plainText !== null ? null : formattedHtml;
|
||||
const text = plainText ?? picked.text;
|
||||
// A From address the server's checks don't back may be forged: neither
|
||||
// honour an earlier "Always trust" for it nor offer one, or a forgery of a
|
||||
// trusted sender would load remote content (and trusting it would let the
|
||||
@@ -660,6 +693,13 @@ export default function EmailBodyView({
|
||||
// Sanitised and wrapped once per message, body and settings, and reused
|
||||
// when the same body mounts again (lib/email-body-document).
|
||||
const emptyLabel = t('email_viewer.no_body_content', '(No body content available)');
|
||||
// A plain-text body in a dark app theme takes the theme's colours, not a
|
||||
// fixed #0a0a0a. Only while the app itself is dark: a single message
|
||||
// switched to dark in a light app has no dark palette to borrow.
|
||||
const plainDarkColors = React.useMemo(
|
||||
() => (resolvedTheme === 'dark' ? { background: c.background, text: c.text, link: c.textLink } : undefined),
|
||||
[resolvedTheme, c.background, c.text, c.textLink],
|
||||
);
|
||||
const prepared = React.useMemo(() => bodyDocument({
|
||||
key: `${jmapAccountId ?? ''}|${email.id}`,
|
||||
rawHtml,
|
||||
@@ -671,12 +711,21 @@ export default function EmailBodyView({
|
||||
messageSpacing,
|
||||
plainTextFont,
|
||||
quoteLabels,
|
||||
textFactor,
|
||||
plainDarkColors,
|
||||
}), [
|
||||
jmapAccountId, email.id, rawHtml, text, email.preview, emptyLabel, shouldBlock, cidMap, renderAsDark,
|
||||
messageSpacing, plainTextFont, quoteLabels,
|
||||
messageSpacing, plainTextFont, quoteLabels, textFactor, plainDarkColors,
|
||||
]);
|
||||
|
||||
const source = React.useMemo(() => ({ html: prepared.html }), [prepared.html]);
|
||||
const source = React.useMemo(
|
||||
() => ({ html: reduceMotion && prepared.isHtml ? withReducedMotion(prepared.html) : prepared.html }),
|
||||
[prepared.html, prepared.isHtml, reduceMotion],
|
||||
);
|
||||
const pauseLabels = React.useMemo(() => ({
|
||||
play: t('email_viewer.play_animation', 'Play animation'),
|
||||
pause: t('email_viewer.pause_animation', 'Pause animation'),
|
||||
}), [t]);
|
||||
const showBanner = shouldBlock && prepared.blockedExternal;
|
||||
// Still cut off after the API's larger refetch (or the refetch failed).
|
||||
const showTruncated = !bodyOverride && hasTruncatedDisplayedBody(email);
|
||||
@@ -686,11 +735,13 @@ export default function EmailBodyView({
|
||||
// input the CSS was built from, so the DOM pass and the stylesheet agree.
|
||||
const injectedJs = React.useMemo(
|
||||
() =>
|
||||
(prepared.isHtml ? buildQuoteCollapseScript(quoteLabels) : '')
|
||||
(prepared.isHtml ? buildMailTextSizeScript(textFactor) : '')
|
||||
+ (prepared.isHtml ? buildQuoteCollapseScript(quoteLabels) : '')
|
||||
+ (prepared.applyInversion ? DARK_REINVERT_SCRIPT : '')
|
||||
+ (prepared.isHtml ? IMG_FIXUP_SCRIPT : '')
|
||||
+ (prepared.isHtml && reduceMotion ? buildPauseAnimatedImagesScript(pauseLabels) : '')
|
||||
+ HEIGHT_REPORTER + SWIPE_DETECTOR + PINCH_ZOOM,
|
||||
[prepared.isHtml, prepared.applyInversion, quoteLabels],
|
||||
[prepared.isHtml, prepared.applyInversion, quoteLabels, textFactor, reduceMotion, pauseLabels],
|
||||
);
|
||||
|
||||
// Performance harness: how much of opening a message is the WebView.
|
||||
@@ -765,6 +816,18 @@ export default function EmailBodyView({
|
||||
|
||||
return (
|
||||
<View style={[styles.wrapper, fill && styles.wrapperFill]}>
|
||||
{plainText !== null && (
|
||||
<View style={styles.plainNote}>
|
||||
<Text style={styles.bannerText}>
|
||||
{t('email_viewer.shown_as_plain_text', 'This message is shown as plain text.')}
|
||||
</Text>
|
||||
{onShowFormatted && (
|
||||
<Pressable onPress={onShowFormatted} hitSlop={8} accessibilityRole="button">
|
||||
<Text style={styles.plainNoteLink}>{t('email_viewer.show_formatted', 'Show formatted')}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
{showBanner && (
|
||||
<View style={styles.banner}>
|
||||
<Text style={styles.bannerText}>
|
||||
@@ -821,6 +884,9 @@ export default function EmailBodyView({
|
||||
// Native Android zoom would scale the viewport inside the fixed-size
|
||||
// container; pinch zoom is implemented in the page instead.
|
||||
setBuiltInZoomControls={false}
|
||||
// The system font size is part of textFactor; Android's WebView
|
||||
// would scale the text by it a second time.
|
||||
textZoom={100}
|
||||
showsVerticalScrollIndicator={false}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
setSupportMultipleWindows={false}
|
||||
@@ -895,6 +961,19 @@ function makeStyles(c: ThemePalette) {
|
||||
...typography.caption,
|
||||
color: c.textSecondary,
|
||||
},
|
||||
plainNote: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
columnGap: spacing.sm,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.sm,
|
||||
},
|
||||
plainNoteLink: {
|
||||
...typography.caption,
|
||||
color: c.primary,
|
||||
textDecorationLine: 'underline',
|
||||
},
|
||||
webContainer: {
|
||||
width: '100%',
|
||||
backgroundColor: 'transparent',
|
||||
|
||||
@@ -13,19 +13,24 @@ import { buildMailboxTree, flattenVisible, type MailboxNode } from '../lib/mailb
|
||||
import { useSheetDrag } from '../lib/use-sheet-drag';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
import { localizeMailboxName } from '../lib/mailbox-label';
|
||||
import { mailboxIconKind, type MailboxIconKind } from '../lib/mailbox-icon';
|
||||
import type { Mailbox } from '../api/types';
|
||||
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||||
import { useSheetInsets } from '../lib/use-layout';
|
||||
|
||||
function moveTargetIcon(role: string | null | undefined, name: string): AppIcon {
|
||||
const lower = name.toLowerCase();
|
||||
if (role === 'inbox' || lower.includes('inbox')) return Inbox;
|
||||
if (role === 'sent' || lower.includes('sent')) return Send;
|
||||
if (role === 'drafts' || lower.includes('draft')) return FileIcon;
|
||||
if (role === 'trash' || lower.includes('trash') || lower.includes('deleted')) return Trash2;
|
||||
if (role === 'junk' || role === 'spam' || lower.includes('junk') || lower.includes('spam')) return Ban;
|
||||
if (role === 'archive' || lower.includes('archive')) return Archive;
|
||||
return Folder;
|
||||
// By role only, like the sidebar (mailboxIconKind, #1235): a user folder
|
||||
// named "web/sentry" is not the Sent folder.
|
||||
const MOVE_TARGET_ICONS: Partial<Record<MailboxIconKind, AppIcon>> = {
|
||||
inbox: Inbox,
|
||||
sent: Send,
|
||||
drafts: FileIcon,
|
||||
trash: Trash2,
|
||||
junk: Ban,
|
||||
archive: Archive,
|
||||
};
|
||||
|
||||
function moveTargetIcon(role: string | null | undefined): AppIcon {
|
||||
return MOVE_TARGET_ICONS[mailboxIconKind(role, false, false)] ?? Folder;
|
||||
}
|
||||
|
||||
interface MoveSheetProps {
|
||||
@@ -106,7 +111,7 @@ export function MoveSheet({
|
||||
</View>
|
||||
<ScrollView>
|
||||
{visibleNodes.map((node) => {
|
||||
const Icon = moveTargetIcon(node.role, node.name);
|
||||
const Icon = moveTargetIcon(node.role);
|
||||
const isCurrent = node.id === currentMailboxId;
|
||||
// A shared account's header is a grouping row, not a folder, and
|
||||
// Drafts is never a move target (the webmail excludes it too).
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import React from 'react';
|
||||
import { View, Text, TextInput, Pressable, StyleSheet } from 'react-native';
|
||||
import { Plus } from './icons';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
import { keywordToken, useKeywordsStore, type KeywordDef } from '../stores/keywords-store';
|
||||
import { checkNewTag, newTagDefinition } from '../lib/new-tag';
|
||||
|
||||
/**
|
||||
* "New tag" at the end of a tag picker: a name field; the tag gets the next
|
||||
* colour of the palette, is saved with the other tags and goes onto the
|
||||
* message(s) straight away (webmail components/email/tag-picker.tsx).
|
||||
*/
|
||||
export function NewTagRow({ keywords, onCreated }: {
|
||||
/** The tags the picker lists: a new id must not repeat one of them. */
|
||||
keywords: KeywordDef[];
|
||||
/** Apply the new tag's keyword token. */
|
||||
onCreated: (token: string) => void;
|
||||
}) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const addKeyword = useKeywordsStore((s) => s.add);
|
||||
const [creating, setCreating] = React.useState(false);
|
||||
const [name, setName] = React.useState('');
|
||||
const { error } = checkNewTag(name, keywords);
|
||||
const errorText = error === 'tag_exists'
|
||||
? t('email_viewer.tag_exists', 'A tag with this name already exists')
|
||||
: error === 'tag_name_invalid' ? t('email_viewer.tag_name_invalid', 'Choose a shorter name') : null;
|
||||
|
||||
const create = () => {
|
||||
const def = newTagDefinition(name, keywords);
|
||||
if (!def) return;
|
||||
addKeyword(def);
|
||||
setCreating(false);
|
||||
setName('');
|
||||
onCreated(keywordToken(def.id));
|
||||
};
|
||||
|
||||
if (!creating) {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => setCreating(true)}
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
accessibilityRole="button"
|
||||
testID="tag-picker-new"
|
||||
>
|
||||
<Plus size={16} color={c.textMuted} />
|
||||
<Text style={styles.label}>{t('email_viewer.new_tag', 'New tag')}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View style={styles.form}>
|
||||
<TextInput
|
||||
autoFocus
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
onSubmitEditing={create}
|
||||
returnKeyType="done"
|
||||
placeholder={t('email_viewer.new_tag_placeholder', 'Tag name')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
accessibilityLabel={t('email_viewer.new_tag_placeholder', 'Tag name')}
|
||||
style={[styles.input, errorText ? styles.inputError : null]}
|
||||
/>
|
||||
{errorText && <Text style={styles.error} accessibilityRole="alert">{errorText}</Text>}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
minHeight: 44,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: c.border,
|
||||
},
|
||||
rowPressed: { backgroundColor: c.surfaceHover },
|
||||
label: { ...typography.body, color: c.textSecondary, flex: 1 },
|
||||
form: { paddingHorizontal: spacing.lg, paddingVertical: spacing.sm, gap: spacing.xs },
|
||||
input: {
|
||||
...typography.body,
|
||||
color: c.text,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.sm,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
minHeight: 40,
|
||||
},
|
||||
inputError: { borderColor: c.error },
|
||||
error: { ...typography.caption, color: c.error },
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import React from 'react';
|
||||
import { View, Text, Image, StyleSheet, type StyleProp, type ViewStyle } from 'react-native';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import {
|
||||
avatarContent,
|
||||
initialsColor,
|
||||
generateEmailAvatarColor,
|
||||
getContactPhotoForEmail,
|
||||
getEmailInitials,
|
||||
@@ -14,6 +16,8 @@ import type { ContactPhotoRef } from '../lib/contact-utils';
|
||||
import { useContactPhotoSource } from '../lib/use-contact-photo';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
import { useContactsStore } from '../stores/contacts-store';
|
||||
import { useColors } from '../theme/colors';
|
||||
import { ShieldAlert } from './icons';
|
||||
|
||||
interface SenderAvatarProps {
|
||||
name?: string | null;
|
||||
@@ -32,23 +36,32 @@ interface SenderAvatarProps {
|
||||
* the contact has no photo, so no other card's photo is borrowed.
|
||||
*/
|
||||
photo?: ContactPhotoRef | null;
|
||||
/**
|
||||
* The message's sender check failed or is unverified (see
|
||||
* useSenderWarning): a warning shield with this label instead of any photo,
|
||||
* favicon or initials, so a forged From doesn't get the real sender's logo
|
||||
* or photo (webmail #1439).
|
||||
*/
|
||||
senderWarning?: string | null;
|
||||
}
|
||||
|
||||
// Mirrors the webmail `Avatar` priority for the subset available on native:
|
||||
// address-book photo → company favicon (if sender domain is non-personal and
|
||||
// `senderFavicons` is on) → initials over HSL-hashed color.
|
||||
export default function SenderAvatar({ name, email, size = 40, style, disableImages, photo }: SenderAvatarProps) {
|
||||
const senderFavicons = useSettingsStore((s) => s.senderFavicons) && !disableImages;
|
||||
// sender warning → address-book photo → company favicon (if sender domain is
|
||||
// non-personal and `senderFavicons` is on) → initials over HSL-hashed color.
|
||||
export default function SenderAvatar({ name, email, size = 40, style, disableImages, photo, senderWarning }: SenderAvatarProps) {
|
||||
const c = useColors();
|
||||
const warned = !!senderWarning;
|
||||
const senderFavicons = useSettingsStore((s) => s.senderFavicons) && !disableImages && !warned;
|
||||
// Compared shallowly, so only avatars whose photo changed re-render when the
|
||||
// contacts change; the index behind it is built once per contacts array.
|
||||
const indexedPhoto = useContactsStore(
|
||||
useShallow((s) => (photo === undefined ? getContactPhotoForEmail(s.contacts, email) : undefined)),
|
||||
useShallow((s) => (photo === undefined && !warned ? getContactPhotoForEmail(s.contacts, email) : undefined)),
|
||||
);
|
||||
const contactPhoto = photo === undefined ? indexedPhoto : photo;
|
||||
// A blob photo (Stalwart 1.0) has no source while it waits for its
|
||||
// download or after it failed; the favicon or initials show meanwhile.
|
||||
const { source: photoSource, onLoad: onPhotoLoad, onError: onPhotoError } =
|
||||
useContactPhotoSource(disableImages ? null : contactPhoto);
|
||||
useContactPhotoSource(disableImages || warned ? null : contactPhoto);
|
||||
const [imgError, setImgError] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -61,8 +74,11 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma
|
||||
|
||||
const faviconDomain = getFaviconDomain(email ?? undefined);
|
||||
const domainFailed = faviconDomain ? hasFaviconFailed(faviconDomain) : false;
|
||||
const showPhoto = !disableImages && !!photoSource;
|
||||
const showFavicon = !showPhoto && senderFavicons && !!faviconDomain && !imgError && !domainFailed;
|
||||
const content = avatarContent({
|
||||
senderWarning,
|
||||
hasPhoto: !disableImages && !!photoSource,
|
||||
showFavicon: senderFavicons && !!faviconDomain && !imgError && !domainFailed,
|
||||
});
|
||||
|
||||
const handleError = React.useCallback(() => {
|
||||
setImgError(true);
|
||||
@@ -75,14 +91,29 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size / 2,
|
||||
backgroundColor: showPhoto ? 'transparent' : showFavicon ? '#ffffff' : bgColor,
|
||||
backgroundColor: content === 'warning'
|
||||
? c.errorBg
|
||||
: content === 'photo' ? 'transparent' : content === 'favicon' ? '#ffffff' : bgColor,
|
||||
},
|
||||
style,
|
||||
];
|
||||
|
||||
const fontSize = Math.round(size * 0.4);
|
||||
|
||||
if (showPhoto && photoSource) {
|
||||
if (content === 'warning') {
|
||||
return (
|
||||
<View
|
||||
style={containerStyle}
|
||||
accessible
|
||||
accessibilityLabel={`${primaryName || email || ''} (${senderWarning})`}
|
||||
testID="sender-warning-avatar"
|
||||
>
|
||||
<ShieldAlert size={Math.round(size * 0.5)} color={c.error} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (content === 'photo' && photoSource) {
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<Image
|
||||
@@ -96,7 +127,7 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma
|
||||
);
|
||||
}
|
||||
|
||||
if (showFavicon && faviconDomain) {
|
||||
if (content === 'favicon' && faviconDomain) {
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<Image
|
||||
@@ -111,7 +142,7 @@ export default function SenderAvatar({ name, email, size = 40, style, disableIma
|
||||
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<Text style={[styles.initials, { fontSize }]}>{initials}</Text>
|
||||
<Text style={[styles.initials, { fontSize, color: initialsColor(bgColor) }]}>{initials}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
import { useAnimDuration } from '../theme/dynamic';
|
||||
import { useEmailStore, spannedAccounts } from '../stores/email-store';
|
||||
import { useEmailStore, spannedAccounts, trashAndJunkIds } from '../stores/email-store';
|
||||
import { useAuthStore } from '../stores/auth-store';
|
||||
import { useAccountStore } from '../stores/account-store';
|
||||
import { useSettingsStore } from '../stores/settings-store';
|
||||
@@ -128,7 +128,7 @@ function RowCounts({ unread, total, showTotal, onPressUnread }: {
|
||||
disabled={!onPressUnread}
|
||||
hitSlop={8}
|
||||
accessibilityRole={onPressUnread ? 'button' : undefined}
|
||||
accessibilityLabel={onPressUnread ? t('sidebar.show_unread', 'Show unread') : undefined}
|
||||
accessibilityLabel={onPressUnread ? t('sidebar.unread_filter', 'Show unread only ({count})', { count: unread }) : undefined}
|
||||
>
|
||||
<Text style={styles.countUnread}>{unread}</Text>
|
||||
</Pressable>
|
||||
@@ -167,11 +167,19 @@ function SidebarRow({
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const leftPad = ROW_PX_BASE + depth * INDENT_STEP;
|
||||
// Which folder is open, and the unread count with the folder's name
|
||||
// ("Inbox, 3 unread"), for screen readers (webmail 7c282f1f4).
|
||||
const accessibleName = unread > 0 && !isAccountHeader
|
||||
? t('shell.name_unread', '{name}, {count} unread', { name: label, count: unread })
|
||||
: label;
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}
|
||||
delayLongPress={350}
|
||||
accessibilityRole={isAccountHeader ? 'header' : 'button'}
|
||||
accessibilityLabel={accessibleName}
|
||||
accessibilityState={isAccountHeader ? undefined : { selected: isSelected }}
|
||||
style={({ pressed }) => [
|
||||
styles.row,
|
||||
isSelected && styles.rowSelected,
|
||||
@@ -454,8 +462,10 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
|
||||
React.useEffect(() => {
|
||||
if (!visible || !tagsExpanded || keywordDefs.length === 0 || !activeAccountId || !jmapClient.isConnected) return;
|
||||
const accounts = tagAccountsKey.split('|').map((id) => id || undefined);
|
||||
void ensureTagCounts(activeAccountId, keywordDefs.map((k) => k.id), accounts);
|
||||
}, [visible, tagsExpanded, keywordDefs, tagAccountsKey, activeAccountId, tagCountsGeneration, ensureTagCounts]);
|
||||
// Trash and Junk stay out of the counts as they do of the tag view (#1156).
|
||||
const excluded = Object.fromEntries(accounts.map((id) => [id ?? '', trashAndJunkIds(mailboxes, id)]));
|
||||
void ensureTagCounts(activeAccountId, keywordDefs.map((k) => k.id), accounts, excluded);
|
||||
}, [visible, tagsExpanded, keywordDefs, tagAccountsKey, mailboxes, activeAccountId, tagCountsGeneration, ensureTagCounts]);
|
||||
|
||||
// ── Folder actions (long-press) ───────────────────────────────────────
|
||||
const refFor = (mb: Mailbox) => ({
|
||||
|
||||
@@ -13,6 +13,7 @@ import { getEmailTagIds } from '../lib/thread-utils';
|
||||
import type { Email } from '../api/types';
|
||||
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||||
import { useSheetInsets } from '../lib/use-layout';
|
||||
import { NewTagRow } from './NewTagRow';
|
||||
|
||||
interface TagSheetProps {
|
||||
visible: boolean;
|
||||
@@ -132,6 +133,7 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
<NewTagRow keywords={keywords} onCreated={(token) => onToggle(token, true)} />
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
// Selecting mail needed a long press; native #91 asks for a tap on the
|
||||
// left side, as the webmail's avatar does.
|
||||
|
||||
describe('list row avatar (#91)', () => {
|
||||
it('toggles the row into the selection on a tap', () => {
|
||||
const src = fs.readFileSync(path.resolve(__dirname, '../../screens/EmailListScreen.tsx'), 'utf8');
|
||||
const avatar = src.slice(src.indexOf('{density.showAvatar && ('), src.indexOf('{/* Content */}'));
|
||||
expect(avatar).toMatch(/<Pressable\s+onPress=\{handleLongPress\}/);
|
||||
expect(avatar).toMatch(/accessibilityRole="checkbox"/);
|
||||
expect(avatar).toMatch(/accessibilityState=\{\{ checked: selected \}\}/);
|
||||
expect(avatar).toMatch(/<SenderAvatar/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
// "Plain Text Only" reads "Compose and read in plain text only", but only the
|
||||
// composer honoured it: the reader always showed the HTML. The webmail reads
|
||||
// HTML mail as plain text by default or per message (1e929b0a9). The
|
||||
// conversion itself is tested in lib/__tests__/html-to-readable-text.test.ts;
|
||||
// this checks the reader is wired to it.
|
||||
|
||||
const src = (p: string) => fs.readFileSync(path.resolve(__dirname, '..', '..', p), 'utf8');
|
||||
|
||||
describe('plain-text reading', () => {
|
||||
it('renders the plain-text reading when the setting or the per-message switch is on', () => {
|
||||
const body = src('components/EmailBodyView.tsx');
|
||||
expect(body).toMatch(/useSettingsStore\(\(s\) => s\.plainTextMode\)/);
|
||||
expect(body).toMatch(/const plainView = plainViewOverride \?\? plainTextMode;/);
|
||||
expect(body).toMatch(/plainTextReading\(\{ html: formattedHtml, text: picked\.text \}\)/);
|
||||
// The document is built from the text, not the HTML, while it is on.
|
||||
expect(body).toMatch(/const rawHtml = plainText !== null \? null : formattedHtml;/);
|
||||
expect(body).toMatch(/email_viewer\.shown_as_plain_text/);
|
||||
});
|
||||
|
||||
it('offers "Show as plain text" per message in the More sheet and passes it to every card', () => {
|
||||
const screen = src('screens/EmailThreadScreen.tsx');
|
||||
expect(screen).toMatch(/email_viewer\.show_as_plain_text/);
|
||||
expect(screen).toMatch(/plainView=\{plainOverrides\[m\.id\] \?\? null\}/);
|
||||
expect(src('components/email/MessageContent.tsx')).toMatch(/plainView=\{plainView\}/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
// List chips showed a typed, coloured icon per file; the reader's chips a
|
||||
// grey paperclip for everything (webmail a78d03a18 uses the list's icons in
|
||||
// the reader too).
|
||||
|
||||
const src = (p: string) => fs.readFileSync(path.resolve(__dirname, '..', p), 'utf8');
|
||||
|
||||
describe('reader attachment chip icons', () => {
|
||||
it('draws the same typed icon and colour as the list chips', () => {
|
||||
const reader = src('email/AttachmentChips.tsx');
|
||||
expect(reader).not.toMatch(/<Paperclip /);
|
||||
expect(reader).toMatch(/const kind = attachmentKind\(item\.type, item\.name\);/);
|
||||
expect(reader).toMatch(/<Icon size=\{14\} color=\{attachmentIconColor\(kind, c\)\} \/>/);
|
||||
const list = src('email/ListAttachmentChips.tsx');
|
||||
expect(list).toMatch(/ATTACHMENT_ICONS\[kind\]/);
|
||||
expect(list).toMatch(/attachmentIconColor\(kind, c\)/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
vi.mock('../../api/jmap-client', () => ({
|
||||
jmapClient: { accountId: 'acc-1', request: vi.fn(), getMaxObjectsInGet: vi.fn(() => 50) },
|
||||
}));
|
||||
|
||||
import { avatarContent } from '../../lib/avatar-utils';
|
||||
import { LIST_AUTH_RESULTS_PROPERTY, senderWarningOf } from '../../lib/email-headers';
|
||||
import { EMAIL_FULL_PROPERTIES, EMAIL_LIST_PROPERTIES } from '../../api/email';
|
||||
import type { Email } from '../../api/types';
|
||||
|
||||
// A forged From kept the real sender's favicon or contact photo: the reader
|
||||
// worked out that the sender check failed, but the avatar beside it still
|
||||
// showed the claimed sender's logo, and list rows had no auth data at all
|
||||
// (webmail 605a3983b, #1439). Now the avatar shows a warning shield.
|
||||
|
||||
const FORGED = 'mx.example.org; spf=fail smtp.mailfrom=evil.example; dkim=none; dmarc=fail header.from=bank.example';
|
||||
const GOOD = 'mx.example.org; spf=pass smtp.mailfrom=bank.example; dkim=pass header.d=bank.example; dmarc=pass header.from=bank.example';
|
||||
|
||||
function row(auth: string[] | undefined): Email {
|
||||
return {
|
||||
id: 'm1', threadId: 't1', mailboxIds: {}, keywords: {}, size: 1, receivedAt: '2026-10-31T10:00:00Z',
|
||||
hasAttachment: false, from: [{ name: 'Bank', email: 'ceo@bank.example' }],
|
||||
...(auth ? { [LIST_AUTH_RESULTS_PROPERTY]: auth } : {}),
|
||||
} as Email;
|
||||
}
|
||||
|
||||
describe('sender warning in avatars (bulwarkmail/webmail#1439)', () => {
|
||||
it('asks for the Authentication-Results headers in the list fetch, not in the full one', () => {
|
||||
expect(EMAIL_LIST_PROPERTIES).toContain('header:Authentication-Results:all');
|
||||
// The full fetch has `headers`, which carry the same header.
|
||||
expect(EMAIL_FULL_PROPERTIES).not.toContain('header:Authentication-Results:all');
|
||||
expect(EMAIL_FULL_PROPERTIES).toContain('headers');
|
||||
});
|
||||
|
||||
it('judges a list row from the listed header and a full message from its headers', () => {
|
||||
expect(senderWarningOf(row([FORGED]))?.status).toBe('failed');
|
||||
expect(senderWarningOf(row([GOOD]))).toBeNull();
|
||||
expect(senderWarningOf(row(undefined))).toBeNull();
|
||||
expect(senderWarningOf(row([]))).toBeNull();
|
||||
const full = { ...row(undefined), headers: [{ name: 'Authentication-Results', value: ` ${FORGED}` }] };
|
||||
expect(senderWarningOf(full)?.status).toBe('failed');
|
||||
});
|
||||
|
||||
it('shows the shield before a contact photo or favicon', () => {
|
||||
expect(avatarContent({ senderWarning: 'Sender check failed', hasPhoto: true, showFavicon: true })).toBe('warning');
|
||||
expect(avatarContent({ senderWarning: undefined, hasPhoto: true, showFavicon: true })).toBe('photo');
|
||||
expect(avatarContent({ senderWarning: null, hasPhoto: false, showFavicon: true })).toBe('favicon');
|
||||
expect(avatarContent({ hasPhoto: false, showFavicon: false })).toBe('initials');
|
||||
});
|
||||
|
||||
it('passes the warning to the avatar in the reader, collapsed conversation cards and list rows', () => {
|
||||
const src = (p: string) => fs.readFileSync(path.resolve(__dirname, '..', '..', p), 'utf8');
|
||||
expect(src('components/email/MessageHeader.tsx')).toMatch(/senderWarning=\{verificationText\?\.label\}/);
|
||||
expect(src('components/email/ThreadMessageCard.tsx')).toMatch(/senderWarning=\{senderWarning\}/);
|
||||
expect(src('screens/EmailListScreen.tsx')).toMatch(/senderWarning=\{senderWarning\}/);
|
||||
expect(src('screens/UnifiedInboxScreen.tsx')).toMatch(/senderWarning=\{verification \?/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
// Screen readers couldn't tell which folder was open, and the unread count
|
||||
// was a button read as "Show unread" with no number (webmail 7c282f1f4).
|
||||
|
||||
describe('folder list names', () => {
|
||||
const src = fs.readFileSync(path.resolve(__dirname, '../SidebarDrawer.tsx'), 'utf8');
|
||||
|
||||
it('marks the open folder as selected and says its unread count with its name', () => {
|
||||
expect(src).toMatch(/accessibilityState=\{isAccountHeader \? undefined : \{ selected: isSelected \}\}/);
|
||||
expect(src).toMatch(/t\('shell\.name_unread', '\{name\}, \{count\} unread', \{ name: label, count: unread \}\)/);
|
||||
expect(src).toMatch(/accessibilityLabel=\{accessibleName\}/);
|
||||
});
|
||||
|
||||
it('names the unread filter button with its count', () => {
|
||||
expect(src).not.toMatch(/sidebar\.show_unread/);
|
||||
expect(src).toMatch(/t\('sidebar\.unread_filter', 'Show unread only \(\{count\}\)', \{ count: unread \}\)/);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, ActivityIndicator, Alert } from 'react-native';
|
||||
import { Paperclip, Download, Eye, ExternalLink, Save, Share2, FolderArchive, FolderOpen } from '../icons';
|
||||
import { Download, Eye, ExternalLink, Save, Share2, FolderArchive, FolderOpen } from '../icons';
|
||||
import type { Attachment, Email } from '../../api/types';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
getAttachmentDisplayName, visibleAttachments, formatSize,
|
||||
} from '../../lib/attachment-display';
|
||||
import { emailExportFilename } from '../../lib/download-filename';
|
||||
import { attachmentKind } from '../../lib/list-attachments';
|
||||
import { ATTACHMENT_ICONS, attachmentIconColor } from './attachment-icons';
|
||||
import { ActionSheet, type ActionSheetItem } from './ActionSheet';
|
||||
import { AttachmentPreviewModal } from './AttachmentPreviewModal';
|
||||
import type { ExtractedAttachment } from './use-body-override';
|
||||
@@ -232,6 +234,9 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne
|
||||
<View style={styles.row}>
|
||||
{visible.map((item) => {
|
||||
const busy = busyKey === item.key;
|
||||
// Typed and coloured like the list's chips (webmail a78d03a18).
|
||||
const kind = attachmentKind(item.type, item.name);
|
||||
const Icon = ATTACHMENT_ICONS[kind];
|
||||
return (
|
||||
<Pressable
|
||||
key={item.key}
|
||||
@@ -241,7 +246,7 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne
|
||||
disabled={!!busyKey}
|
||||
accessibilityLabel={item.name}
|
||||
>
|
||||
{busy ? <ActivityIndicator size="small" color={c.textMuted} /> : <Paperclip size={14} color={c.textMuted} />}
|
||||
{busy ? <ActivityIndicator size="small" color={c.textMuted} /> : <Icon size={14} color={attachmentIconColor(kind, c)} />}
|
||||
<Text style={styles.name} numberOfLines={1}>{item.name}</Text>
|
||||
<Text style={styles.size}>{formatSize(item.size)}</Text>
|
||||
<Download size={14} color={c.textMuted} />
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { File as FileIcon, FileArchive, FileImage, FileSpreadsheet, FileText } from '../icons';
|
||||
import type { Attachment, Email, Mailbox } from '../../api/types';
|
||||
import { getEmailAttachments } from '../../api/email';
|
||||
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
@@ -13,8 +12,9 @@ import { accountIdOfRow } from '../../stores/email-store';
|
||||
import { accountScopedId } from '../../lib/thread-utils';
|
||||
import {
|
||||
attachmentKind, realAttachments, requestListAttachments, shortAttachmentName,
|
||||
type AttachmentKind, type LoadListAttachments,
|
||||
type LoadListAttachments,
|
||||
} from '../../lib/list-attachments';
|
||||
import { ATTACHMENT_ICONS, attachmentIconColor } from './attachment-icons';
|
||||
import { getAttachmentDisplayName } from '../../lib/attachment-display';
|
||||
import { attachmentSaveName, downloadAttachment } from '../../lib/email-export';
|
||||
import { AttachmentPreviewModal } from './AttachmentPreviewModal';
|
||||
@@ -23,31 +23,11 @@ import { useFilePreview } from './use-file-preview';
|
||||
/** How many chips a row shows before collapsing the rest into a count. */
|
||||
const MAX_CHIPS = 2;
|
||||
|
||||
const ICONS: Record<AttachmentKind, typeof FileIcon> = {
|
||||
image: FileImage,
|
||||
pdf: FileText,
|
||||
sheet: FileSpreadsheet,
|
||||
archive: FileArchive,
|
||||
document: FileText,
|
||||
other: FileIcon,
|
||||
};
|
||||
|
||||
function iconColor(kind: AttachmentKind, c: ThemePalette): string {
|
||||
switch (kind) {
|
||||
case 'image': return c.tags.purple.dot;
|
||||
case 'pdf': return c.tags.red.dot;
|
||||
case 'sheet': return c.tags.green.dot;
|
||||
case 'archive': return c.tags.amber.dot;
|
||||
case 'document': return c.tags.blue.dot;
|
||||
default: return c.textMuted;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The row's attachment parts: the ones the email already carries (a message
|
||||
* opened before), else loaded lazily when it has a paperclip.
|
||||
*/
|
||||
function useListAttachments(email: Email, load?: LoadListAttachments): Attachment[] | undefined {
|
||||
export function useListAttachments(email: Email, load?: LoadListAttachments): Attachment[] | undefined {
|
||||
const [loaded, setLoaded] = React.useState<{ key: string; attachments: Attachment[] } | null>(null);
|
||||
const needsLoad = !!load && !!email.hasAttachment && !email.attachments;
|
||||
// Rows of a list spanning accounts can share an id (#1082).
|
||||
@@ -67,7 +47,10 @@ function useListAttachments(email: Email, load?: LoadListAttachments): Attachmen
|
||||
|
||||
interface ChipsProps {
|
||||
email: Email;
|
||||
load?: LoadListAttachments;
|
||||
/** The row's parts (useListAttachments); undefined while they load. */
|
||||
attachments: Attachment[] | undefined;
|
||||
/** The parts are being loaded: hold the chips' line so the row doesn't grow. */
|
||||
pending?: boolean;
|
||||
onOpen: (email: Email, attachment: Attachment) => void;
|
||||
/** Selection mode: taps belong to the row. */
|
||||
disabled?: boolean;
|
||||
@@ -79,13 +62,23 @@ interface ChipsProps {
|
||||
* that file directly.
|
||||
*/
|
||||
export const ListAttachmentChips = React.memo(function ListAttachmentChips({
|
||||
email, load, onOpen, disabled,
|
||||
email, attachments, pending, onOpen, disabled,
|
||||
}: ChipsProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const dyn = useTypography();
|
||||
const attachments = useListAttachments(email, load);
|
||||
const real = React.useMemo(() => realAttachments(attachments), [attachments]);
|
||||
// The line the chips will take, held while they load (webmail 7b25d3a95):
|
||||
// the row grew under the finger when they arrived.
|
||||
if (pending && attachments === undefined) {
|
||||
return (
|
||||
<View style={styles.row} accessibilityElementsHidden importantForAccessibility="no-hide-descendants" testID="list-chips-placeholder">
|
||||
<View style={[styles.chip, styles.placeholderChip]}>
|
||||
<Text style={[styles.name, dyn.caption]}> </Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (real.length === 0) return null;
|
||||
|
||||
const shown = real.slice(0, MAX_CHIPS);
|
||||
@@ -94,7 +87,7 @@ export const ListAttachmentChips = React.memo(function ListAttachmentChips({
|
||||
<View style={styles.row}>
|
||||
{shown.map((a, i) => {
|
||||
const kind = attachmentKind(a.type, a.name);
|
||||
const Icon = ICONS[kind];
|
||||
const Icon = ATTACHMENT_ICONS[kind];
|
||||
return (
|
||||
<Pressable
|
||||
key={`${a.blobId}-${i}`}
|
||||
@@ -106,7 +99,7 @@ export const ListAttachmentChips = React.memo(function ListAttachmentChips({
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={a.name}
|
||||
>
|
||||
<Icon size={12} color={iconColor(kind, c)} />
|
||||
<Icon size={12} color={attachmentIconColor(kind, c)} />
|
||||
<Text style={[styles.name, dyn.caption]} numberOfLines={1}>{shortAttachmentName(a.name ?? '')}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
@@ -234,6 +227,8 @@ function makeStyles(c: ThemePalette) {
|
||||
backgroundColor: c.surface,
|
||||
},
|
||||
chipPressed: { backgroundColor: c.surfaceHover },
|
||||
// Empty space of a chip's height; a failed load leaves no grey stub behind.
|
||||
placeholderChip: { width: 96, opacity: 0 },
|
||||
name: { ...typography.caption, color: c.textSecondary, flexShrink: 1 },
|
||||
more: {
|
||||
paddingHorizontal: 6,
|
||||
|
||||
@@ -22,6 +22,10 @@ export interface MessageContentProps {
|
||||
/** On screen rather than pre-rendered by the pager (gates auto-sent read receipts). */
|
||||
active: boolean;
|
||||
themeOverride?: 'light' | 'dark' | null;
|
||||
/** Per-message "Show as plain text"; null follows the "Plain Text Only" setting. */
|
||||
plainView?: boolean | null;
|
||||
/** Turn the per-message plain-text view off ("Show formatted"). */
|
||||
onShowFormatted?: (email: Email) => void;
|
||||
onSwipe?: (direction: 'prev' | 'next') => void;
|
||||
onZoomChange?: (zoom: { pinching: boolean; zoomed: boolean }) => void;
|
||||
onToggleStar?: (email: Email) => void;
|
||||
@@ -52,8 +56,8 @@ export interface MessageContentProps {
|
||||
* cards.
|
||||
*/
|
||||
export function MessageContent({
|
||||
email, jmapAccountId, identities, currentMailboxRole, active, themeOverride, onSwipe, onZoomChange,
|
||||
onToggleStar, onAddressPress, onEmailPatched, compact, deferBody, onBodySettled, fill,
|
||||
email, jmapAccountId, identities, currentMailboxRole, active, themeOverride, plainView, onShowFormatted,
|
||||
onSwipe, onZoomChange, onToggleStar, onAddressPress, onEmailPatched, compact, deferBody, onBodySettled, fill,
|
||||
}: MessageContentProps) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
@@ -124,6 +128,8 @@ export function MessageContent({
|
||||
onSwipe={onSwipe}
|
||||
onZoomChange={onZoomChange}
|
||||
themeOverride={themeOverride}
|
||||
plainView={plainView}
|
||||
onShowFormatted={onShowFormatted ? () => onShowFormatted(email) : undefined}
|
||||
bodyOverride={unwrap.override}
|
||||
onSettled={onBodySettled}
|
||||
fill={fill}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { useAccountKeywordDefs } from '../../stores/keywords-store';
|
||||
import { emailDisplayDate, formatHeaderDate, formatHeaderTime, formatFullDateTime } from '../../lib/email-date';
|
||||
import {
|
||||
deriveHeaderInfo, deliveryDeltaMs, formatDelta, isAuthenticationSpoofed, findReceivingIdentity,
|
||||
deriveHeaderInfo, deliveryDeltaMs, formatDelta, isAuthenticationSpoofed, findReceivingIdentity, viaIdentityNameShown,
|
||||
getSenderVerification, describeSenderVerification,
|
||||
type AuthenticationResults, type EmailHeaderInfo,
|
||||
} from '../../lib/email-headers';
|
||||
@@ -138,7 +138,7 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA
|
||||
if (spoofed || identities.length === 0) return null;
|
||||
const fromEmail = from?.email?.toLowerCase();
|
||||
const sentAs = fromEmail ? identities.find((i) => i.email?.toLowerCase() === fromEmail) : undefined;
|
||||
if (sentAs) return { identity: sentAs, direction: 'from' as const };
|
||||
if (sentAs) return viaIdentityNameShown(sentAs, from) ? { identity: sentAs, direction: 'from' as const } : null;
|
||||
const received = findReceivingIdentity(identities, email);
|
||||
if (received && identities.length > 1 && received.id !== identities[0].id) {
|
||||
return { identity: received, direction: 'to' as const };
|
||||
@@ -172,7 +172,12 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={from?.name || from?.email || t('email_viewer.unknown_sender', 'Unknown')}
|
||||
>
|
||||
<SenderAvatar name={from?.name} email={from?.email} size={compact ? componentSizes.avatarSm : componentSizes.avatarMd} />
|
||||
<SenderAvatar
|
||||
name={from?.name}
|
||||
email={from?.email}
|
||||
size={compact ? componentSizes.avatarSm : componentSizes.avatarMd}
|
||||
senderWarning={verificationText?.label}
|
||||
/>
|
||||
</Pressable>
|
||||
<View style={styles.info}>
|
||||
<Pressable onPress={from ? () => onAddressPress(from) : undefined}>
|
||||
@@ -253,7 +258,7 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA
|
||||
<View style={[styles.badge, { backgroundColor: c.primaryBg }]}>
|
||||
<Text style={[styles.badgeText, { color: c.primary }]}>
|
||||
{viaIdentity.direction === 'from'
|
||||
? t('identities.badge.identity_short', 'via {name}', { name: viaIdentity.identity.email })
|
||||
? t('identities.badge.identity_short', 'via {name}', { name: viaIdentity.identity.name })
|
||||
: t('email_viewer.received_via', 'to {name}', { name: viaIdentity.identity.email })}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -10,6 +10,8 @@ import SenderAvatar from '../SenderAvatar';
|
||||
import { MessageContent, type MessageContentProps } from './MessageContent';
|
||||
import { emailDisplayDate, formatHeaderDate, formatHeaderTime } from '../../lib/email-date';
|
||||
import { singleLine } from '../../lib/single-line';
|
||||
import { cleanPreview } from '../../lib/preview-text';
|
||||
import { useSenderWarning } from './use-sender-warning';
|
||||
|
||||
interface Props extends MessageContentProps {
|
||||
expanded: boolean;
|
||||
@@ -43,11 +45,12 @@ export function ThreadMessageCard({
|
||||
const unread = !email.keywords?.$seen;
|
||||
const starred = !!email.keywords?.$flagged;
|
||||
const date = emailDisplayDate(email);
|
||||
const senderWarning = useSenderWarning(expanded ? undefined : email);
|
||||
|
||||
if (!expanded) {
|
||||
return (
|
||||
<Pressable onPress={onToggleExpanded} style={({ pressed }) => [styles.collapsed, pressed && styles.pressed]}>
|
||||
<SenderAvatar name={from?.name} email={from?.email} size={componentSizes.avatarSm} />
|
||||
<SenderAvatar name={from?.name} email={from?.email} size={componentSizes.avatarSm} senderWarning={senderWarning} />
|
||||
<View style={styles.collapsedInfo}>
|
||||
<View style={styles.collapsedTop}>
|
||||
<Text style={[styles.collapsedName, unread && styles.unread]} numberOfLines={1}>
|
||||
@@ -56,7 +59,7 @@ export function ThreadMessageCard({
|
||||
{email.hasAttachment && <Paperclip size={12} color={c.textMuted} />}
|
||||
<Text style={styles.collapsedDate}>{formatHeaderDate(date, locale)} {formatHeaderTime(date, timeFormat, locale)}</Text>
|
||||
</View>
|
||||
<Text style={styles.collapsedPreview} numberOfLines={1}>{singleLine(email.preview)}</Text>
|
||||
<Text style={styles.collapsedPreview} numberOfLines={1}>{singleLine(cleanPreview(email.preview))}</Text>
|
||||
</View>
|
||||
{starred && <Star size={14} color={c.starred} fill={c.starred} />}
|
||||
</Pressable>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { File as FileIcon, FileArchive, FileImage, FileSpreadsheet, FileText } from '../icons';
|
||||
import type { ThemePalette } from '../../theme/tokens';
|
||||
import type { AttachmentKind } from '../../lib/list-attachments';
|
||||
|
||||
/**
|
||||
* The icon of an attachment chip by file type, the same in the list and the
|
||||
* reader (webmail components/email/attachment-chips.tsx, a78d03a18).
|
||||
*/
|
||||
export const ATTACHMENT_ICONS: Record<AttachmentKind, typeof FileIcon> = {
|
||||
image: FileImage,
|
||||
pdf: FileText,
|
||||
sheet: FileSpreadsheet,
|
||||
archive: FileArchive,
|
||||
document: FileText,
|
||||
other: FileIcon,
|
||||
};
|
||||
|
||||
/** The icon's colour by file type. */
|
||||
export function attachmentIconColor(kind: AttachmentKind, c: ThemePalette): string {
|
||||
switch (kind) {
|
||||
case 'image': return c.tags.purple.dot;
|
||||
case 'pdf': return c.tags.red.dot;
|
||||
case 'sheet': return c.tags.green.dot;
|
||||
case 'archive': return c.tags.amber.dot;
|
||||
case 'document': return c.tags.blue.dot;
|
||||
default: return c.textMuted;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
import type { Email } from '../../api/types';
|
||||
import { describeSenderVerification, senderWarningOf } from '../../lib/email-headers';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
|
||||
/**
|
||||
* The sender-check label of a message whose From the receiving server's
|
||||
* checks don't back, the same verdict as the notice in the reader. Avatars
|
||||
* show it instead of the claimed sender's favicon or photo (webmail
|
||||
* use-sender-warning.ts, #1439). Undefined when the check passed, there are
|
||||
* no results to judge by, or no message is given (Sent and Drafts rows show
|
||||
* the recipient, whose avatar the check says nothing about).
|
||||
*/
|
||||
export function useSenderWarning(email: Pick<Email, 'headers' | 'from'> | undefined): string | undefined {
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const verification = React.useMemo(() => (email ? senderWarningOf(email) : null), [email]);
|
||||
return verification ? describeSenderVerification(verification, t).label : undefined;
|
||||
}
|
||||
Reference in New Issue
Block a user