feat: add media auto-download (#5076)

* feat: media auto-download view

* media auto download view completed and saving the settings in mmkv

* audio download preference

* audio auto download when the user who sent the audio is the same logged on mobile

* creation of isAutoDownloadEnabled, evaluate hist hook, Image Full Size preload done

* minor tweak audio show play button after download

* refactor audioFile to handleMediaDownload and fixed the audio download

* desestructured params to download too

* image download and autoDownload, algo fix the formatAttachmentUrl to show the image from local

* add the possibility to cancel image download and clear local images

* refactor blur component

* video download and auto download, also keeped the behavior to download unsuportted videos to the gallery

* add the possibility to start downloading a video, then exit the room, back again to room and cancel the video previously downloading

* remove the custom hook for autoDownload

* remove blurcomponent, fix the blur style in image.tsx, minor tweak video function name

* send messageId to video

* introducing the reducer to keep the downloads in progress

* create a media download selector

* remove all the redux stuff and do the same as file upload

* video download behavior

* done for image and audio

* fix the try catch download media

* clean up

* image container uiKit

* fix lint

* change rn-fetch-blob to expo-filesystem

* add pt-br

* pass the correct message id when there is an attachment on reply

* refactor some changes requested

* fix audio and move the netInfo from autoDownloadPreference to redux

* variable isAutoDownloadEnable name and handleMediaDownload getExtension

* message/Image refactored, change the component to show the image from FastImage to Image

* refactor handleMediaDownload and deleteMedia

* minor tweak

* refactor audio

* refactor video

* fix the type on the messagesView(the view of files)

* minor tweak

* fix the name of searchMediaFIleAsync's result

* minor tweak, add the default behavior, add the OFF as label

* minor tweaks

* verify if the media auto download exists on settings view

* fix media auto download view layout and minor tweak wifi

* avoid auto download from reply

* minor tweak at comment

* tweak list.section

* change the name to netInfoState and Local_document_directory

* remove mediaType and refactor audio and image

* separate blurview

* thumbnail video and video behavior

* add Audio to i18n and minor tweak

* set the blur as always dark and add the possibility to overlay

* don't need to controle the filepath in the view

* fix the loading in image and video at begin

* save the file with a similar filename as expected

* removed the necessity of messageId or id

* minor tweak

* switch useLayoutEffect to useEffect

* avoid onpress do some edge case because of cached at video

* minor tweak

* tweak at audio comment extension

* minor tweak type userpreferences

* remove test id from mediaAutoDownloadView

* change action's name to SET_NET_INFO_STATE

* caching and deleting video's thumbnails

* remove generate thumbnail

* minor tweak in image

* update camera-roll and save the file from local url

* remove local_cache_directory and deleteThumbnail

* update blur to fix error on android

* fix blur is hiding the file description

* avoid download unsupported video

* return void when it is loading the audio
This commit is contained in:
Reinaldo Neto 2023-08-07 11:02:30 -03:00 committed by GitHub
parent 278ed91f9a
commit c9f4ca1197
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 1012 additions and 232 deletions

View File

@ -46,7 +46,8 @@ export const APP = createRequestTypes('APP', [
'INIT',
'INIT_LOCAL_SETTINGS',
'SET_MASTER_DETAIL',
'SET_NOTIFICATION_PRESENCE_CAP'
'SET_NOTIFICATION_PRESENCE_CAP',
'SET_NET_INFO_STATE'
]);
export const MESSAGES = createRequestTypes('MESSAGES', ['REPLY_BROADCAST']);
export const CREATE_CHANNEL = createRequestTypes('CREATE_CHANNEL', [...defaultTypes]);

View File

@ -1,4 +1,5 @@
import { Action } from 'redux';
import { NetInfoStateType } from '@react-native-community/netinfo';
import { RootEnum } from '../definitions';
import { APP } from './actionsTypes';
@ -16,7 +17,11 @@ interface ISetNotificationPresenceCap extends Action {
show: boolean;
}
export type TActionApp = IAppStart & ISetMasterDetail & ISetNotificationPresenceCap;
interface ISetNetInfoState extends Action {
netInfoState: NetInfoStateType;
}
export type TActionApp = IAppStart & ISetMasterDetail & ISetNotificationPresenceCap & ISetNetInfoState;
interface Params {
root: RootEnum;
@ -62,3 +67,10 @@ export function setNotificationPresenceCap(show: boolean): ISetNotificationPrese
show
};
}
export function setNetInfoState(netInfoState: NetInfoStateType): ISetNetInfoState {
return {
type: APP.SET_NET_INFO_STATE,
netInfoState
};
}

View File

