Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/unit-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ jobs:

strategy:
matrix:
node-version: [20]
node-version: [22]
fail-fast: true

steps:
Expand Down
4 changes: 4 additions & 0 deletions assets/lang/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,8 @@ const translations = {
},
openMailboxes: 'Open mailboxes',
unableToDecryptPreview: 'This message could not be decrypted',
showRemoteImages: 'Show images',
remoteImagesBlocked: 'Images in this message are blocked',
},
compose_email: {
title: 'New message',
Expand Down Expand Up @@ -1433,6 +1435,8 @@ const translations = {
},
openMailboxes: 'Abrir bandejas',
unableToDecryptPreview: 'No se ha podido descifrar este mensaje',
showRemoteImages: 'Mostrar imágenes',
remoteImagesBlocked: 'Las imágenes de este mensaje están bloqueadas',
},
compose_email: {
title: 'Nuevo mensaje',
Expand Down
6 changes: 0 additions & 6 deletions ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1609,8 +1609,6 @@ PODS:
- Yoga
- react-native-randombytes (3.6.2):
- React-Core
- react-native-render-html (6.3.4):
- React-Core
- react-native-safe-area-context (5.6.2):
- hermes-engine
- RCTRequired
Expand Down Expand Up @@ -2549,7 +2547,6 @@ DEPENDENCIES:
- react-native-pdf (from `../node_modules/react-native-pdf`)
- react-native-pdf-thumbnail (from `../node_modules/react-native-pdf-thumbnail`)
- react-native-randombytes (from `../node_modules/react-native-randombytes`)
- react-native-render-html (from `../node_modules/react-native-render-html`)
- react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`)
- react-native-video (from `../node_modules/react-native-video`)
- react-native-webview (from `../node_modules/react-native-webview`)
Expand Down Expand Up @@ -2772,8 +2769,6 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-pdf-thumbnail"
react-native-randombytes:
:path: "../node_modules/react-native-randombytes"
react-native-render-html:
:path: "../node_modules/react-native-render-html"
react-native-safe-area-context:
:path: "../node_modules/react-native-safe-area-context"
react-native-video:
Expand Down Expand Up @@ -2955,7 +2950,6 @@ SPEC CHECKSUMS:
react-native-pdf: 70836b3b48595c7f85a3aa2b5d122b175c455733
react-native-pdf-thumbnail: cc2076c2d871e43cf267611975db8ed3682cc65e
react-native-randombytes: 4eeddad82d1fd57adda1cd5beffe67d041c9d6de
react-native-render-html: 5afc4751f1a98621b3009432ef84c47019dcb2bd
react-native-safe-area-context: 37e680fc4cace3c0030ee46e8987d24f5d3bdab2
react-native-video: f5982e21efab0dc356d92541a8a9e19581307f58
react-native-webview: b29007f4723bca10872028067b07abacfa1cb35a
Expand Down
7 changes: 7 additions & 0 deletions jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ const untranspiledModulePatterns = [
'p-limit',
'yocto-queue',
'mime',
'sanitize-html',
'htmlparser2',
'domhandler',
'domutils',
'dom-serializer',
'domelementtype',
'entities',
];

const config: Config.InitialOptions = {
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"@shopify/flash-list": "2.0.2",
"@testing-library/react-hooks": "^8.0.1",
"@types/luxon": "^3.0.1",
"@types/sanitize-html": "^2.16.1",
"@types/unorm": "^1.3.28",
"async": "^3.2.6",
"axios": "^1.18.1",
Expand Down Expand Up @@ -113,7 +114,6 @@
"react-native-progress": "^5.0.0",
"react-native-randombytes": "^3.6.2",
"react-native-reanimated": "~4.1.1",
"react-native-render-html": "6.3.4",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-share": "^7.3.3",
Expand All @@ -132,6 +132,7 @@
"redux": "^4.0.5",
"reflect-metadata": "^0.2.2",
"regenerator-runtime": "^0.14.1",
"sanitize-html": "^2.17.7",
"socket.io-client": "^4.8.3",
"stream-browserify": "^3.0.0",
"tailwind-rn": "^4.2.0",
Expand Down Expand Up @@ -179,7 +180,7 @@
"typescript": "~5.9.2"
},
"engines": {
"node": ">=20"
"node": ">=22.12.0"
},
"resolutions": {
"pbkdf2": "^3.1.3",
Expand Down
115 changes: 115 additions & 0 deletions src/screens/mail/EmailDetailScreen/EmailBody.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { EmailResponse } from '@internxt/sdk/dist/mail/types';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Linking, TouchableOpacity, View } from 'react-native';
import { WebView } from 'react-native-webview';
import type { ShouldStartLoadRequest, WebViewMessageEvent } from 'react-native-webview/lib/WebViewTypes';
import { useTailwind } from 'tailwind-rn';

import { logger } from '@internxt-mobile/services/common/logger/logger.service';
import strings from '../../../../assets/lang/strings';
import AppText from '../../../components/AppText';
import useGetColor from '../../../hooks/useColor';
import {
buildEmailBodyHtml,
hasRemoteImages,
type EmailBodySource,
} from '../../../services/mail/emailBody/emailBodyContent';
import { buildEmailDocument } from '../../../services/mail/emailBody/emailDocument';
import { useEmailBodyHeight } from './hooks/useEmailBodyHeight';

const OPENABLE_SCHEMES = new Set(['http:', 'https:', 'mailto:', 'tel:']);
const GENERIC_USER_AGENT = 'Mozilla/5.0 (Mobile)';

export const EmailBody = ({ message, bodySource }: { message: EmailResponse; bodySource: EmailBodySource }) => {
const tailwind = useTailwind();
const getColor = useGetColor();
const [areRemoteImagesAllowed, setAreRemoteImagesAllowed] = useState(false);
const isFirstLoad = useRef(true);
const backgroundColor = getColor('bg-surface');
const textColor = getColor('text-gray-100');

const body = useMemo(() => {
const html = buildEmailBodyHtml(message, bodySource);
return { html, hasImagesHostedElsewhere: hasRemoteImages(html) };
}, [message, bodySource]);

const emailDocument = useMemo(
() => buildEmailDocument(body.html, { backgroundColor, textColor, areRemoteImagesAllowed }),
[body.html, backgroundColor, textColor, areRemoteImagesAllowed],
);

const { height, heightReporterScript, onHeightReported, onMeasureFailed } = useEmailBodyHeight(emailDocument);

useEffect(() => {
isFirstLoad.current = true;
}, [emailDocument]);

const onMessageFromBody = (event: WebViewMessageEvent) => {
onHeightReported(event.nativeEvent.data);
};

const onNavigationRequested = (request: ShouldStartLoadRequest) => {
if (isFirstLoad.current || request.url === 'about:blank') {
isFirstLoad.current = false;
return true;
}

const scheme = request.url.slice(0, request.url.indexOf(':') + 1).toLowerCase();
if (OPENABLE_SCHEMES.has(scheme)) {
Linking.openURL(request.url).catch((error) => logger.error('Could not open a link from an email:', error));
}
return false;
};

const areImagesBlocked = !areRemoteImagesAllowed && body.hasImagesHostedElsewhere;

return (
<View>
{areImagesBlocked && (
<View
style={[
tailwind('mb-3 flex-row items-center justify-between rounded-lg px-3 py-2'),
{ backgroundColor: getColor('bg-gray-5') },
]}
>
<AppText numberOfLines={2} style={[tailwind('flex-1 pr-3 text-xs'), { color: getColor('text-gray-60') }]}>
{strings.screens.mail.remoteImagesBlocked}
</AppText>
<TouchableOpacity accessibilityRole="button" onPress={() => setAreRemoteImagesAllowed(true)}>
<AppText style={[tailwind('text-sm'), { color: getColor('text-primary') }]}>
{strings.screens.mail.showRemoteImages}
</AppText>
</TouchableOpacity>
</View>
)}
<WebView
originWhitelist={['*']}
source={{ html: emailDocument }}
style={{ height: height || 1, opacity: height ? 1 : 0, backgroundColor: 'transparent' }}
scrollEnabled={false}
javaScriptEnabled
injectedJavaScript={heightReporterScript}
onMessage={onMessageFromBody}
onShouldStartLoadWithRequest={onNavigationRequested}
onError={onMeasureFailed}
onHttpError={onMeasureFailed}
onRenderProcessGone={onMeasureFailed}
onContentProcessDidTerminate={onMeasureFailed}
userAgent={GENERIC_USER_AGENT}
setSupportMultipleWindows={false}
javaScriptCanOpenWindowsAutomatically={false}
thirdPartyCookiesEnabled={false}
sharedCookiesEnabled={false}
incognito
cacheEnabled={false}
domStorageEnabled={false}
allowFileAccess={false}
allowFileAccessFromFileURLs={false}
allowUniversalAccessFromFileURLs={false}
mixedContentMode="never"
allowsLinkPreview={false}
showsVerticalScrollIndicator={false}
/>
</View>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { act, renderHook } from '@testing-library/react-hooks';

import { useEmailBodyHeight } from './useEmailBodyHeight';

jest.mock('@internxt-mobile/services/common/logger/logger.service', () => ({
logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() },
}));

const A_DOCUMENT = '<html><body>Hello there</body></html>';
const ANOTHER_DOCUMENT = '<html><body>Something else</body></html>';

describe('Measuring how tall a message needs to be', () => {
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());

test('when the message has not been measured yet, then it has no height', () => {
const { result } = renderHook(() => useEmailBodyHeight(A_DOCUMENT));

expect(result.current.height).toBe(0);
});

test('when the message reports how tall it is, then that is the height it gets', () => {
const { result } = renderHook(() => useEmailBodyHeight(A_DOCUMENT));

act(() => result.current.onHeightReported('640'));

expect(result.current.height).toBe(640);
});

test('when the message claims to be absurdly tall, then the height is capped', () => {
const { result } = renderHook(() => useEmailBodyHeight(A_DOCUMENT));

act(() => result.current.onHeightReported('999999'));

expect(result.current.height).toBeLessThan(999999);
expect(result.current.height).toBeGreaterThan(0);
});

test('when what the message reports is not a number, then it is ignored', () => {
const { result } = renderHook(() => useEmailBodyHeight(A_DOCUMENT));

act(() => result.current.onHeightReported('640'));
act(() => result.current.onHeightReported('not a height'));

expect(result.current.height).toBe(640);
});

test('when the message reports no height at all, then it is ignored', () => {
const { result } = renderHook(() => useEmailBodyHeight(A_DOCUMENT));

act(() => result.current.onHeightReported('640'));
act(() => result.current.onHeightReported('0'));

expect(result.current.height).toBe(640);
});

test('when the message never reports its height, then it still gets one so it is not left invisible', () => {
const { result } = renderHook(() => useEmailBodyHeight(A_DOCUMENT));

act(() => jest.runAllTimers());

expect(result.current.height).toBeGreaterThan(0);
});

test('when the message reported its height in time, then the fallback does not replace it', () => {
const { result } = renderHook(() => useEmailBodyHeight(A_DOCUMENT));

act(() => result.current.onHeightReported('640'));
act(() => jest.runAllTimers());

expect(result.current.height).toBe(640);
});

test('when the message cannot be displayed at all, then it still gets a height', () => {
const { result } = renderHook(() => useEmailBodyHeight(A_DOCUMENT));

act(() => result.current.onMeasureFailed(new Error('the page could not be loaded')));

expect(result.current.height).toBeGreaterThan(0);
});

test('when another message is displayed, then it is measured again instead of keeping the previous height', () => {
const { result, rerender } = renderHook(({ document }) => useEmailBodyHeight(document), {
initialProps: { document: A_DOCUMENT },
});
act(() => result.current.onHeightReported('640'));

rerender({ document: ANOTHER_DOCUMENT });

expect(result.current.height).toBe(0);
});
});
66 changes: 66 additions & 0 deletions src/screens/mail/EmailDetailScreen/hooks/useEmailBodyHeight.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { useEffect, useState } from 'react';

import { logger } from '@internxt-mobile/services/common/logger/logger.service';

const MAX_BODY_HEIGHT = 12000;
const MIN_REPORTED_HEIGHT_CHANGE = 2;
const FALLBACK_BODY_HEIGHT = 320;
const MEASURE_TIMEOUT_MS = 4000;

const HEIGHT_REPORTER_SCRIPT = `
(function () {
var lastReported = 0;
var report = function () {
var height = Math.max(document.documentElement.scrollHeight, document.body.scrollHeight);
if (Math.abs(height - lastReported) < ${MIN_REPORTED_HEIGHT_CHANGE}) {
return;
}
lastReported = height;
window.ReactNativeWebView.postMessage(String(height));
};
report();
if (window.ResizeObserver) new ResizeObserver(report).observe(document.body);
})();
true;
`;

export type MeasuredEmailBodyHeight = {
height: number;
heightReporterScript: string;
onHeightReported: (reportedHeight: string) => void;
onMeasureFailed: (reason: unknown) => void;
};

/**
* Keeps the height a message needs to be displayed in full, which only the document itself knows.
* The height starts unknown and falls back to a height that shows something when the document
* cannot report one, so that a message is never left invisible.
*
* @param emailDocument the document handed to the web view; a new one starts the measuring again
* @returns the height measured so far, the script that reports it, and the handlers for what the
* web view sends back
*/
export const useEmailBodyHeight = (emailDocument: string): MeasuredEmailBodyHeight => {
const [height, setHeight] = useState(0);

useEffect(() => {
setHeight(0);
const timeout = setTimeout(() => setHeight((current) => current || FALLBACK_BODY_HEIGHT), MEASURE_TIMEOUT_MS);
return () => clearTimeout(timeout);
}, [emailDocument]);

const onHeightReported = (reportedHeight: string) => {
const measuredHeight = Number(reportedHeight);
if (!Number.isFinite(measuredHeight) || measuredHeight <= 0) {
return;
}
setHeight(Math.min(measuredHeight, MAX_BODY_HEIGHT));
};

const onMeasureFailed = (reason: unknown) => {
logger.error('The body of an email could not be displayed:', reason);
setHeight((current) => current || FALLBACK_BODY_HEIGHT);
};

return { height, heightReporterScript: HEIGHT_REPORTER_SCRIPT, onHeightReported, onMeasureFailed };
};
Loading
Loading