fix: fix bugs related to auto-translate and add tests (#5144)

* fix re-render on autoTranslateRoom sub update

* create autoTranslate tests

* create getMessageFromAttachment

* fix autoTranslate null value

* add translateLanguage to context

* fix type

* fix shouldComponentUpdate

* add autoTranslate and autoTranslateLanguage to subscription

* use getMessageFromAttachment instead att.description

* remove dequal

* add tryCatch

* 🙏
This commit is contained in:
Gleidson Daniel Silva 2023-08-14 17:22:46 -03:00 committed by GitHub
parent 0c8b2f565e
commit acbcac29c8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 564 additions and 287 deletions

View File

@ -322,7 +322,7 @@ const MessageActions = React.memo(
const db = database.active; const db = database.active;
await db.write(async () => { await db.write(async () => {
await message.update(m => { await message.update(m => {
m.autoTranslate = !m.autoTranslate; m.autoTranslate = m.autoTranslate !== null ? !m.autoTranslate : false;
m._updatedAt = new Date(); m._updatedAt = new Date();
}); });
}); });
@ -479,7 +479,7 @@ const MessageActions = React.memo(
// Toggle Auto-translate // Toggle Auto-translate
if (room.autoTranslate && message.u && message.u._id !== user.id) { if (room.autoTranslate && message.u && message.u._id !== user.id) {
options.push({ options.push({
title: I18n.t(message.autoTranslate ? 'View_Original' : 'Translate'), title: I18n.t(message.autoTranslate !== false ? 'View_Original' : 'Translate'),
icon: 'language', icon: 'language',
onPress: () => handleToggleTranslation(message) onPress: () => handleToggleTranslation(message)
}); });

View File

@ -13,6 +13,7 @@ import { IAttachment, TGetCustomEmoji } from '../../definitions';
import CollapsibleQuote from './Components/CollapsibleQuote'; import CollapsibleQuote from './Components/CollapsibleQuote';
import openLink from '../../lib/methods/helpers/openLink'; import openLink from '../../lib/methods/helpers/openLink';
import Markdown from '../markdown'; import Markdown from '../markdown';
import { getMessageFromAttachment } from './utils';
export type TElement = { export type TElement = {
type: string; type: string;
@ -56,12 +57,14 @@ const AttachedActions = ({ attachment, getCustomEmoji }: { attachment: IAttachme
const Attachments: React.FC<IMessageAttachments> = React.memo( const Attachments: React.FC<IMessageAttachments> = React.memo(
({ attachments, timeFormat, showAttachment, style, getCustomEmoji, isReply, author }: IMessageAttachments) => { ({ attachments, timeFormat, showAttachment, style, getCustomEmoji, isReply, author }: IMessageAttachments) => {
const { theme } = useTheme(); const { theme } = useTheme();
const { translateLanguage } = useContext(MessageContext);
if (!attachments || attachments.length === 0) { if (!attachments || attachments.length === 0) {
return null; return null;
} }
const attachmentsElements = attachments.map((file: IAttachment, index: number) => { const attachmentsElements = attachments.map((file: IAttachment, index: number) => {
const msg = getMessageFromAttachment(file, translateLanguage);
if (file && file.image_url) { if (file && file.image_url) {
return ( return (
<Image <Image
@ -72,6 +75,7 @@ const Attachments: React.FC<IMessageAttachments> = React.memo(
style={style} style={style}
isReply={isReply} isReply={isReply}
author={author} author={author}
msg={msg}
/> />
); );
} }
@ -86,6 +90,7 @@ const Attachments: React.FC<IMessageAttachments> = React.memo(
style={style} style={style}
theme={theme} theme={theme}
author={author} author={author}
msg={msg}
/> />
); );
} }
@ -99,6 +104,7 @@ const Attachments: React.FC<IMessageAttachments> = React.memo(
getCustomEmoji={getCustomEmoji} getCustomEmoji={getCustomEmoji}
style={style} style={style}
isReply={isReply} isReply={isReply}
msg={msg}
/> />
); );
} }
@ -112,7 +118,9 @@ const Attachments: React.FC<IMessageAttachments> = React.memo(
); );
} }
return <Reply key={index} index={index} attachment={file} timeFormat={timeFormat} getCustomEmoji={getCustomEmoji} />; return (
<Reply key={index} index={index} attachment={file} timeFormat={timeFormat} getCustomEmoji={getCustomEmoji} msg={msg} />
);
}); });
return <>{attachmentsElements}</>; return <>{attachmentsElements}</>;
}, },

View File

@ -40,6 +40,7 @@ interface IMessageAudioProps {
getCustomEmoji: TGetCustomEmoji; getCustomEmoji: TGetCustomEmoji;
scale?: number; scale?: number;
author?: IUserMessage; author?: IUserMessage;
msg?: string;
} }
interface IMessageAudioState { interface IMessageAudioState {
@ -348,8 +349,7 @@ class MessageAudio extends React.Component<IMessageAudioProps, IMessageAudioStat
render() { render() {
const { loading, paused, currentTime, duration, cached } = this.state; const { loading, paused, currentTime, duration, cached } = this.state;
const { file, getCustomEmoji, theme, scale, isReply, style } = this.props; const { msg, getCustomEmoji, theme, scale, isReply, style } = this.props;
const { description } = file;
// @ts-ignore can't use declare to type this // @ts-ignore can't use declare to type this
const { baseUrl, user } = this.context; const { baseUrl, user } = this.context;
@ -367,7 +367,7 @@ class MessageAudio extends React.Component<IMessageAudioProps, IMessageAudioStat
return ( return (
<> <>
<Markdown <Markdown
msg={description} msg={msg}
style={[isReply && style]} style={[isReply && style]}
username={user.username} username={user.username}
getCustomEmoji={getCustomEmoji} getCustomEmoji={getCustomEmoji}

View File

@ -1,19 +1,18 @@
import React, { useContext, useEffect, useState } from 'react'; import React, { useContext, useEffect, useState } from 'react';
import { StyleProp, TextStyle, View } from 'react-native'; import { StyleProp, TextStyle, View } from 'react-native';
import FastImage from 'react-native-fast-image'; import FastImage from 'react-native-fast-image';
import { dequal } from 'dequal';
import Touchable from './Touchable';
import Markdown from '../markdown';
import styles from './styles';
import MessageContext from './Context';
import { TGetCustomEmoji } from '../../definitions/IEmoji';
import { IAttachment, IUserMessage } from '../../definitions'; import { IAttachment, IUserMessage } from '../../definitions';
import { useTheme } from '../../theme'; import { TGetCustomEmoji } from '../../definitions/IEmoji';
import { formatAttachmentUrl } from '../../lib/methods/helpers/formatAttachmentUrl';
import { cancelDownload, downloadMediaFile, isDownloadActive, getMediaCache } from '../../lib/methods/handleMediaDownload';
import { fetchAutoDownloadEnabled } from '../../lib/methods/autoDownloadPreference'; import { fetchAutoDownloadEnabled } from '../../lib/methods/autoDownloadPreference';
import { cancelDownload, downloadMediaFile, getMediaCache, isDownloadActive } from '../../lib/methods/handleMediaDownload';
import { formatAttachmentUrl } from '../../lib/methods/helpers/formatAttachmentUrl';
import { useTheme } from '../../theme';
import Markdown from '../markdown';
import BlurComponent from './Components/BlurComponent'; import BlurComponent from './Components/BlurComponent';
import MessageContext from './Context';
import Touchable from './Touchable';
import styles from './styles';
interface IMessageButton { interface IMessageButton {
children: React.ReactElement; children: React.ReactElement;
@ -29,6 +28,7 @@ interface IMessageImage {
isReply?: boolean; isReply?: boolean;
getCustomEmoji?: TGetCustomEmoji; getCustomEmoji?: TGetCustomEmoji;
author?: IUserMessage; author?: IUserMessage;
msg?: string;
} }
const Button = React.memo(({ children, onPress, disabled }: IMessageButton) => { const Button = React.memo(({ children, onPress, disabled }: IMessageButton) => {
@ -61,124 +61,124 @@ export const MessageImage = React.memo(({ imgUri, cached, loading }: { imgUri: s
); );
}); });
const ImageContainer = React.memo( const ImageContainer = ({
({ file, imageUrl, showAttachment, getCustomEmoji, style, isReply, author }: IMessageImage) => { file,
const [imageCached, setImageCached] = useState(file); imageUrl,
const [cached, setCached] = useState(false); showAttachment,
const [loading, setLoading] = useState(true); getCustomEmoji,
const { theme } = useTheme(); style,
const { baseUrl, user } = useContext(MessageContext); isReply,
const getUrl = (link?: string) => imageUrl || formatAttachmentUrl(link, user.id, user.token, baseUrl); author,
const img = getUrl(file.image_url); msg
// The param file.title_link is the one that point to image with best quality, however we still need to test the imageUrl }: IMessageImage): React.ReactElement | null => {
// And we cannot be certain whether the file.title_link actually exists. const [imageCached, setImageCached] = useState(file);
const imgUrlToCache = getUrl(imageCached.title_link || imageCached.image_url); const [cached, setCached] = useState(false);
const [loading, setLoading] = useState(true);
const { theme } = useTheme();
const { baseUrl, user } = useContext(MessageContext);
const getUrl = (link?: string) => imageUrl || formatAttachmentUrl(link, user.id, user.token, baseUrl);
const img = getUrl(file.image_url);
// The param file.title_link is the one that point to image with best quality, however we still need to test the imageUrl
// And we cannot be certain whether the file.title_link actually exists.
const imgUrlToCache = getUrl(imageCached.title_link || imageCached.image_url);
useEffect(() => { useEffect(() => {
const handleCache = async () => { const handleCache = async () => {
if (img) { if (img) {
const cachedImageResult = await getMediaCache({ const cachedImageResult = await getMediaCache({
type: 'image',
mimeType: imageCached.image_type,
urlToCache: imgUrlToCache
});
if (cachedImageResult?.exists) {
setImageCached(prev => ({
...prev,
title_link: cachedImageResult?.uri
}));
setLoading(false);
setCached(true);
return;
}
if (isReply) {
setLoading(false);
return;
}
if (isDownloadActive(imgUrlToCache)) {
return;
}
setLoading(false);
await handleAutoDownload();
}
};
handleCache();
}, []);
if (!img) {
return null;
}
const handleAutoDownload = async () => {
const isCurrentUserAuthor = author?._id === user.id;
const isAutoDownloadEnabled = fetchAutoDownloadEnabled('imagesPreferenceDownload');
if (isAutoDownloadEnabled || isCurrentUserAuthor) {
await handleDownload();
}
};
const handleDownload = async () => {
try {
setLoading(true);
const imageUri = await downloadMediaFile({
downloadUrl: imgUrlToCache,
type: 'image', type: 'image',
mimeType: imageCached.image_type mimeType: imageCached.image_type,
urlToCache: imgUrlToCache
}); });
setImageCached(prev => ({ if (cachedImageResult?.exists) {
...prev, setImageCached(prev => ({
title_link: imageUri ...prev,
})); title_link: cachedImageResult?.uri
setCached(true); }));
} catch (e) { setLoading(false);
setCached(false); setCached(true);
} finally { return;
}
if (isReply) {
setLoading(false);
return;
}
if (isDownloadActive(imgUrlToCache)) {
return;
}
setLoading(false); setLoading(false);
await handleAutoDownload();
} }
}; };
handleCache();
}, []);
const onPress = () => { if (!img) {
if (loading && isDownloadActive(imgUrlToCache)) { return null;
cancelDownload(imgUrlToCache); }
setLoading(false);
setCached(false);
return;
}
if (!cached && !loading) {
handleDownload();
return;
}
if (!showAttachment) {
return;
}
showAttachment(imageCached);
};
if (imageCached.description) { const handleAutoDownload = async () => {
return ( const isCurrentUserAuthor = author?._id === user.id;
<View> const isAutoDownloadEnabled = fetchAutoDownloadEnabled('imagesPreferenceDownload');
<Markdown if (isAutoDownloadEnabled || isCurrentUserAuthor) {
msg={imageCached.description} await handleDownload();
style={[isReply && style]}
username={user.username}
getCustomEmoji={getCustomEmoji}
theme={theme}
/>
<Button disabled={isReply} onPress={onPress}>
<MessageImage imgUri={img} cached={cached} loading={loading} />
</Button>
</View>
);
} }
};
const handleDownload = async () => {
try {
setLoading(true);
const imageUri = await downloadMediaFile({
downloadUrl: imgUrlToCache,
type: 'image',
mimeType: imageCached.image_type
});
setImageCached(prev => ({
...prev,
title_link: imageUri
}));
setCached(true);
} catch (e) {
setCached(false);
} finally {
setLoading(false);
}
};
const onPress = () => {
if (loading && isDownloadActive(imgUrlToCache)) {
cancelDownload(imgUrlToCache);
setLoading(false);
setCached(false);
return;
}
if (!cached && !loading) {
handleDownload();
return;
}
if (!showAttachment) {
return;
}
showAttachment(imageCached);
};
if (msg) {
return ( return (
<Button disabled={isReply} onPress={onPress}> <View>
<MessageImage imgUri={img} cached={cached} loading={loading} /> <Markdown msg={msg} style={[isReply && style]} username={user.username} getCustomEmoji={getCustomEmoji} theme={theme} />
</Button> <Button disabled={isReply} onPress={onPress}>
<MessageImage imgUri={img} cached={cached} loading={loading} />
</Button>
</View>
); );
}, }
(prevProps, nextProps) => dequal(prevProps.file, nextProps.file)
); return (
<Button disabled={isReply} onPress={onPress}>
<MessageImage imgUri={img} cached={cached} loading={loading} />
</Button>
);
};
ImageContainer.displayName = 'MessageImageContainer'; ImageContainer.displayName = 'MessageImageContainer';
MessageImage.displayName = 'MessageImage'; MessageImage.displayName = 'MessageImage';

View File

@ -91,6 +91,7 @@ interface IMessageReply {
timeFormat?: string; timeFormat?: string;
index: number; index: number;
getCustomEmoji: TGetCustomEmoji; getCustomEmoji: TGetCustomEmoji;
msg?: string;
} }
const Title = React.memo( const Title = React.memo(
@ -197,7 +198,7 @@ const Fields = React.memo(
); );
const Reply = React.memo( const Reply = React.memo(
({ attachment, timeFormat, index, getCustomEmoji }: IMessageReply) => { ({ attachment, timeFormat, index, getCustomEmoji, msg }: IMessageReply) => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { theme } = useTheme(); const { theme } = useTheme();
const { baseUrl, user, jumpToMessage } = useContext(MessageContext); const { baseUrl, user, jumpToMessage } = useContext(MessageContext);
@ -238,7 +239,7 @@ const Reply = React.memo(
style={[ style={[
styles.button, styles.button,
index > 0 && styles.marginTop, index > 0 && styles.marginTop,
attachment.description && styles.marginBottom, msg && styles.marginBottom,
{ {
borderColor borderColor
} }
@ -271,7 +272,7 @@ const Reply = React.memo(
) : null} ) : null}
</View> </View>
</Touchable> </Touchable>
<Markdown msg={attachment.description} username={user.username} getCustomEmoji={getCustomEmoji} theme={theme} /> <Markdown msg={msg} username={user.username} getCustomEmoji={getCustomEmoji} theme={theme} />
</> </>
); );
}, },

View File

@ -1,6 +1,5 @@
import React, { useContext, useEffect, useState } from 'react'; import React, { useContext, useEffect, useState } from 'react';
import { StyleProp, StyleSheet, TextStyle, View, Text } from 'react-native'; import { StyleProp, StyleSheet, TextStyle, View, Text } from 'react-native';
import { dequal } from 'dequal';
import FastImage from 'react-native-fast-image'; import FastImage from 'react-native-fast-image';
import messageStyles from './styles'; import messageStyles from './styles';
@ -56,6 +55,7 @@ interface IMessageVideo {
getCustomEmoji: TGetCustomEmoji; getCustomEmoji: TGetCustomEmoji;
style?: StyleProp<TextStyle>[]; style?: StyleProp<TextStyle>[];
isReply?: boolean; isReply?: boolean;
msg?: string;
} }
const CancelIndicator = () => { const CancelIndicator = () => {
@ -81,139 +81,130 @@ const Thumbnail = ({ loading, thumbnailUrl, cached }: { loading: boolean; thumbn
</> </>
); );
const Video = React.memo( const Video = ({ file, showAttachment, getCustomEmoji, style, isReply, msg }: IMessageVideo): React.ReactElement | null => {
({ file, showAttachment, getCustomEmoji, style, isReply }: IMessageVideo) => { const [videoCached, setVideoCached] = useState(file);
const [videoCached, setVideoCached] = useState(file); const [loading, setLoading] = useState(true);
const [loading, setLoading] = useState(true); const [cached, setCached] = useState(false);
const [cached, setCached] = useState(false); const { baseUrl, user } = useContext(MessageContext);
const { baseUrl, user } = useContext(MessageContext); const { theme } = useTheme();
const { theme } = useTheme(); const video = formatAttachmentUrl(file.video_url, user.id, user.token, baseUrl);
const video = formatAttachmentUrl(file.video_url, user.id, user.token, baseUrl);
useEffect(() => { useEffect(() => {
const handleVideoSearchAndDownload = async () => { const handleVideoSearchAndDownload = async () => {
if (video) { if (video) {
const cachedVideoResult = await getMediaCache({ const cachedVideoResult = await getMediaCache({
type: 'video',
mimeType: file.video_type,
urlToCache: video
});
const downloadActive = isDownloadActive(video);
if (cachedVideoResult?.exists) {
setVideoCached(prev => ({
...prev,
video_url: cachedVideoResult?.uri
}));
setLoading(false);
setCached(true);
if (downloadActive) {
cancelDownload(video);
}
return;
}
if (isReply) {
setLoading(false);
return;
}
await handleAutoDownload();
}
};
handleVideoSearchAndDownload();
}, []);
if (!baseUrl) {
return null;
}
const handleAutoDownload = async () => {
const isAutoDownloadEnabled = fetchAutoDownloadEnabled('videoPreferenceDownload');
if (isAutoDownloadEnabled && file.video_type && isTypeSupported(file.video_type)) {
await handleDownload();
return;
}
setLoading(false);
};
const handleDownload = async () => {
setLoading(true);
try {
const videoUri = await downloadMediaFile({
downloadUrl: video,
type: 'video', type: 'video',
mimeType: file.video_type mimeType: file.video_type,
urlToCache: video
}); });
setVideoCached(prev => ({ const downloadActive = isDownloadActive(video);
...prev, if (cachedVideoResult?.exists) {
video_url: videoUri setVideoCached(prev => ({
})); ...prev,
setCached(true); video_url: cachedVideoResult?.uri
} catch { }));
setCached(false); setLoading(false);
} finally { setCached(true);
setLoading(false); if (downloadActive) {
cancelDownload(video);
}
return;
}
if (isReply) {
setLoading(false);
return;
}
await handleAutoDownload();
} }
}; };
handleVideoSearchAndDownload();
}, []);
const onPress = async () => { if (!baseUrl) {
if (file.video_type && cached && isTypeSupported(file.video_type) && showAttachment) { return null;
showAttachment(videoCached); }
return;
}
if (!loading && !cached && file.video_type && isTypeSupported(file.video_type)) {
handleDownload();
return;
}
if (loading && !cached) {
handleCancelDownload();
return;
}
if (!isIOS && file.video_url) {
await downloadVideoToGallery(video);
return;
}
EventEmitter.emit(LISTENER, { message: I18n.t('Unsupported_format') });
};
const handleCancelDownload = () => { const handleAutoDownload = async () => {
if (loading) { const isAutoDownloadEnabled = fetchAutoDownloadEnabled('videoPreferenceDownload');
cancelDownload(video); if (isAutoDownloadEnabled && file.video_type && isTypeSupported(file.video_type)) {
setLoading(false); await handleDownload();
} return;
}; }
setLoading(false);
};
const downloadVideoToGallery = async (uri: string) => { const handleDownload = async () => {
setLoading(true); setLoading(true);
const fileDownloaded = await fileDownload(uri, file); try {
const videoUri = await downloadMediaFile({
downloadUrl: video,
type: 'video',
mimeType: file.video_type
});
setVideoCached(prev => ({
...prev,
video_url: videoUri
}));
setCached(true);
} catch {
setCached(false);
} finally {
setLoading(false); setLoading(false);
}
};
if (fileDownloaded) { const onPress = async () => {
EventEmitter.emit(LISTENER, { message: I18n.t('saved_to_gallery') }); if (file.video_type && cached && isTypeSupported(file.video_type) && showAttachment) {
return; showAttachment(videoCached);
} return;
EventEmitter.emit(LISTENER, { message: I18n.t('error-save-video') }); }
}; if (!loading && !cached && file.video_type && isTypeSupported(file.video_type)) {
handleDownload();
return;
}
if (loading && !cached) {
handleCancelDownload();
return;
}
if (!isIOS && file.video_url) {
await downloadVideoToGallery(video);
return;
}
EventEmitter.emit(LISTENER, { message: I18n.t('Unsupported_format') });
};
return ( const handleCancelDownload = () => {
<> if (loading) {
<Markdown cancelDownload(video);
msg={file.description} setLoading(false);
username={user.username} }
getCustomEmoji={getCustomEmoji} };
style={[isReply && style]}
theme={theme} const downloadVideoToGallery = async (uri: string) => {
/> setLoading(true);
<Touchable const fileDownloaded = await fileDownload(uri, file);
disabled={isReply} setLoading(false);
onPress={onPress}
style={[styles.button, messageStyles.mustWrapBlur, { backgroundColor: themes[theme].videoBackground }]} if (fileDownloaded) {
background={Touchable.Ripple(themes[theme].bannerBackground)} EventEmitter.emit(LISTENER, { message: I18n.t('saved_to_gallery') });
> return;
<Thumbnail loading={loading} cached={cached} /> }
</Touchable> EventEmitter.emit(LISTENER, { message: I18n.t('error-save-video') });
</> };
);
}, return (
(prevProps, nextProps) => dequal(prevProps.file, nextProps.file) <>
); <Markdown msg={msg} username={user.username} getCustomEmoji={getCustomEmoji} style={[isReply && style]} theme={theme} />
<Touchable
disabled={isReply}
onPress={onPress}
style={[styles.button, messageStyles.mustWrapBlur, { backgroundColor: themes[theme].videoBackground }]}
background={Touchable.Ripple(themes[theme].bannerBackground)}
>
<Thumbnail loading={loading} cached={cached} />
</Touchable>
</>
);
};
export default Video; export default Video;

View File

@ -95,7 +95,8 @@ class MessageContainer extends React.Component<IMessageContainerProps, IMessageC
shouldComponentUpdate(nextProps: IMessageContainerProps, nextState: IMessageContainerState) { shouldComponentUpdate(nextProps: IMessageContainerProps, nextState: IMessageContainerState) {
const { isManualUnignored } = this.state; const { isManualUnignored } = this.state;
const { threadBadgeColor, isIgnored, highlighted, previousItem } = this.props; const { threadBadgeColor, isIgnored, highlighted, previousItem, autoTranslateRoom, autoTranslateLanguage } = this.props;
if (nextProps.highlighted !== highlighted) { if (nextProps.highlighted !== highlighted) {
return true; return true;
} }
@ -111,6 +112,12 @@ class MessageContainer extends React.Component<IMessageContainerProps, IMessageC
if (nextProps.previousItem?._id !== previousItem?._id) { if (nextProps.previousItem?._id !== previousItem?._id) {
return true; return true;
} }
if (nextProps.autoTranslateRoom !== autoTranslateRoom) {
return true;
}
if (nextProps.autoTranslateRoom !== autoTranslateRoom || nextProps.autoTranslateLanguage !== autoTranslateLanguage) {
return true;
}
return false; return false;
} }
@ -382,14 +389,17 @@ class MessageContainer extends React.Component<IMessageContainerProps, IMessageC
let message = msg; let message = msg;
let isTranslated = false; let isTranslated = false;
const otherUserMessage = u.username !== user.username;
// "autoTranslateRoom" and "autoTranslateLanguage" are properties from the subscription // "autoTranslateRoom" and "autoTranslateLanguage" are properties from the subscription
// "autoTranslateMessage" is a toggle between "View Original" and "Translate" state // "autoTranslateMessage" is a toggle between "View Original" and "Translate" state
if (autoTranslateRoom && autoTranslateMessage && autoTranslateLanguage) { if (autoTranslateRoom && autoTranslateMessage && autoTranslateLanguage && otherUserMessage) {
const messageTranslated = getMessageTranslation(item, autoTranslateLanguage); const messageTranslated = getMessageTranslation(item, autoTranslateLanguage);
isTranslated = !!messageTranslated; isTranslated = !!messageTranslated;
message = messageTranslated || message; message = messageTranslated || message;
} }
const canTranslateMessage = autoTranslateRoom && autoTranslateLanguage && autoTranslateMessage !== false && otherUserMessage;
return ( return (
<MessageContext.Provider <MessageContext.Provider
value={{ value={{
@ -409,7 +419,8 @@ class MessageContainer extends React.Component<IMessageContainerProps, IMessageC
jumpToMessage, jumpToMessage,
threadBadgeColor, threadBadgeColor,
toggleFollowThread, toggleFollowThread,
replies replies,
translateLanguage: canTranslateMessage ? autoTranslateLanguage : undefined
}} }}
> >
{/* @ts-ignore*/} {/* @ts-ignore*/}

View File

@ -1,4 +1,5 @@
/* eslint-disable complexity */ /* eslint-disable complexity */
import { IAttachment } from '../../definitions';
import { MessageTypesValues, TMessageModel } from '../../definitions/IMessage'; import { MessageTypesValues, TMessageModel } from '../../definitions/IMessage';
import I18n from '../../i18n'; import I18n from '../../i18n';
import { DISCUSSION } from './constants'; import { DISCUSSION } from './constants';
@ -194,3 +195,14 @@ export const getMessageTranslation = (message: TMessageModel, autoTranslateLangu
} }
return null; return null;
}; };
export const getMessageFromAttachment = (attachment: IAttachment, translateLanguage?: string): string | undefined => {
let msg = attachment.description;
if (translateLanguage) {
const translatedMessage = attachment.translations?.[translateLanguage];
if (translatedMessage) {
msg = translatedMessage;
}
}
return msg;
};

View File

@ -1,4 +1,5 @@
import { IUser } from './IUser'; import { IUser } from './IUser';
import { IAttachmentTranslations } from './IMessage';
export interface IAttachment { export interface IAttachment {
ts?: string | Date; ts?: string | Date;
@ -29,6 +30,7 @@ export interface IAttachment {
thumb_url?: string; thumb_url?: string;
collapsed?: boolean; collapsed?: boolean;
audio_type?: string; audio_type?: string;
translations?: IAttachmentTranslations;
} }
export interface IServerAttachment { export interface IServerAttachment {

View File

@ -41,7 +41,7 @@ export interface IEditedBy {
export type TOnLinkPress = (link: string) => void; export type TOnLinkPress = (link: string) => void;
export interface ITranslations { export interface IMessageTranslations {
_id: string; _id: string;
language: string; language: string;
value: string; value: string;
@ -136,7 +136,7 @@ export interface IMessage extends IMessageFromServer {
replies?: string[]; replies?: string[];
unread?: boolean; unread?: boolean;
autoTranslate?: boolean; autoTranslate?: boolean;
translations?: ITranslations[]; translations?: IMessageTranslations[];
tmsg?: string; tmsg?: string;
blocks?: any; blocks?: any;
e2e?: E2EType; e2e?: E2EType;
@ -243,3 +243,7 @@ export type MessageTypesValues =
| 'message_pinned' | 'message_pinned'
| 'message_snippeted' | 'message_snippeted'
| 'jitsi_call_started'; | 'jitsi_call_started';
export interface IAttachmentTranslations {
[k: string]: string;
}

View File

@ -1,5 +1,5 @@
import { store } from '../../store/auxStore'; import { store } from '../../store/auxStore';
import { IAttachment, IMessage } from '../../../definitions'; import { IAttachment, IAttachmentTranslations, IMessage } from '../../../definitions';
import { getAvatarURL } from '../../methods/helpers'; import { getAvatarURL } from '../../methods/helpers';
export function createQuoteAttachment(message: IMessage, messageLink: string): IAttachment { export function createQuoteAttachment(message: IMessage, messageLink: string): IAttachment {
@ -8,7 +8,8 @@ export function createQuoteAttachment(message: IMessage, messageLink: string): I
return { return {
text: message.msg, text: message.msg,
...('translations' in message && { translations: message?.translations }), // this type is wrong
...('translations' in message && { translations: message?.translations as unknown as IAttachmentTranslations }),
message_link: messageLink, message_link: messageLink,
author_name: message.alias || message.u.username, author_name: message.alias || message.u.username,
author_icon: getAvatarURL({ author_icon: getAvatarURL({

View File

@ -100,15 +100,20 @@ const AutoTranslateView = (): React.ReactElement => {
title={name || language} title={name || language}
onPress={() => saveAutoTranslateLanguage(language)} onPress={() => saveAutoTranslateLanguage(language)}
testID={`auto-translate-view-${language}`} testID={`auto-translate-view-${language}`}
right={() => (selectedLanguage === language ? <List.Icon name='check' color={colors.tintColor} /> : null)} right={() =>
selectedLanguage === language ? (
<List.Icon testID={`auto-translate-view-${language}-check`} name='check' color={colors.tintColor} />
) : null
}
translateTitle={false} translateTitle={false}
/> />
)); ));
return ( return (
<SafeAreaView testID='auto-translate-view'> <SafeAreaView>
<StatusBar /> <StatusBar />
<FlatList <FlatList
testID='auto-translate-view'
data={languages} data={languages}
keyExtractor={item => item.name || item.language} keyExtractor={item => item.name || item.language}
renderItem={({ item: { language, name } }) => <LanguageItem language={language} name={name} />} renderItem={({ item: { language, name } }) => <LanguageItem language={language} name={name} />}
@ -117,9 +122,13 @@ const AutoTranslateView = (): React.ReactElement => {
<List.Separator /> <List.Separator />
<List.Item <List.Item
title='Enable_Auto_Translate' title='Enable_Auto_Translate'
testID='auto-translate-view-switch'
right={() => ( right={() => (
<Switch value={enableAutoTranslate} trackColor={SWITCH_TRACK_COLOR} onValueChange={toggleAutoTranslate} /> <Switch
testID='auto-translate-view-switch'
value={enableAutoTranslate}
trackColor={SWITCH_TRACK_COLOR}
onValueChange={toggleAutoTranslate}
/>
)} )}
/> />
<List.Separator /> <List.Separator />

View File

@ -57,6 +57,8 @@ export interface IListContainerProps {
navigation: any; // TODO: type me navigation: any; // TODO: type me
showMessageInMainThread: boolean; showMessageInMainThread: boolean;
serverVersion: string | null; serverVersion: string | null;
autoTranslateRoom?: boolean;
autoTranslateLanguage?: string;
} }
interface IListContainerState { interface IListContainerState {
@ -106,7 +108,7 @@ class ListContainer extends React.Component<IListContainerProps, IListContainerS
shouldComponentUpdate(nextProps: IListContainerProps, nextState: IListContainerState) { shouldComponentUpdate(nextProps: IListContainerProps, nextState: IListContainerState) {
const { refreshing, highlightedMessage } = this.state; const { refreshing, highlightedMessage } = this.state;
const { hideSystemMessages, tunread, ignored, loading } = this.props; const { hideSystemMessages, tunread, ignored, loading, autoTranslateLanguage, autoTranslateRoom } = this.props;
if (loading !== nextProps.loading) { if (loading !== nextProps.loading) {
return true; return true;
} }
@ -125,6 +127,9 @@ class ListContainer extends React.Component<IListContainerProps, IListContainerS
if (!dequal(ignored, nextProps.ignored)) { if (!dequal(ignored, nextProps.ignored)) {
return true; return true;
} }
if (autoTranslateLanguage !== nextProps.autoTranslateLanguage || autoTranslateRoom !== nextProps.autoTranslateRoom) {
return true;
}
return false; return false;
} }

View File

@ -138,7 +138,9 @@ const roomAttrsUpdate = [
'status', 'status',
'lastMessage', 'lastMessage',
'onHold', 'onHold',
't' 't',
'autoTranslate',
'autoTranslateLanguage'
] as TRoomUpdate[]; ] as TRoomUpdate[];
interface IRoomViewProps extends IActionSheetProvider, IBaseScreen<ChatsStackParamList, 'RoomView'> { interface IRoomViewProps extends IActionSheetProvider, IBaseScreen<ChatsStackParamList, 'RoomView'> {
@ -1491,7 +1493,7 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
render() { render() {
console.count(`${this.constructor.name}.render calls`); console.count(`${this.constructor.name}.render calls`);
const { room, loading } = this.state; const { room, loading, canAutoTranslate } = this.state;
const { user, baseUrl, theme, navigation, Hide_System_Messages, width, serverVersion } = this.props; const { user, baseUrl, theme, navigation, Hide_System_Messages, width, serverVersion } = this.props;
const { rid, t } = room; const { rid, t } = room;
let sysMes; let sysMes;
@ -1520,6 +1522,8 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
hideSystemMessages={Array.isArray(sysMes) ? sysMes : Hide_System_Messages} hideSystemMessages={Array.isArray(sysMes) ? sysMes : Hide_System_Messages}
showMessageInMainThread={user.showMessageInMainThread ?? false} showMessageInMainThread={user.showMessageInMainThread ?? false}
serverVersion={serverVersion} serverVersion={serverVersion}
autoTranslateRoom={canAutoTranslate && 'id' in room && room.autoTranslate}
autoTranslateLanguage={'id' in room ? room.autoTranslateLanguage : undefined}
/> />
{this.renderFooter()} {this.renderFooter()}
{this.renderActions()} {this.renderActions()}

View File

@ -1,4 +1,4 @@
import axios from 'axios'; import axios, { AxiosInstance } from 'axios';
import data from '../data'; import data from '../data';
import random from './random'; import random from './random';
@ -161,30 +161,15 @@ export interface IDeleteCreateUser {
username: string; username: string;
} }
const deleteCreatedUser = async ({ server, username: usernameToDelete }: IDeleteCreateUser) => { const deleteCreatedUser = async ({ username: usernameToDelete }: IDeleteCreateUser) => {
const serverConnection = axios.create({ try {
baseURL: `${server}/api/v1/`, const api = await initApi(data.adminUser, data.adminPassword);
headers: { const result = await api.get(`users.info?username=${usernameToDelete}`);
'Content-Type': 'application/json;charset=UTF-8' const responsePost = await api.post('users.delete', { userId: result.data.user._id, confirmRelinquish: true });
} return responsePost.data;
}); } catch (error) {
console.log(`Logging in as admin in ${server}`); console.log(JSON.stringify(error));
const response = await serverConnection.post('login', { }
user: data.adminUser,
password: data.adminPassword
});
const { authToken, userId } = response.data.data;
serverConnection.defaults.headers.common['X-User-Id'] = userId;
serverConnection.defaults.headers.common['X-Auth-Token'] = authToken;
console.log(`Get user info: users.info?username=${usernameToDelete}`);
const result = await serverConnection.get(`users.info?username=${usernameToDelete}`);
const userIdToDelete = result.data.user._id;
const body = { userId: userIdToDelete, confirmRelinquish: false };
console.log(`Delete user: users.delete ${JSON.stringify(body)}`);
const responsePost = await serverConnection.post('users.delete', body);
return responsePost.data;
}; };
// Delete created users to avoid use all the Seats Available on the server // Delete created users to avoid use all the Seats Available on the server
@ -195,3 +180,20 @@ export const deleteCreatedUsers = async (deleteUsersAfterAll: IDeleteCreateUser[
} }
} }
}; };
export const initApi = async (user: string, password: string): Promise<AxiosInstance> => {
const api = axios.create({
baseURL: `${server}/api/v1/`,
headers: {
'Content-Type': 'application/json;charset=UTF-8'
}
});
const response = await api.post('login', {
user,
password
});
const { authToken, userId } = response.data.data;
api.defaults.headers.common['X-User-Id'] = userId;
api.defaults.headers.common['X-Auth-Token'] = authToken;
return api;
};

View File

@ -0,0 +1,227 @@
import { by, device, element, expect, waitFor } from 'detox';
import { TTextMatcher, login, navigateToLogin, platformTypes, searchRoom, tapBack, tryTapping } from '../../helpers/app';
import { ITestUser, createRandomRoom, createRandomUser, initApi } from '../../helpers/data_setup';
import random from '../../helpers/random';
const roomId = '64b846e4760e618aa9f91ab7';
const sendMessageOnTranslationTestRoom = async (msg: string): Promise<{ user: ITestUser; msgId: string }> => {
const user = await createRandomUser();
const api = await initApi(user.username, user.password);
const msgId = random();
await api.post('channels.join', { roomId, joinCode: null });
await api.post('chat.sendMessage', {
message: { _id: msgId, rid: roomId, msg, tshow: false }
});
return { user, msgId };
};
const deleteMessageOnTranslationTestRoom = async ({ user, msgId }: { user: ITestUser; msgId: string }): Promise<void> => {
const api = await initApi(user.username, user.password);
await api.post('chat.delete', {
msgId,
roomId
});
};
async function navigateToRoom(roomName: string) {
await searchRoom(`${roomName}`);
await element(by.id(`rooms-list-view-item-${roomName}`)).tap();
await waitFor(element(by.id('room-view')))
.toBeVisible()
.withTimeout(5000);
}
export function waitForVisible(id: string) {
return waitFor(element(by.id(id)))
.toBeVisible()
.withTimeout(5000);
}
export function waitForVisibleTextMatcher(msg: string, textMatcher: TTextMatcher) {
return waitFor(element(by[textMatcher](msg)).atIndex(0))
.toExist()
.withTimeout(5000);
}
export function waitForNotVisible(id: string) {
return waitFor(element(by.id(id)))
.not.toBeVisible()
.withTimeout(5000);
}
describe('Auto Translate', () => {
let textMatcher: TTextMatcher;
const languages = {
default: 'en',
translated: 'pt'
};
const oldMessage = {
[languages.default]: 'dog',
[languages.translated]: 'cachorro'
};
const newMessage = {
[languages.default]: 'cat',
[languages.translated]: 'gato'
};
const attachmentMessage = {
[languages.default]: 'attachment',
[languages.translated]: 'anexo'
};
beforeAll(async () => {
const user = await createRandomUser();
await createRandomRoom(user);
await device.launchApp({ permissions: { notifications: 'YES' }, delete: true });
({ textMatcher } = platformTypes[device.getPlatform()]);
await navigateToLogin();
await login(user.username, user.password);
});
it('should join translation-test room', async () => {
await navigateToRoom('translation-test');
await element(by.id('room-view-join-button')).tap();
await waitForNotVisible('room-view-join-button');
await tapBack();
await navigateToRoom('translation-test');
await waitForVisible('messagebox');
await expect(element(by.id('room-view-join'))).not.toBeVisible();
});
it('should see old message not translated before enable auto translate', async () => {
await waitForVisibleTextMatcher(oldMessage[languages.default] as string, textMatcher);
await waitForVisibleTextMatcher(attachmentMessage[languages.default] as string, textMatcher);
});
it('should enable auto translate', async () => {
await element(by.id('room-header')).tap();
await waitForVisible('room-actions-view');
await element(by.id('room-actions-view')).swipe('up');
await waitForVisible('room-actions-auto-translate');
await element(by.id('room-actions-auto-translate')).tap();
await waitForVisible('auto-translate-view-switch');
await element(by.id('auto-translate-view-switch')).tap();
// verify default language is checked
await waitFor(element(by.id(`auto-translate-view-${languages.default}`)))
.toBeVisible()
.whileElement(by.id('auto-translate-view'))
.scroll(750, 'down');
await waitForVisible(`auto-translate-view-${languages.default}-check`);
// enable translated language
await waitFor(element(by.id(`auto-translate-view-${languages.translated}`)))
.toBeVisible()
.whileElement(by.id('auto-translate-view'))
.scroll(750, 'down');
await waitForNotVisible(`auto-translate-view-${languages.translated}-check`);
await element(by.id(`auto-translate-view-${languages.translated}`)).tap();
await waitForVisible(`auto-translate-view-${languages.translated}-check`);
// verify default language is unchecked
await waitFor(element(by.id(`auto-translate-view-${languages.default}`)))
.toBeVisible()
.whileElement(by.id('auto-translate-view'))
.scroll(750, 'up');
await waitForNotVisible(`auto-translate-view-${languages.default}-check`);
await tapBack();
await tapBack();
});
it('should see old message translated after enable auto translate', async () => {
await waitForVisibleTextMatcher(oldMessage[languages.translated] as string, textMatcher);
await waitForVisibleTextMatcher(attachmentMessage[languages.translated] as string, textMatcher);
});
it('should see new message translated', async () => {
const randomMatcher = random();
const data = await sendMessageOnTranslationTestRoom(`${newMessage[languages.default]} - ${randomMatcher}`);
await waitForVisibleTextMatcher(`${newMessage[languages.translated]} - ${randomMatcher}`, textMatcher);
await deleteMessageOnTranslationTestRoom(data);
});
it('should see original message', async () => {
const randomMatcher = random();
const data = await sendMessageOnTranslationTestRoom(`${newMessage[languages.default]} - ${randomMatcher}`);
await waitForVisibleTextMatcher(`${newMessage[languages.translated]} - ${randomMatcher}`, textMatcher);
await tryTapping(element(by[textMatcher](`${newMessage[languages.translated]} - ${randomMatcher}`)).atIndex(0), 2000, true);
await waitForVisible('action-sheet-handle');
await element(by.id('action-sheet-handle')).swipe('up', 'fast', 0.5);
await waitForVisibleTextMatcher('View original', textMatcher);
await element(by[textMatcher]('View original')).atIndex(0).tap();
await waitForVisibleTextMatcher(`${newMessage[languages.default]} - ${randomMatcher}`, textMatcher);
await deleteMessageOnTranslationTestRoom(data);
});
it('disable auto translate and see original message', async () => {
const randomMatcher = random();
const data = await sendMessageOnTranslationTestRoom(`${newMessage[languages.default]} - ${randomMatcher}`);
await waitForVisibleTextMatcher(`${newMessage[languages.translated]} - ${randomMatcher}`, textMatcher);
await element(by.id('room-header')).tap();
await waitForVisible('room-actions-view');
await element(by.id('room-actions-view')).swipe('up');
await waitForVisible('room-actions-auto-translate');
await element(by.id('room-actions-auto-translate')).tap();
await waitForVisible('auto-translate-view-switch');
await element(by.id('auto-translate-view-switch')).tap();
await tapBack();
await tapBack();
await waitForVisibleTextMatcher(`${newMessage[languages.default]} - ${randomMatcher}`, textMatcher);
await deleteMessageOnTranslationTestRoom(data);
});
it(`should don't see action to View original when disable auto translate`, async () => {
await waitForVisibleTextMatcher(oldMessage[languages.default] as string, textMatcher);
await tryTapping(element(by[textMatcher](oldMessage[languages.default] as string)).atIndex(0), 2000, true);
await waitForVisible('action-sheet-handle');
await element(by.id('action-sheet-handle')).swipe('up', 'fast', 0.5);
await waitForNotVisible('View original');
// close action sheet
await element(by.id('room-header')).tap();
});
it('should the language selected when activating auto translate again must be the old one', async () => {
await element(by.id('room-header')).tap();
await waitForVisible('room-actions-view');
await element(by.id('room-actions-view')).swipe('up');
await waitForVisible('room-actions-auto-translate');
await element(by.id('room-actions-auto-translate')).tap();
await waitForVisible('auto-translate-view-switch');
await element(by.id('auto-translate-view-switch')).tap();
// verify translated language is checked and is the old one
await waitFor(element(by.id(`auto-translate-view-${languages.translated}`)))
.toBeVisible()
.whileElement(by.id('auto-translate-view'))
.scroll(750, 'down');
await waitForVisible(`auto-translate-view-${languages.translated}-check`);
});
});