@ -3,10 +3,17 @@ import { Image } from 'react-native';
import { FastImageProps } from 'react-native-fast-image';
import { types } from './types';
import { LOCAL_DOCUMENT_DIRECTORY } from '../../lib/methods/handleMediaDownload';
export const ImageComponent = (type?: string): React.ComponentType<Partial<Image> | FastImageProps> => {
export function ImageComponent({
type,
uri
}: {
type?: string;
uri: string;
}): React.ComponentType<Partial<Image> | FastImageProps> {
let Component;
if (type === types.REACT_NATIVE_IMAGE) {
if (type === types.REACT_NATIVE_IMAGE || (LOCAL_DOCUMENT_DIRECTORY && uri.startsWith(LOCAL_DOCUMENT_DIRECTORY))) {
const { Image } = require('react-native');
Component = Image;
} else {

View File

@ -109,7 +109,7 @@ export const ImageViewer = ({ uri = '', imageComponentType, width, height, ...pr
const gesture = Gesture.Simultaneous(pinchGesture, panGesture, doubleTapGesture);
const Component = ImageComponent(imageComponentType);
const Component = ImageComponent({ type: imageComponentType, uri });
const { colors } = useTheme();

View File

@ -54,7 +54,7 @@ const AttachedActions = ({ attachment, getCustomEmoji }: { attachment: IAttachme
};
const Attachments: React.FC<IMessageAttachments> = React.memo(
({ attachments, timeFormat, showAttachment, style, getCustomEmoji, isReply, id }: IMessageAttachments) => {
({ attachments, timeFormat, showAttachment, style, getCustomEmoji, isReply, author }: IMessageAttachments) => {
const { theme } = useTheme();
if (!attachments || attachments.length === 0) {
@ -71,6 +71,7 @@ const Attachments: React.FC<IMessageAttachments> = React.memo(
getCustomEmoji={getCustomEmoji}
style={style}
isReply={isReply}
author={author}
/>
);
}
@ -84,7 +85,7 @@ const Attachments: React.FC<IMessageAttachments> = React.memo(
isReply={isReply}
style={style}
theme={theme}
messageId={id}
author={author}
/>
);
}
@ -111,16 +112,7 @@ const Attachments: React.FC<IMessageAttachments> = React.memo(
);
}
return (
<Reply
key={index}
index={index}
attachment={file}
timeFormat={timeFormat}
getCustomEmoji={getCustomEmoji}
messageId={id}
/>
);
return <Reply key={index} index={index} attachment={file} timeFormat={timeFormat} getCustomEmoji={getCustomEmoji} />;
});
return <>{attachmentsElements}</>;
},

View File

@ -17,18 +17,19 @@ import MessageContext from './Context';
import ActivityIndicator from '../ActivityIndicator';
import { withDimensions } from '../../dimensions';
import { TGetCustomEmoji } from '../../definitions/IEmoji';
import { IAttachment } from '../../definitions';
import { TSupportedThemes } from '../../theme';
import { downloadAudioFile } from '../../lib/methods/audioFile';
import { IAttachment, IUserMessage } from '../../definitions';
import { TSupportedThemes, useTheme } from '../../theme';
import { downloadMediaFile, getMediaCache } from '../../lib/methods/handleMediaDownload';
import EventEmitter from '../../lib/methods/helpers/events';
import { PAUSE_AUDIO } from './constants';
import { fetchAutoDownloadEnabled } from '../../lib/methods/autoDownloadPreference';
interface IButton {
loading: boolean;
paused: boolean;
theme: TSupportedThemes;
disabled?: boolean;
onPress: () => void;
cached: boolean;
}
interface IMessageAudioProps {
@ -38,7 +39,7 @@ interface IMessageAudioProps {
theme: TSupportedThemes;
getCustomEmoji: TGetCustomEmoji;
scale?: number;
messageId: string;
author?: IUserMessage;
}
interface IMessageAudioState {
@ -46,6 +47,7 @@ interface IMessageAudioState {
currentTime: number;
duration: number;
paused: boolean;
cached: boolean;
}
const mode = {
@ -90,25 +92,29 @@ const formatTime = (seconds: number) => moment.utc(seconds * 1000).format('mm:ss
const BUTTON_HIT_SLOP = { top: 12, right: 12, bottom: 12, left: 12 };
const Button = React.memo(({ loading, paused, onPress, disabled, theme }: IButton) => (
<Touchable
style={styles.playPauseButton}
disabled={disabled}
onPress={onPress}
hitSlop={BUTTON_HIT_SLOP}
background={Touchable.SelectableBackgroundBorderless()}
>
{loading ? (
<ActivityIndicator style={[styles.playPauseButton, styles.audioLoading]} />
) : (
<CustomIcon
name={paused ? 'play-filled' : 'pause-filled'}
size={36}
color={disabled ? themes[theme].tintDisabled : themes[theme].tintColor}
/>
)}
</Touchable>
));
const Button = React.memo(({ loading, paused, onPress, disabled, cached }: IButton) => {
const { colors } = useTheme();
let customIconName: 'arrow-down-circle' | 'play-filled' | 'pause-filled' = 'arrow-down-circle';
if (cached) {
customIconName = paused ? 'play-filled' : 'pause-filled';
}
return (
<Touchable
style={styles.playPauseButton}
disabled={disabled}
onPress={onPress}
hitSlop={BUTTON_HIT_SLOP}
background={Touchable.SelectableBackgroundBorderless()}
>
{loading ? (
<ActivityIndicator style={[styles.playPauseButton, styles.audioLoading]} />
) : (
<CustomIcon name={customIconName} size={36} color={disabled ? colors.tintDisabled : colors.tintColor} />
)}
</Touchable>
);
});
Button.displayName = 'MessageAudioButton';
@ -119,10 +125,11 @@ class MessageAudio extends React.Component<IMessageAudioProps, IMessageAudioStat
constructor(props: IMessageAudioProps) {
super(props);
this.state = {
loading: false,
loading: true,
currentTime: 0,
duration: 0,
paused: true
paused: true,
cached: false
};
this.sound = new Audio.Sound();
@ -135,29 +142,26 @@ class MessageAudio extends React.Component<IMessageAudioProps, IMessageAudioStat
};
async componentDidMount() {
const { file, messageId } = this.props;
// @ts-ignore can't use declare to type this
const { baseUrl, user } = this.context;
let url = file.audio_url;
if (url && !url.startsWith('http')) {
url = `${baseUrl}${file.audio_url}`;
const { file, isReply } = this.props;
const cachedAudioResult = await getMediaCache({
type: 'audio',
mimeType: file.audio_type,
urlToCache: this.getUrl()
});
if (cachedAudioResult?.exists) {
await this.sound.loadAsync({ uri: cachedAudioResult.uri });
this.setState({ loading: false, cached: true });
return;
}
this.setState({ loading: true });
try {
if (url) {
const audio = await downloadAudioFile(`${url}?rc_uid=${user.id}&rc_token=${user.token}`, url, messageId);
await this.sound.loadAsync({ uri: audio });
}
} catch {
// Do nothing
if (isReply) {
this.setState({ loading: false });
return;
}
this.setState({ loading: false });
await this.handleAutoDownload();
}
shouldComponentUpdate(nextProps: IMessageAudioProps, nextState: IMessageAudioState) {
const { currentTime, duration, paused, loading } = this.state;
const { currentTime, duration, paused, loading, cached } = this.state;
const { file, theme } = this.props;
if (nextProps.theme !== theme) {
return true;
@ -177,6 +181,9 @@ class MessageAudio extends React.Component<IMessageAudioProps, IMessageAudioStat
if (nextState.loading !== loading) {
return true;
}
if (nextState.cached !== cached) {
return true;
}
return false;
}
@ -198,6 +205,39 @@ class MessageAudio extends React.Component<IMessageAudioProps, IMessageAudioStat
}
}
getUrl = () => {
const { file } = this.props;
// @ts-ignore can't use declare to type this
const { baseUrl } = this.context;
let url = file.audio_url;
if (url && !url.startsWith('http')) {
url = `${baseUrl}${file.audio_url}`;
}
return url;
};
handleAutoDownload = async () => {
const { author } = this.props;
// @ts-ignore can't use declare to type this
const { user } = this.context;
const url = this.getUrl();
try {
if (url) {
const isCurrentUserAuthor = author?._id === user.id;
const isAutoDownloadEnabled = fetchAutoDownloadEnabled('audioPreferenceDownload');
if (isAutoDownloadEnabled || isCurrentUserAuthor) {
await this.handleDownload();
return;
}
this.setState({ loading: false, cached: false });
}
} catch {
// Do nothing
}
};
onPlaybackStatusUpdate = (status: AVPlaybackStatus) => {
if (status) {
this.onLoad(status);
@ -247,6 +287,39 @@ class MessageAudio extends React.Component<IMessageAudioProps, IMessageAudioStat
this.setState({ paused: !paused }, this.playPause);
};
handleDownload = async () => {
const { file } = this.props;
// @ts-ignore can't use declare to type this
const { user } = this.context;
this.setState({ loading: true });
try {
const url = this.getUrl();
if (url) {
const audio = await downloadMediaFile({
downloadUrl: `${url}?rc_uid=${user.id}&rc_token=${user.token}`,
type: 'audio',
mimeType: file.audio_type
});
await this.sound.loadAsync({ uri: audio });
this.setState({ loading: false, cached: true });
}
} catch {
this.setState({ loading: false, cached: false });
}
};
onPress = () => {
const { cached, loading } = this.state;
if (loading) {
return;
}
if (cached) {
this.togglePlayPause();
return;
}
this.handleDownload();
};
playPause = async () => {
const { paused } = this.state;
try {
@ -274,7 +347,7 @@ class MessageAudio extends React.Component<IMessageAudioProps, IMessageAudioStat
};
render() {
const { loading, paused, currentTime, duration } = this.state;
const { loading, paused, currentTime, duration, cached } = this.state;
const { file, getCustomEmoji, theme, scale, isReply, style } = this.props;
const { description } = file;
// @ts-ignore can't use declare to type this
@ -306,7 +379,7 @@ class MessageAudio extends React.Component<IMessageAudioProps, IMessageAudioStat
{ backgroundColor: themes[theme].chatComponentBackground, borderColor: themes[theme].borderColor }
]}
>
<Button disabled={isReply} loading={loading} paused={paused} onPress={this.togglePlayPause} theme={theme} />
<Button disabled={isReply} loading={loading} paused={paused} cached={cached} onPress={this.onPress} />
<Slider
disabled={isReply}
style={styles.slider}

View File

@ -0,0 +1,37 @@
import React from 'react';
import { StyleProp, View, ViewStyle } from 'react-native';
import { BlurView } from '@react-native-community/blur';
import styles from '../../styles';
import { useTheme } from '../../../../theme';
import RCActivityIndicator from '../../../ActivityIndicator';
import { CustomIcon, TIconsName } from '../../../CustomIcon';
const BlurComponent = ({
loading = false,
style = {},
iconName,
showOverlay = false
}: {
loading: boolean;
style: StyleProp<ViewStyle>;
iconName: TIconsName;
showOverlay?: boolean;
}) => {
const { colors } = useTheme();
return (
<>
{!showOverlay ? (
<BlurView style={[style, styles.blurView]} blurType={'dark'} blurAmount={2} />
) : (
<View style={[style, styles.blurView, { backgroundColor: colors.overlayColor }]} />
)}
<View style={[style, styles.blurIndicator]}>
{loading ? <RCActivityIndicator size={54} /> : <CustomIcon color={colors.buttonText} name={iconName} size={54} />}
</View>
</>
);
};
export default BlurComponent;

View File

@ -1,25 +1,24 @@
import React, { useContext } from 'react';
import React, { useContext, useEffect, useState } from 'react';
import { StyleProp, TextStyle, View } from 'react-native';
import FastImage from 'react-native-fast-image';
import { dequal } from 'dequal';
import { createImageProgress } from 'react-native-image-progress';
import * as Progress from 'react-native-progress';
import Touchable from './Touchable';
import Markdown from '../markdown';
import styles from './styles';
import { themes } from '../../lib/constants';
import MessageContext from './Context';
import { TGetCustomEmoji } from '../../definitions/IEmoji';
import { IAttachment } from '../../definitions';
import { TSupportedThemes, useTheme } from '../../theme';
import { IAttachment, IUserMessage } from '../../definitions';
import { useTheme } from '../../theme';
import { formatAttachmentUrl } from '../../lib/methods/helpers/formatAttachmentUrl';
import { cancelDownload, downloadMediaFile, isDownloadActive, getMediaCache } from '../../lib/methods/handleMediaDownload';
import { fetchAutoDownloadEnabled } from '../../lib/methods/autoDownloadPreference';
import BlurComponent from './Components/BlurComponent';
interface IMessageButton {
children: React.ReactElement;
disabled?: boolean;
onPress: () => void;
theme: TSupportedThemes;
}
interface IMessageImage {
@ -29,71 +28,152 @@ interface IMessageImage {
style?: StyleProp<TextStyle>[];
isReply?: boolean;
getCustomEmoji?: TGetCustomEmoji;
author?: IUserMessage;
}
const ImageProgress = createImageProgress(FastImage);
const Button = React.memo(({ children, onPress, disabled }: IMessageButton) => {
const { colors } = useTheme();
return (
<Touchable
disabled={disabled}
onPress={onPress}
style={[styles.imageContainer, styles.mustWrapBlur]}
background={Touchable.Ripple(colors.bannerBackground)}
>
{children}
</Touchable>
);
});
const Button = React.memo(({ children, onPress, disabled, theme }: IMessageButton) => (
<Touchable
disabled={disabled}
onPress={onPress}
style={styles.imageContainer}
background={Touchable.Ripple(themes[theme].bannerBackground)}
>
{children}
</Touchable>
));
export const MessageImage = React.memo(({ imgUri, theme }: { imgUri: string; theme: TSupportedThemes }) => (
<ImageProgress
style={[styles.image, { borderColor: themes[theme].borderColor }]}
source={{ uri: encodeURI(imgUri) }}
resizeMode={FastImage.resizeMode.cover}
indicator={Progress.Pie}
indicatorProps={{
color: themes[theme].actionTintColor
}}
/>
));
export const MessageImage = React.memo(({ imgUri, cached, loading }: { imgUri: string; cached: boolean; loading: boolean }) => {
const { colors } = useTheme();
return (
<>
<FastImage
style={[styles.image, { borderColor: colors.borderColor }]}
source={{ uri: encodeURI(imgUri) }}
resizeMode={FastImage.resizeMode.cover}
/>
{!cached ? (
<BlurComponent loading={loading} style={[styles.image, styles.imageBlurContainer]} iconName='arrow-down-circle' />
) : null}
</>
);
});
const ImageContainer = React.memo(
({ file, imageUrl, showAttachment, getCustomEmoji, style, isReply }: IMessageImage) => {
({ file, imageUrl, showAttachment, getCustomEmoji, style, isReply, author }: IMessageImage) => {
const [imageCached, setImageCached] = useState(file);
const [cached, setCached] = useState(false);
const [loading, setLoading] = useState(true);
const { theme } = useTheme();
const { baseUrl, user } = useContext(MessageContext);
const img = imageUrl || formatAttachmentUrl(file.image_url, user.id, user.token, baseUrl);
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(() => {
const handleCache = async () => {
if (img) {
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',
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;
}
return showAttachment(file);
showAttachment(imageCached);
};
if (file.description) {
if (imageCached.description) {
return (
<Button disabled={isReply} theme={theme} onPress={onPress}>
<View>
<Markdown
msg={file.description}
style={[isReply && style]}
username={user.username}
getCustomEmoji={getCustomEmoji}
theme={theme}
/>
<MessageImage imgUri={img} theme={theme} />
</View>
</Button>
<View>
<Markdown
msg={imageCached.description}
style={[isReply && style]}
username={user.username}
getCustomEmoji={getCustomEmoji}
theme={theme}
/>
<Button disabled={isReply} onPress={onPress}>
<MessageImage imgUri={img} cached={cached} loading={loading} />
</Button>
</View>
);
}
return (
<Button disabled={isReply} theme={theme} onPress={onPress}>
<MessageImage imgUri={img} theme={theme} />
<Button disabled={isReply} onPress={onPress}>
<MessageImage imgUri={img} cached={cached} loading={loading} />
</Button>
);
},

View File

@ -91,7 +91,6 @@ interface IMessageReply {
timeFormat?: string;
index: number;
getCustomEmoji: TGetCustomEmoji;
messageId: string;
}
const Title = React.memo(
@ -198,7 +197,7 @@ const Fields = React.memo(
);
const Reply = React.memo(
({ attachment, timeFormat, index, getCustomEmoji, messageId }: IMessageReply) => {
({ attachment, timeFormat, index, getCustomEmoji }: IMessageReply) => {
const [loading, setLoading] = useState(false);
const { theme } = useTheme();
const { baseUrl, user, jumpToMessage } = useContext(MessageContext);
@ -257,7 +256,6 @@ const Reply = React.memo(
timeFormat={timeFormat}
style={[{ color: themes[theme].auxiliaryTintColor, fontSize: 14, marginBottom: 8 }]}
isReply
id={messageId}
/>
<Fields attachment={attachment} getCustomEmoji={getCustomEmoji} theme={theme} />
{loading ? (

View File

@ -1,11 +1,12 @@
import React, { useContext, useState } from 'react';
import { StyleProp, StyleSheet, TextStyle } from 'react-native';
import React, { useContext, useEffect, useState } from 'react';
import { StyleProp, StyleSheet, TextStyle, View, Text } from 'react-native';
import { dequal } from 'dequal';
import FastImage from 'react-native-fast-image';
import messageStyles from './styles';
import Touchable from './Touchable';
import Markdown from '../markdown';
import { isIOS } from '../../lib/methods/helpers';
import { CustomIcon } from '../CustomIcon';
import { themes } from '../../lib/constants';
import MessageContext from './Context';
import { fileDownload } from './helpers/fileDownload';
@ -13,10 +14,13 @@ import EventEmitter from '../../lib/methods/helpers/events';
import { LISTENER } from '../Toast';
import I18n from '../../i18n';
import { IAttachment } from '../../definitions/IAttachment';
import RCActivityIndicator from '../ActivityIndicator';
import { TGetCustomEmoji } from '../../definitions/IEmoji';
import { useTheme } from '../../theme';
import { formatAttachmentUrl } from '../../lib/methods/helpers/formatAttachmentUrl';
import { cancelDownload, downloadMediaFile, isDownloadActive, getMediaCache } from '../../lib/methods/handleMediaDownload';
import { fetchAutoDownloadEnabled } from '../../lib/methods/autoDownloadPreference';
import sharedStyles from '../../views/Styles';
import BlurComponent from './Components/BlurComponent';
const SUPPORTED_TYPES = ['video/quicktime', 'video/mp4', ...(isIOS ? [] : ['video/3gp', 'video/mkv'])];
const isTypeSupported = (type: string) => SUPPORTED_TYPES.indexOf(type) !== -1;
@ -29,6 +33,20 @@ const styles = StyleSheet.create({
marginBottom: 6,
alignItems: 'center',
justifyContent: 'center'
},
cancelContainer: {
position: 'absolute',
top: 8,
right: 8
},
text: {
...sharedStyles.textRegular,
fontSize: 12
},
thumbnailImage: {
borderRadius: 4,
width: '100%',
height: '100%'
}
});
@ -40,30 +58,130 @@ interface IMessageVideo {
isReply?: boolean;
}
const CancelIndicator = () => {
const { colors } = useTheme();
return (
<View style={styles.cancelContainer}>
<Text style={[styles.text, { color: colors.auxiliaryText }]}>{I18n.t('Cancel')}</Text>
</View>
);
};
// TODO: Wait backend send the thumbnailUrl as prop
const Thumbnail = ({ loading, thumbnailUrl, cached }: { loading: boolean; thumbnailUrl?: string; cached: boolean }) => (
<>
{thumbnailUrl ? <FastImage style={styles.thumbnailImage} source={{ uri: thumbnailUrl }} /> : null}
<BlurComponent
iconName={cached ? 'play-filled' : 'arrow-down-circle'}
loading={loading}
style={styles.button}
showOverlay={cached}
/>
{loading ? <CancelIndicator /> : null}
</>
);
const Video = React.memo(
({ file, showAttachment, getCustomEmoji, style, isReply }: IMessageVideo) => {
const [videoCached, setVideoCached] = useState(file);
const [loading, setLoading] = useState(true);
const [cached, setCached] = useState(false);
const { baseUrl, user } = useContext(MessageContext);
const [loading, setLoading] = useState(false);
const { theme } = useTheme();
const video = formatAttachmentUrl(file.video_url, user.id, user.token, baseUrl);
useEffect(() => {
const handleVideoSearchAndDownload = async () => {
if (video) {
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 onPress = async () => {
if (file.video_type && isTypeSupported(file.video_type) && showAttachment) {
return showAttachment(file);
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',
mimeType: file.video_type
});
setVideoCached(prev => ({
...prev,
video_url: videoUri
}));
setCached(true);
} catch {
setCached(false);
} finally {
setLoading(false);
}
};
const onPress = async () => {
if (file.video_type && cached && isTypeSupported(file.video_type) && showAttachment) {
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) {
const uri = formatAttachmentUrl(file.video_url, user.id, user.token, baseUrl);
await downloadVideo(uri);
await downloadVideoToGallery(video);
return;
}
EventEmitter.emit(LISTENER, { message: I18n.t('Unsupported_format') });
};
const downloadVideo = async (uri: string) => {
const handleCancelDownload = () => {
if (loading) {
cancelDownload(video);
setLoading(false);
}
};
const downloadVideoToGallery = async (uri: string) => {
setLoading(true);
const fileDownloaded = await fileDownload(uri, file);
setLoading(false);
@ -87,10 +205,10 @@ const Video = React.memo(
<Touchable
disabled={isReply}
onPress={onPress}
style={[styles.button, { backgroundColor: themes[theme].videoBackground }]}
style={[styles.button, messageStyles.mustWrapBlur, { backgroundColor: themes[theme].videoBackground }]}
background={Touchable.Ripple(themes[theme].bannerBackground)}
>
{loading ? <RCActivityIndicator /> : <CustomIcon name='play-filled' size={54} color={themes[theme].buttonText} />}
<Thumbnail loading={loading} cached={cached} />
</Touchable>
</>
);

View File

@ -14,7 +14,7 @@ export interface IMessageAttachments {
isReply?: boolean;
showAttachment?: (file: IAttachment) => void;
getCustomEmoji: TGetCustomEmoji;
id: string;
author?: IUserMessage;
}
export interface IMessageAvatar {

View File

@ -1,7 +1,7 @@
import { StyleSheet } from 'react-native';
import sharedStyles from '../../views/Styles';
import { isTablet } from '../../lib/methods/helpers';
import { isAndroid, isTablet } from '../../lib/methods/helpers';
export default StyleSheet.create({
root: {
@ -105,6 +105,9 @@ export default StyleSheet.create({
borderWidth: 1,
overflow: 'hidden'
},
imageBlurContainer: {
height: '100%'
},
imagePressed: {
opacity: 0.5
},
@ -168,5 +171,22 @@ export default StyleSheet.create({
threadDetails: {
flex: 1,
marginLeft: 12
},
blurView: {
position: 'absolute',
borderWidth: 0,
top: 0,
left: 0,
bottom: 0,
right: 0
},
blurIndicator: {
position: 'absolute',
justifyContent: 'center',
alignItems: 'center'
},
mustWrapBlur: {
// https://github.com/Kureev/react-native-blur/issues/520#issuecomment-1378339192 Fix BlurView
overflow: isAndroid ? 'hidden' : 'visible'
}
});

View File

@ -28,6 +28,7 @@ export interface IAttachment {
color?: string;
thumb_url?: string;
collapsed?: boolean;
audio_type?: string;
}
export interface IServerAttachment {

View File

@ -726,6 +726,13 @@
"Presence_Cap_Warning_Description": "Active connections have reached the limit for the workspace, thus the service that handles user status is disabled. It can be re-enabled manually in workspace settings.",
"Learn_more": "Learn more",
"and_N_more": "and {{count}} more",
"Media_auto_download": "Media auto-download",
"Images": "Images",
"Video": "Video",
"Wi_Fi_and_mobile_data":"Wi-Fi and mobile data",
"Wi_Fi": "Wi-Fi",
"Off": "Off",
"Audio": "Audio",
"Forward_message": "Forward message",
"Person_or_channel": "Person or channel",
"Select": "Select",

View File

@ -711,16 +711,23 @@
"Discard_changes_description": "Todas as alterações serão perdidas, se você sair sem salvar.",
"Presence_Cap_Warning_Title": "Status do usuário desabilitado temporariamente",
"Presence_Cap_Warning_Description": "O limite de conexões ativas para a workspace foi atingido, por isso o serviço responsável pela presença dos usuários está temporariamente desabilitado. Ele pode ser reabilitado manualmente nas configurações da workspace.",
"Learn_more": "Saiba mais",
"and_N_more": "e mais {{count}}",
"Media_auto_download": "Download automático de mídia",
"Images": "Imagens",
"Video": "Vídeo",
"Wi_Fi_and_mobile_data":"Wi-Fi e dados móveis",
"Wi_Fi": "Wi-Fi",
"Off": "Desativado",
"Audio": "Áudio",
"decline": "Recusar",
"accept": "Aceitar",
"Incoming_call_from": "Chamada recebida de",
"Call_started": "Chamada Iniciada",
"Forward_message": "Encaminhar mensagem",
"Person_or_channel": "Pessoa ou canal",
"Select": "Selecionar",
"Nickname": "Apelido",
"Bio": "Biografia",
"decline": "Recusar",
"accept": "Aceitar",
"Incoming_call_from": "Chamada recebida de",
"Call_started": "Chamada Iniciada",
"Learn_more": "Saiba mais",
"and_N_more": "e mais {{count}}",
"Message_has_been_shared":"Menssagem foi compartilhada"
}

View File

@ -99,6 +99,7 @@ export const colors = {
gray300: '#5f656e',
gray100: '#CBCED1',
n900: '#1F2329',
overlayColor: '#1F2329B2',
...mentions,
...callButtons
},
@ -175,6 +176,7 @@ export const colors = {
gray300: '#5f656e',
gray100: '#CBCED1',
n900: '#FFFFFF',
overlayColor: '#1F2329B2',
...mentions,
...callButtons
},
@ -251,6 +253,7 @@ export const colors = {
gray300: '#5f656e',
gray100: '#CBCED1',
n900: '#FFFFFF',
overlayColor: '#1F2329B2',
...mentions,
...callButtons
}

View File

@ -11,4 +11,5 @@ export * from './messageTypeLoad';
export * from './notifications';
export * from './defaultSettings';
export * from './tablet';
export * from './mediaAutoDownload';
export * from './userAgent';

View File

@ -0,0 +1,5 @@
export type MediaDownloadOption = 'never' | 'wifi_mobile_data' | 'wifi';
export const IMAGES_PREFERENCE_DOWNLOAD = 'imagesPreferenceDownload';
export const VIDEO_PREFERENCE_DOWNLOAD = 'videoPreferenceDownload';
export const AUDIO_PREFERENCE_DOWNLOAD = 'audioPreferenceDownload';

View File

@ -1,59 +0,0 @@
import * as FileSystem from 'expo-file-system';
import { sanitizeLikeString } from '../database/utils';
import { store } from '../store/auxStore';
import log from './helpers/log';
const DEFAULT_EXTENSION = 'mp3';
const sanitizeString = (value: string) => sanitizeLikeString(value.substring(value.lastIndexOf('/') + 1));
const getExtension = (value: string) => {
let extension = DEFAULT_EXTENSION;
const filename = value.split('/').pop();
if (filename?.includes('.')) {
extension = value.substring(value.lastIndexOf('.') + 1);
}
return extension;
};
const ensureDirAsync = async (dir: string, intermediates = true): Promise<void> => {
const info = await FileSystem.getInfoAsync(dir);
if (info.exists && info.isDirectory) {
return;
}
await FileSystem.makeDirectoryAsync(dir, { intermediates });
return ensureDirAsync(dir, intermediates);
};
export const downloadAudioFile = async (url: string, fileUrl: string, messageId: string): Promise<string> => {
let path = '';
try {
const serverUrl = store.getState().server.server;
const serverUrlParsed = sanitizeString(serverUrl);
const folderPath = `${FileSystem.documentDirectory}audios/${serverUrlParsed}`;
const filename = `${messageId}.${getExtension(fileUrl)}`;
const filePath = `${folderPath}/${filename}`;
await ensureDirAsync(folderPath);
const file = await FileSystem.getInfoAsync(filePath);
if (!file.exists) {
const downloadedFile = await FileSystem.downloadAsync(url, filePath);
path = downloadedFile.uri;
} else {
path = file.uri;
}
} catch (error) {
log(error);
}
return path;
};
export const deleteAllAudioFiles = async (serverUrl: string): Promise<void> => {
try {
const serverUrlParsed = sanitizeString(serverUrl);
const path = `${FileSystem.documentDirectory}audios/${serverUrlParsed}`;
await FileSystem.deleteAsync(path, { idempotent: true });
} catch (error) {
log(error);
}
};

View File

@ -0,0 +1,36 @@
import { NetInfoStateType } from '@react-native-community/netinfo';
import {
IMAGES_PREFERENCE_DOWNLOAD,
AUDIO_PREFERENCE_DOWNLOAD,
VIDEO_PREFERENCE_DOWNLOAD,
MediaDownloadOption
} from '../constants';
import userPreferences from './userPreferences';
import { store } from '../store/auxStore';
type TMediaType = typeof IMAGES_PREFERENCE_DOWNLOAD | typeof AUDIO_PREFERENCE_DOWNLOAD | typeof VIDEO_PREFERENCE_DOWNLOAD;
export const fetchAutoDownloadEnabled = (mediaType: TMediaType) => {
const { netInfoState } = store.getState().app;
const mediaDownloadPreference = userPreferences.getString(mediaType) as MediaDownloadOption;
if (mediaDownloadPreference === 'wifi_mobile_data') {
return true;
}
if (mediaDownloadPreference === 'wifi' && netInfoState === NetInfoStateType.wifi) {
return true;
}
if (mediaDownloadPreference === null) {
if (mediaType === 'imagesPreferenceDownload') {
return true;
}
if (mediaType === 'audioPreferenceDownload' || mediaType === 'videoPreferenceDownload') {
return netInfoState === NetInfoStateType.wifi;
}
}
return false;
};

View File

@ -0,0 +1,58 @@
import { getFilename } from './handleMediaDownload';
describe('Test the getFilename', () => {
it('returns the title without changes', () => {
const { image_type, image_url, title } = {
title: 'help-image.png',
image_url: '/file-upload/oTQmb2zRCsYF4pdHv/help-image-url.png',
image_type: 'image/png'
};
const filename = getFilename({ type: 'image', mimeType: image_type, title, url: image_url });
expect(filename).toBe(title);
});
it("returns the title with correct extension based on image_type when the title's extension is wrong", () => {
const { image_type, image_url, title } = {
title: 'help-image.MOV',
image_url: '/file-upload/oTQmb2zRCsYF4pdHv/help-image-url.MOV',
image_type: 'image/png'
};
const filename = getFilename({ type: 'image', mimeType: image_type, title, url: image_url });
expect(filename).toBe('help-image.png');
});
it("returns the filename from image_url when there isn't extension at title", () => {
const { image_type, image_url, title } = {
title: 'help-image',
image_url: '/file-upload/oTQmb2zRCsYF4pdHv/help-image-url.png',
image_type: 'image/png'
};
const filename = getFilename({ type: 'image', mimeType: image_type, title, url: image_url });
expect(filename).toBe('help-image-url.png');
});
it("returns the filename from image_url with correct extension based on image_type when there isn't extension at title and the image_url's extension is wrong", () => {
const { image_type, image_url, title } = {
title: 'help-image',
image_url: '/file-upload/oTQmb2zRCsYF4pdHv/help-image-url.MOV',
image_type: 'image/png'
};
const filename = getFilename({ type: 'image', mimeType: image_type, title, url: image_url });
expect(filename).toBe('help-image-url.png');
});
it("returns the filename from image_url and based on the image_type when there isn't extension either at title and image_url", () => {
const { image_type, image_url, title } = {
title: 'help-image',
image_url: '/file-upload/oTQmb2zRCsYF4pdHv/help-image-url.png',
image_type: 'image/png'
};
const filename = getFilename({ type: 'image', mimeType: image_type, title, url: image_url });
expect(filename).toBe('help-image-url.png');
});
});

View File

@ -0,0 +1,203 @@
import * as FileSystem from 'expo-file-system';
import * as mime from 'react-native-mime-types';
import { isEmpty } from 'lodash';
import { sanitizeLikeString } from '../database/utils';
import { store } from '../store/auxStore';
import log from './helpers/log';
export type MediaTypes = 'audio' | 'image' | 'video';
const defaultType = {
audio: 'mp3',
image: 'jpg',
video: 'mp4'
};
export const LOCAL_DOCUMENT_DIRECTORY = FileSystem.documentDirectory;
const sanitizeString = (value: string) => {
const urlWithoutQueryString = value.split('?')[0];
return sanitizeLikeString(urlWithoutQueryString.substring(urlWithoutQueryString.lastIndexOf('/') + 1));
};
const serverUrlParsedAsPath = (serverURL: string) => `${sanitizeString(serverURL)}/`;
export const getFilename = ({
title,
url,
type,
mimeType
}: {
title?: string;
url?: string;
type: MediaTypes;
mimeType?: string;
}) => {
const isTitleTyped = mime.lookup(title);
const extension = getExtension(type, mimeType);
if (isTitleTyped && title) {
if (isTitleTyped === mimeType) {
return title;
}
// removing any character sequence after the last dot
const filenameWithoutWrongExtension = title.replace(/\.\w+$/, '');
return `${filenameWithoutWrongExtension}.${extension}`;
}
const filenameFromUrl = url?.substring(url.lastIndexOf('/') + 1);
const isFileNameFromUrlTyped = mime.lookup(filenameFromUrl);
if (isFileNameFromUrlTyped && filenameFromUrl) {
if (isFileNameFromUrlTyped === mimeType) {
return filenameFromUrl;
}
// removing any character sequence after the last dot
const filenameWithoutWrongExtension = filenameFromUrl.replace(/\.\w+$/, '');
return `${filenameWithoutWrongExtension}.${extension}`;
}
return `${filenameFromUrl}.${extension}`;
};
const getExtension = (type: MediaTypes, mimeType?: string) => {
if (!mimeType) {
return defaultType[type];
}
// The library is returning mpag instead of mp3 for audio/mpeg
if (mimeType === 'audio/mpeg') {
return 'mp3';
}
// Audios sent by Android devices are in the audio/aac format, which cannot be converted to mp3 by iOS.
// However, both platforms support the m4a format, so they can maintain the same behavior.
if (mimeType === 'audio/aac') {
return 'm4a';
}
// The return of mime.extension('video/quicktime') is .qt,
// this format the iOS isn't recognize and can't save on gallery
if (mimeType === 'video/quicktime') {
return 'mov';
}
const extension = mime.extension(mimeType);
// The mime.extension can return false when there aren't any extension
if (!extension) {
return defaultType[type];
}
return extension;
};
const ensureDirAsync = async (dir: string, intermediates = true): Promise<void> => {
const info = await FileSystem.getInfoAsync(dir);
if (info.exists && info.isDirectory) {
return;
}
await FileSystem.makeDirectoryAsync(dir, { intermediates });
return ensureDirAsync(dir, intermediates);
};
const getFilePath = ({ type, mimeType, urlToCache }: { type: MediaTypes; mimeType?: string; urlToCache?: string }) => {
if (!urlToCache) {
return;
}
const folderPath = getFolderPath();
const fileUrlSanitized = sanitizeString(urlToCache);
const filename = `${fileUrlSanitized}.${getExtension(type, mimeType)}`;
const filePath = `${folderPath}${filename}`;
return filePath;
};
const getFolderPath = () => {
const serverUrl = store.getState().server.server;
const serverUrlParsed = serverUrlParsedAsPath(serverUrl);
const folderPath = `${LOCAL_DOCUMENT_DIRECTORY}${serverUrlParsed}`;
return folderPath;
};
export const getFileInfoAsync = async (filePath: string) => {
const file = await FileSystem.getInfoAsync(filePath);
return file;
};
export const getMediaCache = async ({
type,
mimeType,
urlToCache
}: {
type: MediaTypes;
mimeType?: string;
urlToCache?: string;
}) => {
if (!urlToCache) {
return null;
}
try {
const folderPath = getFolderPath();
const filePath = getFilePath({ type, mimeType, urlToCache });
if (!filePath) {
return null;
}
await ensureDirAsync(folderPath);
const file = await getFileInfoAsync(filePath);
return file;
} catch (error) {
log(error);
return null;
}
};
export const deleteMediaFiles = async (serverUrl: string): Promise<void> => {
try {
const serverUrlParsed = serverUrlParsedAsPath(serverUrl);
const path = `${LOCAL_DOCUMENT_DIRECTORY}${serverUrlParsed}`;
await FileSystem.deleteAsync(path, { idempotent: true });
} catch (error) {
log(error);
}
};
const downloadQueue: { [index: string]: FileSystem.DownloadResumable } = {};
export const mediaDownloadKey = (messageUrl: string) => `${sanitizeString(messageUrl)}`;
export function isDownloadActive(messageUrl: string): boolean {
return !!downloadQueue[mediaDownloadKey(messageUrl)];
}
export async function cancelDownload(messageUrl: string): Promise<void> {
const downloadKey = mediaDownloadKey(messageUrl);
if (!isEmpty(downloadQueue[downloadKey])) {
try {
await downloadQueue[downloadKey].cancelAsync();
} catch {
// Do nothing
}
delete downloadQueue[downloadKey];
}
}
export function downloadMediaFile({
type,
mimeType,
downloadUrl
}: {
type: MediaTypes;
mimeType?: string;
downloadUrl: string;
}): Promise<string> {
return new Promise(async (resolve, reject) => {
try {
const path = getFilePath({ type, mimeType, urlToCache: downloadUrl });
if (!path) {
reject();
return;
}
const downloadKey = mediaDownloadKey(downloadUrl);
downloadQueue[downloadKey] = FileSystem.createDownloadResumable(downloadUrl, path);
const result = await downloadQueue[downloadKey].downloadAsync();
if (result?.uri) {
return resolve(result.uri);
}
reject();
} catch {
reject();
}
});
}

View File

@ -1,4 +1,9 @@
import { LOCAL_DOCUMENT_DIRECTORY } from '../handleMediaDownload';
export const formatAttachmentUrl = (attachmentUrl: string | undefined, userId: string, token: string, server: string): string => {
if (LOCAL_DOCUMENT_DIRECTORY && attachmentUrl?.startsWith(LOCAL_DOCUMENT_DIRECTORY)) {
return attachmentUrl;
}
if (attachmentUrl && attachmentUrl.startsWith('http')) {
if (attachmentUrl.includes('rc_token')) {
return encodeURI(attachmentUrl);

View File

@ -4,6 +4,7 @@ import createSagaMiddleware from 'redux-saga';
import reducers from '../../reducers';
import sagas from '../../sagas';
import applyAppStateMiddleware from './appStateMiddleware';
import applyInternetStateMiddleware from './internetStateMiddleware';
let sagaMiddleware;
let enhancers;
@ -17,6 +18,7 @@ if (__DEV__) {
enhancers = compose(
applyAppStateMiddleware(),
applyInternetStateMiddleware(),
applyMiddleware(reduxImmutableStateInvariant),
applyMiddleware(sagaMiddleware),
Reactotron.createEnhancer()

View File

@ -0,0 +1,18 @@
import NetInfo, { NetInfoState, NetInfoStateType } from '@react-native-community/netinfo';
import { setNetInfoState } from '../../actions/app';
export default () =>
(createStore: any) =>
(...args: any) => {
const store = createStore(...args);
let currentType: NetInfoStateType | undefined;
const handleInternetStateChange = (nextState: NetInfoState) => {
if (nextState.type !== currentType) {
store.dispatch(setNetInfoState(nextState.type));
currentType = nextState.type;
}
};
NetInfo.addEventListener(handleInternetStateChange);
return store;
};

View File

@ -1,3 +1,5 @@
import { NetInfoStateType } from '@react-native-community/netinfo';
import { TActionApp } from '../actions/app';
import { RootEnum } from '../definitions';
import { APP, APP_STATE } from '../actions/actionsTypes';
@ -10,6 +12,7 @@ export interface IApp {
foreground: boolean;
background: boolean;
notificationPresenceCap: boolean;
netInfoState?: NetInfoStateType | null;
}
export const initialState: IApp = {
@ -19,7 +22,8 @@ export const initialState: IApp = {
ready: false,
foreground: true,
background: false,
notificationPresenceCap: false
notificationPresenceCap: false,
netInfoState: null
};
export default function app(state = initialState, action: TActionApp): IApp {
@ -62,6 +66,11 @@ export default function app(state = initialState, action: TActionApp): IApp {
...state,
notificationPresenceCap: action.show
};
case APP.SET_NET_INFO_STATE:
return {
...state,
netInfoState: action.netInfoState
};
default:
return state;
}

View File

@ -46,6 +46,7 @@ import LanguageView from '../views/LanguageView';
import ThemeView from '../views/ThemeView';
import DefaultBrowserView from '../views/DefaultBrowserView';
import ScreenLockConfigView from '../views/ScreenLockConfigView';
import MediaAutoDownloadView from '../views/MediaAutoDownloadView';
// Admin Stack
import AdminPanelView from '../views/AdminPanelView';
// NewMessage Stack
@ -179,6 +180,7 @@ const SettingsStackNavigator = () => {
<SettingsStack.Screen name='LanguageView' component={LanguageView} />
<SettingsStack.Screen name='ThemeView' component={ThemeView} />
<SettingsStack.Screen name='DefaultBrowserView' component={DefaultBrowserView} />
<SettingsStack.Screen name='MediaAutoDownloadView' component={MediaAutoDownloadView} />
<SettingsStack.Screen
name='ScreenLockConfigView'
component={ScreenLockConfigView}

View File

@ -50,6 +50,7 @@ import CreateChannelView from '../../views/CreateChannelView';
import UserPreferencesView from '../../views/UserPreferencesView';
import UserNotificationPrefView from '../../views/UserNotificationPreferencesView';
import SecurityPrivacyView from '../../views/SecurityPrivacyView';
import MediaAutoDownloadView from '../../views/MediaAutoDownloadView';
import E2EEncryptionSecurityView from '../../views/E2EEncryptionSecurityView';
// InsideStackNavigator
import AttachmentView from '../../views/AttachmentView';
@ -190,6 +191,7 @@ const ModalStackNavigator = React.memo(({ navigation }: INavigation) => {
<ModalStack.Screen name='UserPreferencesView' component={UserPreferencesView} />
<ModalStack.Screen name='UserNotificationPrefView' component={UserNotificationPrefView} />
<ModalStack.Screen name='SecurityPrivacyView' component={SecurityPrivacyView} />
<ModalStack.Screen name='MediaAutoDownloadView' component={MediaAutoDownloadView} />
<ModalStack.Screen name='E2EEncryptionSecurityView' component={E2EEncryptionSecurityView} />
</ModalStack.Navigator>
</ModalContainer>

View File

@ -195,6 +195,7 @@ export type ModalStackParamList = {
UserPreferencesView: undefined;
UserNotificationPrefView: undefined;
SecurityPrivacyView: undefined;
MediaAutoDownloadView: undefined;
E2EEncryptionSecurityView: undefined;
};

View File

@ -209,6 +209,7 @@ export type SettingsStackParamList = {
ScreenLockConfigView: undefined;
ProfileView: undefined;
DisplayPrefsView: undefined;
MediaAutoDownloadView: undefined;
};
export type AdminPanelStackParamList = {

View File

@ -2,10 +2,8 @@ import { CameraRoll } from '@react-native-camera-roll/camera-roll';
import { HeaderBackground, useHeaderHeight } from '@react-navigation/elements';
import { StackNavigationOptions } from '@react-navigation/stack';
import { ResizeMode, Video } from 'expo-av';
import { sha256 } from 'js-sha256';
import React from 'react';
import { PermissionsAndroid, View, useWindowDimensions } from 'react-native';
import * as mime from 'react-native-mime-types';
import { PermissionsAndroid, useWindowDimensions, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { shallowEqual } from 'react-redux';
import RNFetchBlob from 'rn-fetch-blob';
@ -24,6 +22,7 @@ import EventEmitter from '../lib/methods/helpers/events';
import { getUserSelector } from '../selectors/login';
import { TNavigation } from '../stacks/stackType';
import { useTheme } from '../theme';
import { LOCAL_DOCUMENT_DIRECTORY, getFilename } from '../lib/methods/handleMediaDownload';
const RenderContent = ({
setLoading,
@ -145,13 +144,13 @@ const AttachmentView = (): React.ReactElement => {
const handleSave = async () => {
const { title_link, image_url, image_type, video_url, video_type } = attachment;
const url = title_link || image_url || video_url;
// When the attachment is a video, the video_url refers to local file and the title_link to the link
const url = video_url || title_link || image_url;
if (!url) {
return;
}
const mediaAttachment = formatAttachmentUrl(url, user.id, user.token, baseUrl);
if (isAndroid) {
const rationale = {
title: I18n.t('Write_External_Permission'),
@ -166,16 +165,22 @@ const AttachmentView = (): React.ReactElement => {
setLoading(true);
try {
const extension = image_url
? `.${mime.extension(image_type) || 'jpg'}`
: `.${(video_type === 'video/quicktime' && 'mov') || mime.extension(video_type) || 'mp4'}`;
// The return of mime.extension('video/quicktime') is .qt,
// this format the iOS isn't recognize and can't save on gallery
const documentDir = `${RNFetchBlob.fs.dirs.DocumentDir}/`;
const path = `${documentDir + sha256(url) + extension}`;
const file = await RNFetchBlob.config({ path }).fetch('GET', mediaAttachment);
await CameraRoll.save(path, { album: 'Rocket.Chat' });
file.flush();
if (LOCAL_DOCUMENT_DIRECTORY && url.startsWith(LOCAL_DOCUMENT_DIRECTORY)) {
await CameraRoll.save(url, { album: 'Rocket.Chat' });
} else {
const mediaAttachment = formatAttachmentUrl(url, user.id, user.token, baseUrl);
let filename = '';
if (image_url) {
filename = getFilename({ title: attachment.title, type: 'image', mimeType: image_type, url });
} else {
filename = getFilename({ title: attachment.title, type: 'video', mimeType: video_type, url });
}
const documentDir = `${RNFetchBlob.fs.dirs.DocumentDir}/`;
const path = `${documentDir + filename}`;
const file = await RNFetchBlob.config({ path }).fetch('GET', mediaAttachment);
await CameraRoll.save(path, { album: 'Rocket.Chat' });
file.flush();
}
EventEmitter.emit(LISTENER, { message: I18n.t('saved_to_gallery') });
} catch (e) {
EventEmitter.emit(LISTENER, { message: I18n.t(image_url ? 'error-save-image' : 'error-save-video') });

View File

@ -0,0 +1,74 @@
import React from 'react';
import { StyleSheet, Text } from 'react-native';
import { TActionSheetOptionsItem, useActionSheet } from '../../containers/ActionSheet';
import { CustomIcon } from '../../containers/CustomIcon';
import * as List from '../../containers/List';
import I18n from '../../i18n';
import { useTheme } from '../../theme';
import sharedStyles from '../Styles';
import { MediaDownloadOption } from '../../lib/constants';
const styles = StyleSheet.create({
title: { ...sharedStyles.textRegular, fontSize: 16 }
});
type TOPTIONS = { label: string; value: MediaDownloadOption }[];
const OPTIONS: TOPTIONS = [
{
label: 'Wi_Fi_and_mobile_data',
value: 'wifi_mobile_data'
},
{
label: 'Wi_Fi',
value: 'wifi'
},
{
label: 'Never',
value: 'never'
}
];
interface IBaseParams {
value: string;
onChangeValue: (value: MediaDownloadOption) => void;
}
const ListPicker = ({
value,
title,
onChangeValue
}: {
title: string;
} & IBaseParams) => {
const { showActionSheet, hideActionSheet } = useActionSheet();
const { colors } = useTheme();
const option = OPTIONS.find(option => option.value === value) || OPTIONS[2];
const getOptions = (): TActionSheetOptionsItem[] =>
OPTIONS.map(i => ({
title: I18n.t(i.label, { defaultValue: i.label }),
onPress: () => {
hideActionSheet();
onChangeValue(i.value);
},
right: option.value === i.value ? () => <CustomIcon name={'check'} size={20} color={colors.tintActive} /> : undefined
}));
return (
<List.Item
title={title}
onPress={() => showActionSheet({ options: getOptions() })}
right={() => (
<Text style={[styles.title, { color: colors.actionTintColor }]}>
{/* when picking an option the label should be Never
but when showing among the other settings the label should be Off */}
{option.label === 'Never' ? I18n.t('Off') : I18n.t(option.label)}
</Text>
)}
/>
);
};
export default ListPicker;

View File

@ -0,0 +1,50 @@
import React, { useLayoutEffect } from 'react';
import { useNavigation } from '@react-navigation/native';
import { StackNavigationProp } from '@react-navigation/stack';
import * as List from '../../containers/List';
import SafeAreaView from '../../containers/SafeAreaView';
import StatusBar from '../../containers/StatusBar';
import ListPicker from './ListPicker';
import { useUserPreferences } from '../../lib/methods/userPreferences';
import {
AUDIO_PREFERENCE_DOWNLOAD,
IMAGES_PREFERENCE_DOWNLOAD,
MediaDownloadOption,
VIDEO_PREFERENCE_DOWNLOAD
} from '../../lib/constants';
import i18n from '../../i18n';
import { SettingsStackParamList } from '../../stacks/types';
const MediaAutoDownload = () => {
const [imagesPreference, setImagesPreference] = useUserPreferences<MediaDownloadOption>(
IMAGES_PREFERENCE_DOWNLOAD,
'wifi_mobile_data'
);
const [videoPreference, setVideoPreference] = useUserPreferences<MediaDownloadOption>(VIDEO_PREFERENCE_DOWNLOAD, 'wifi');
const [audioPreference, setAudioPreference] = useUserPreferences<MediaDownloadOption>(AUDIO_PREFERENCE_DOWNLOAD, 'wifi');
const navigation = useNavigation<StackNavigationProp<SettingsStackParamList, 'MediaAutoDownloadView'>>();
useLayoutEffect(() => {
navigation.setOptions({
title: i18n.t('Media_auto_download')
});
}, [navigation]);
return (
<SafeAreaView>
<StatusBar />
<List.Container>
<List.Section>
<ListPicker onChangeValue={setImagesPreference} value={imagesPreference} title='Images' />
<List.Separator />
<ListPicker onChangeValue={setVideoPreference} value={videoPreference} title='Video' />
<List.Separator />
<ListPicker onChangeValue={setAudioPreference} value={audioPreference} title='Audio' />
</List.Section>
</List.Container>
</SafeAreaView>
);
};
export default MediaAutoDownload;

View File

@ -1,12 +1,12 @@
export default function (message: { type: string; url: string }) {
if (/image/.test(message.type)) {
return { image_url: message.url };
return { image_url: message.url, image_type: message.type };
}
if (/audio/.test(message.type)) {
return { audio_url: message.url };
return { audio_url: message.url, audio_type: message.type };
}
if (/video/.test(message.type)) {
return { video_url: message.url };
return { video_url: message.url, video_type: message.type };
}
return {
title_link: message.url,

View File

@ -10,7 +10,7 @@ import Message from '../../containers/message';
import ActivityIndicator from '../../containers/ActivityIndicator';
import I18n from '../../i18n';
import StatusBar from '../../containers/StatusBar';
import getFileUrlFromMessage from './getFileUrlFromMessage';
import getFileUrlAndTypeFromMessage from './getFileUrlAndTypeFromMessage';
import { themes } from '../../lib/constants';
import { TSupportedThemes, withTheme } from '../../theme';
import { getUserSelector } from '../../selectors/login';
@ -204,7 +204,7 @@ class MessagesView extends React.Component<IMessagesViewProps, IMessagesViewStat
{
title: item.name,
description: item.description,
...getFileUrlFromMessage(item)
...getFileUrlAndTypeFromMessage(item)
}
]
}}

View File

@ -21,7 +21,7 @@ import { APP_STORE_LINK, FDROID_MARKET_LINK, isFDroidBuild, LICENSE_LINK, PLAY_M
import database from '../../lib/database';
import { useAppSelector } from '../../lib/hooks';
import { clearCache } from '../../lib/methods';
import { deleteAllAudioFiles } from '../../lib/methods/audioFile';
import { deleteMediaFiles } from '../../lib/methods/handleMediaDownload';
import { getDeviceModel, getReadableVersion, isAndroid } from '../../lib/methods/helpers';
import EventEmitter from '../../lib/methods/helpers/events';
import { showConfirmationAlert, showErrorAlert } from '../../lib/methods/helpers/info';
@ -99,7 +99,7 @@ const SettingsView = (): React.ReactElement => {
confirmationText: I18n.t('Clear'),
onPress: async () => {
dispatch(appStart({ root: RootEnum.ROOT_LOADING, text: I18n.t('Clear_cache_loading') }));
await deleteAllAudioFiles(server);
await deleteMediaFiles(server);
await clearCache({ server });
await FastImage.clearMemoryCache();
await FastImage.clearDiskCache();
@ -224,6 +224,13 @@ const SettingsView = (): React.ReactElement => {
testID='settings-view-theme'
/>
<List.Separator />
<List.Item
title='Media_auto_download'
showActionIndicator
onPress={() => navigateToScreen('MediaAutoDownloadView')}
testID='settings-view-media-auto-download'
/>
<List.Separator />
<List.Item
title='Security_and_privacy'
showActionIndicator

View File

@ -61,6 +61,10 @@ describe('Settings screen', () => {
await expect(element(by.id('settings-view-security-privacy'))).toExist();
});
it('should have media auto-download', async () => {
await expect(element(by.id('settings-view-media-auto-download'))).toExist();
});
it('should have licence', async () => {
await expect(element(by.id('settings-view-license'))).toExist();
});

View File

@ -383,9 +383,9 @@ PODS:
- glog
- react-native-background-timer (2.4.1):
- React-Core
- react-native-blur (4.1.0):
- react-native-blur (4.3.2):
- React-Core
- react-native-cameraroll (5.6.0):
- react-native-cameraroll (5.7.2):
- React-Core
- react-native-cookies (6.2.1):
- React-Core
@ -943,8 +943,8 @@ SPEC CHECKSUMS:
React-jsinspector: 9885f6f94d231b95a739ef7bb50536fb87ce7539
React-logger: 3f8ebad1be1bf3299d1ab6d7f971802d7395c7ef
react-native-background-timer: 17ea5e06803401a379ebf1f20505b793ac44d0fe
react-native-blur: ba2f37268542f8a26d809f48c5162705a3261fc6
react-native-cameraroll: 755bcc628148a90a7c9cf3f817a252be3a601bc5
react-native-blur: cfdad7b3c01d725ab62a8a729f42ea463998afa2
react-native-cameraroll: 134805127580aed23403b8c2cb1548920dd77b3a
react-native-cookies: f54fcded06bb0cda05c11d86788020b43528a26c
react-native-document-picker: f5ec1a712ca2a975c233117f044817bb8393cad4
react-native-mmkv-storage: cfb6854594cfdc5f7383a9e464bb025417d1721c

View File

@ -43,10 +43,10 @@
"@hookform/resolvers": "^2.9.10",
"@nozbe/watermelondb": "^0.25.5",
"@react-native-async-storage/async-storage": "^1.17.11",
"@react-native-camera-roll/camera-roll": "^5.6.0",
"@react-native-camera-roll/camera-roll": "^5.7.2",
"@react-native-clipboard/clipboard": "^1.8.5",
"@react-native-community/art": "^1.2.0",
"@react-native-community/blur": "^4.1.0",
"@react-native-community/blur": "^4.3.2",
"@react-native-community/datetimepicker": "^6.7.5",
"@react-native-community/hooks": "3.0.0",
"@react-native-community/netinfo": "6.0.0",

View File

@ -4628,10 +4628,10 @@
dependencies:
merge-options "^3.0.4"
"@react-native-camera-roll/camera-roll@^5.6.0":
version "5.6.0"
resolved "https://registry.yarnpkg.com/@react-native-camera-roll/camera-roll/-/camera-roll-5.6.0.tgz#385082d57d694f3fd5ae386f8b8ce24b0969c5f9"
integrity sha512-a/GYwnBTxj1yKWB9m/qy8GzjowSocML8NbLT81wdMh0JzZYXCLze51BR2cb8JNDgRPzA9xe7KpD3j9qQOSOjag==
"@react-native-camera-roll/camera-roll@^5.7.2":
version "5.7.2"
resolved "https://registry.yarnpkg.com/@react-native-camera-roll/camera-roll/-/camera-roll-5.7.2.tgz#db11525ae26c8a61630c424aebd323a7c784a921"
integrity sha512-s8VAUG1Kvi+tEJkLHObmOJdXAL/uclnXJ/IdnJtx2fCKiWA3Ho0ln9gDQqCYHHHHu+sXk7wovsH/I2/AYy0brg==
"@react-native-clipboard/clipboard@^1.8.5":
version "1.8.5"
@ -4647,10 +4647,10 @@
invariant "^2.2.4"
prop-types "^15.7.2"
"@react-native-community/blur@^4.1.0":
version "4.1.0"
resolved "https://registry.yarnpkg.com/@react-native-community/blur/-/blur-4.1.0.tgz#ed1361a569150c2249aae9b734e278fd262b70cd"
integrity sha512-esfuAjbAoeysfI3RhmCHlYwlXobXzcsVGZEHgDhVGB88aO9RktY6b13mYbo2FXZ8XnntcccuvXlgckvoIsggWg==
"@react-native-community/blur@^4.3.2":
version "4.3.2"
resolved "https://registry.yarnpkg.com/@react-native-community/blur/-/blur-4.3.2.tgz#185a2c7dd03ba168cc95069bc4742e9505fd6c6c"
integrity sha512-0ID+pyZKdC4RdgC7HePxUQ6JmsbNrgz03u+6SgqYpmBoK/rE+7JffqIw7IEsfoKitLEcRNLGekIBsfwCqiEkew==
"@react-native-community/cli-clean@^10.1.1":
version "10.1.1"