Implementing the Audio Playback/Download on the chat (#7900)
* feat: added new check for isAudio and added the supported mime types * feat: adding the progress and audio on the audio file message * feat: finishing the layout of the audio_file * feat: play and pause audio * feat: update the progress bar when audio is playing * feat: update the timeframe of the audio * feat: update with the new design * feat: adding download and preview * feat: creates a hook for the file download and preview * feat: adding useCallback to make the return stable * fix: iOS issue when playing in loop * feat: adding localization for the error * fix: removing code tha was inserted for debug * feat: add a new line on the en.json * fix: fixing types * feat: adding the onSeek method inside the progress bar * feat: changing progress value to animated value * feat: changing to GestureDetector and making the seek method work * feat: adding a touchable without feedback to prevent the audio to triggering other page * feat: adding the drag on the seek method * feat: making the download button more gray * fix: fix tests * fix: prevent onProgress from clearing the seconds when the audio is paused * feat: clamping the position of the cursor to never be dragged offscreen * feat: enhancing the experience of dragging the cursor on the progress bar * feat: differentiate between the throttles * feat: remvoing the aac audio files support * feat: pausing if the focus has changed * feat: render differently the audio file when in the files list * refactor: optimize audio file component by using useCallback for event handlers * refactor: extract stopPropagation function for better readability in AudioFile component * feat: implement custom useThrottled hook and replace lodash throttle in AudioFile component * refactor: update useThrottled hook to accept a generic callback type * fix: loading of uri of audio files after merge * feat: add audioFile style to enhance layout for audio files * fix: tests
This commit is contained in:
parent
4a9678ed01
commit
8e854a8bdd
11 changed files with 581 additions and 180 deletions
|
|
@ -2,12 +2,13 @@
|
|||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Button} from '@rneui/base';
|
||||
import React, {useCallback, useRef, useState} from 'react';
|
||||
import React, {useCallback, useRef} from 'react';
|
||||
import {StyleSheet, View} from 'react-native';
|
||||
|
||||
import Document, {type DocumentRef} from '@components/document';
|
||||
import ProgressBar from '@components/progress_bar';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {useDownloadFileAndPreview} from '@hooks/files';
|
||||
|
||||
import BookmarkDetails from './bookmark_details';
|
||||
|
||||
|
|
@ -33,9 +34,9 @@ const styles = StyleSheet.create({
|
|||
});
|
||||
|
||||
const BookmarkDocument = ({bookmark, canDownloadFiles, file, onLongPress}: Props) => {
|
||||
const [progress, setProgress] = useState(0);
|
||||
const document = useRef<DocumentRef>(null);
|
||||
const theme = useTheme();
|
||||
const {progress, toggleDownloadAndPreview} = useDownloadFileAndPreview();
|
||||
|
||||
const handlePress = useCallback(async () => {
|
||||
if (document.current) {
|
||||
|
|
@ -47,7 +48,7 @@ const BookmarkDocument = ({bookmark, canDownloadFiles, file, onLongPress}: Props
|
|||
<Document
|
||||
canDownloadFiles={canDownloadFiles}
|
||||
file={file.toFileInfo(bookmark.ownerId)}
|
||||
onProgress={setProgress}
|
||||
downloadAndPreviewFile={toggleDownloadAndPreview}
|
||||
ref={document}
|
||||
>
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -1,22 +1,10 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {deleteAsync} from 'expo-file-system';
|
||||
import {forwardRef, useImperativeHandle, useRef, useState, type ReactNode, useCallback} from 'react';
|
||||
import {forwardRef, useImperativeHandle, type ReactNode, useCallback} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {Platform, StatusBar, type StatusBarStyle} from 'react-native';
|
||||
import FileViewer from 'react-native-file-viewer';
|
||||
import tinyColor from 'tinycolor2';
|
||||
|
||||
import {downloadFile} from '@actions/remote/file';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {alertDownloadDocumentDisabled, alertDownloadFailed, alertFailedToOpenDocument} from '@utils/document';
|
||||
import {getFullErrorMessage, isErrorWithMessage} from '@utils/errors';
|
||||
import {fileExists, getLocalFilePathFromFile} from '@utils/file';
|
||||
import {logDebug} from '@utils/log';
|
||||
|
||||
import type {ClientResponse, ProgressPromise} from '@mattermost/react-native-network-client';
|
||||
import {alertDownloadDocumentDisabled} from '@utils/document';
|
||||
|
||||
export type DocumentRef = {
|
||||
handlePreviewPress: () => void;
|
||||
|
|
@ -26,113 +14,11 @@ type DocumentProps = {
|
|||
canDownloadFiles: boolean;
|
||||
file: FileInfo;
|
||||
children: ReactNode;
|
||||
onProgress: (progress: number) => void;
|
||||
downloadAndPreviewFile: (file: FileInfo) => void;
|
||||
}
|
||||
|
||||
const Document = forwardRef<DocumentRef, DocumentProps>(({canDownloadFiles, children, onProgress, file}: DocumentProps, ref) => {
|
||||
const Document = forwardRef<DocumentRef, DocumentProps>(({canDownloadFiles, children, downloadAndPreviewFile, file}: DocumentProps, ref) => {
|
||||
const intl = useIntl();
|
||||
const serverUrl = useServerUrl();
|
||||
const theme = useTheme();
|
||||
const [didCancel, setDidCancel] = useState(false);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [preview, setPreview] = useState(false);
|
||||
const downloadTask = useRef<ProgressPromise<ClientResponse>>();
|
||||
|
||||
const cancelDownload = () => {
|
||||
setDidCancel(true);
|
||||
if (downloadTask.current?.cancel) {
|
||||
downloadTask.current.cancel();
|
||||
}
|
||||
};
|
||||
|
||||
const downloadAndPreviewFile = useCallback(async () => {
|
||||
setDidCancel(false);
|
||||
let path;
|
||||
let exists = false;
|
||||
|
||||
try {
|
||||
path = file.localPath || '';
|
||||
if (path) {
|
||||
exists = await fileExists(path);
|
||||
}
|
||||
|
||||
if (!exists) {
|
||||
path = getLocalFilePathFromFile(serverUrl, file);
|
||||
exists = await fileExists(path);
|
||||
}
|
||||
|
||||
if (exists) {
|
||||
openDocument();
|
||||
} else {
|
||||
setDownloading(true);
|
||||
downloadTask.current = downloadFile(serverUrl, file.id!, path!);
|
||||
downloadTask.current?.progress?.(onProgress);
|
||||
|
||||
await downloadTask.current;
|
||||
onProgress(1);
|
||||
openDocument();
|
||||
}
|
||||
} catch (error) {
|
||||
if (path) {
|
||||
deleteAsync(path, {idempotent: true});
|
||||
}
|
||||
setDownloading(false);
|
||||
onProgress(0);
|
||||
|
||||
if (!isErrorWithMessage(error) || error.message !== 'cancelled') {
|
||||
logDebug('error on downloadAndPreviewFile', getFullErrorMessage(error));
|
||||
alertDownloadFailed(intl);
|
||||
}
|
||||
}
|
||||
}, [file, onProgress]);
|
||||
|
||||
const setStatusBarColor = useCallback((style: StatusBarStyle = 'light-content') => {
|
||||
if (Platform.OS === 'ios') {
|
||||
if (style) {
|
||||
StatusBar.setBarStyle(style, true);
|
||||
} else {
|
||||
const headerColor = tinyColor(theme.sidebarHeaderBg);
|
||||
let barStyle: StatusBarStyle = 'light-content';
|
||||
if (headerColor.isLight() && Platform.OS === 'ios') {
|
||||
barStyle = 'dark-content';
|
||||
}
|
||||
StatusBar.setBarStyle(barStyle, true);
|
||||
}
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
const openDocument = useCallback(async () => {
|
||||
if (!didCancel && !preview) {
|
||||
let path = file.localPath || '';
|
||||
let exists = false;
|
||||
if (path) {
|
||||
exists = await fileExists(path);
|
||||
}
|
||||
|
||||
if (!exists) {
|
||||
path = getLocalFilePathFromFile(serverUrl, file);
|
||||
}
|
||||
|
||||
setPreview(true);
|
||||
setStatusBarColor('dark-content');
|
||||
FileViewer.open(path!.replace('file://', ''), {
|
||||
displayName: file.name,
|
||||
onDismiss: onDonePreviewingFile,
|
||||
showOpenWithDialog: true,
|
||||
showAppsSuggestions: true,
|
||||
}).then(() => {
|
||||
setDownloading(false);
|
||||
onProgress(0);
|
||||
}).catch(() => {
|
||||
alertFailedToOpenDocument(file, intl);
|
||||
onDonePreviewingFile();
|
||||
|
||||
if (path) {
|
||||
deleteAsync(path, {idempotent: true});
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [didCancel, preview, file, onProgress, setStatusBarColor]);
|
||||
|
||||
const handlePreviewPress = useCallback(async () => {
|
||||
if (!canDownloadFiles) {
|
||||
|
|
@ -140,21 +26,8 @@ const Document = forwardRef<DocumentRef, DocumentProps>(({canDownloadFiles, chil
|
|||
return;
|
||||
}
|
||||
|
||||
if (downloading) {
|
||||
onProgress(0);
|
||||
cancelDownload();
|
||||
setDownloading(false);
|
||||
} else {
|
||||
downloadAndPreviewFile();
|
||||
}
|
||||
}, [canDownloadFiles, downloadAndPreviewFile, downloading, intl, onProgress, openDocument]);
|
||||
|
||||
const onDonePreviewingFile = () => {
|
||||
onProgress(0);
|
||||
setDownloading(false);
|
||||
setPreview(false);
|
||||
setStatusBarColor();
|
||||
};
|
||||
downloadAndPreviewFile(file);
|
||||
}, [canDownloadFiles, downloadAndPreviewFile, intl, file]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
handlePreviewPress,
|
||||
|
|
|
|||
226
app/components/files/audio_file.tsx
Normal file
226
app/components/files/audio_file.tsx
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {
|
||||
View,
|
||||
TouchableOpacity,
|
||||
Text,
|
||||
TouchableWithoutFeedback,
|
||||
type GestureResponderEvent,
|
||||
} from 'react-native';
|
||||
import Video, {type OnLoadData, type OnProgressData, type OnVideoErrorData, type VideoRef} from 'react-native-video';
|
||||
|
||||
import {useTheme} from '@context/theme';
|
||||
import {useDownloadFileAndPreview} from '@hooks/files';
|
||||
import useThrottled from '@hooks/throttled';
|
||||
import {alertDownloadDocumentDisabled} from '@utils/document';
|
||||
import {logDebug} from '@utils/log';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
import CompassIcon from '../compass_icon';
|
||||
import FormattedText from '../formatted_text';
|
||||
import ProgressBar from '../progress_bar';
|
||||
|
||||
type Props = {
|
||||
file: FileInfo;
|
||||
canDownloadFiles: boolean;
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||
audioFileWrapper: {
|
||||
position: 'relative',
|
||||
borderColor: changeOpacity(theme.centerChannelColor, 0.2),
|
||||
borderRadius: 4,
|
||||
borderWidth: 1,
|
||||
padding: 12,
|
||||
overflow: 'hidden',
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
playButton: {
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: changeOpacity(theme.buttonBg, 0.12),
|
||||
borderRadius: 100,
|
||||
width: 40,
|
||||
height: 40,
|
||||
},
|
||||
playIcon: {
|
||||
color: theme.buttonBg,
|
||||
},
|
||||
downloadIcon: {
|
||||
color: changeOpacity(theme.centerChannelColor, 0.56),
|
||||
},
|
||||
progressBar: {
|
||||
flex: 1,
|
||||
},
|
||||
timerText: {
|
||||
color: theme.centerChannelColor,
|
||||
...typography('Body', 75, 'SemiBold'),
|
||||
},
|
||||
}));
|
||||
|
||||
const AudioFile = ({file, canDownloadFiles}: Props) => {
|
||||
const intl = useIntl();
|
||||
const theme = useTheme();
|
||||
const style = getStyleSheet(theme);
|
||||
const [hasPaused, setHasPaused] = useState<boolean>(true);
|
||||
const [hasError, setHasError] = useState<boolean>(false);
|
||||
const [hasEnded, setHasEnded] = useState<boolean>(false);
|
||||
const [progress, setProgress] = useState<number>(0);
|
||||
const [timeInMinutes, setTimeInMinutes] = useState<string>('0:00');
|
||||
const [duration, setDuration] = useState<number>(0);
|
||||
const videoRef = useRef<VideoRef>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasEnded) {
|
||||
const timer = setTimeout(() => {
|
||||
setHasPaused(true);
|
||||
setProgress(0);
|
||||
setHasEnded(false);
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
|
||||
return () => null;
|
||||
}, [hasEnded]);
|
||||
|
||||
const source = useMemo(() => ({uri: file.uri}), [file.uri]);
|
||||
|
||||
const {toggleDownloadAndPreview} = useDownloadFileAndPreview();
|
||||
|
||||
const onPlayPress = () => {
|
||||
setHasPaused(!hasPaused);
|
||||
};
|
||||
|
||||
const onDownloadPress = useCallback(async () => {
|
||||
if (!canDownloadFiles) {
|
||||
alertDownloadDocumentDisabled(intl);
|
||||
return;
|
||||
}
|
||||
|
||||
toggleDownloadAndPreview(file);
|
||||
}, [canDownloadFiles, intl, file, toggleDownloadAndPreview]);
|
||||
|
||||
const loadTimeInMinutes = useCallback((timeInSeconds: number) => {
|
||||
const minutes = Math.floor(timeInSeconds / 60);
|
||||
const seconds = Math.floor(timeInSeconds % 60);
|
||||
setTimeInMinutes(`${minutes}:${seconds.toString().padStart(2, '0')}`);
|
||||
}, []);
|
||||
|
||||
const onLoad = useCallback((loadData: OnLoadData) => {
|
||||
loadTimeInMinutes(loadData.duration);
|
||||
setDuration(loadData.duration);
|
||||
}, [loadTimeInMinutes]);
|
||||
|
||||
const throttledLoadTimeOneSecond = useThrottled(loadTimeInMinutes, 1000);
|
||||
const throttledLoadTime100Milliseconds = useThrottled(loadTimeInMinutes, 100);
|
||||
|
||||
const onProgress = useCallback((progressData: OnProgressData) => {
|
||||
if (hasPaused) {
|
||||
return;
|
||||
}
|
||||
const {currentTime, playableDuration} = progressData;
|
||||
setProgress(currentTime / playableDuration);
|
||||
throttledLoadTimeOneSecond(currentTime);
|
||||
}, [hasPaused, throttledLoadTimeOneSecond]);
|
||||
|
||||
const onEnd = useCallback(() => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.seek(0);
|
||||
}
|
||||
setHasEnded(true);
|
||||
}, []);
|
||||
|
||||
const onError = useCallback(({error}: OnVideoErrorData) => {
|
||||
setHasError(true);
|
||||
logDebug((error && typeof error === 'object' && 'errorString' in error) ? error.errorString : error);
|
||||
}, []);
|
||||
|
||||
const onSeek = useCallback((seekPosition: number) => {
|
||||
if (videoRef.current && duration > 0) {
|
||||
const newTime = seekPosition * duration;
|
||||
videoRef.current.seek(newTime);
|
||||
setProgress(seekPosition);
|
||||
throttledLoadTime100Milliseconds(newTime);
|
||||
}
|
||||
}, [duration, throttledLoadTime100Milliseconds]);
|
||||
|
||||
const onAudioFocusChanged = useCallback(({hasAudioFocus}: {hasAudioFocus: boolean}) => {
|
||||
if (!hasAudioFocus) {
|
||||
setHasPaused(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const stopPropagation = useCallback((e: GestureResponderEvent) => {
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
if (hasError) {
|
||||
return (
|
||||
<FormattedText
|
||||
id={'audio.loading_error'}
|
||||
defaultMessage={'Error loading audio.'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableWithoutFeedback
|
||||
onPress={stopPropagation}
|
||||
>
|
||||
<View style={style.audioFileWrapper}>
|
||||
<TouchableOpacity
|
||||
style={style.playButton}
|
||||
onPress={onPlayPress}
|
||||
>
|
||||
<CompassIcon
|
||||
name={hasPaused ? 'play' : 'pause'}
|
||||
size={24}
|
||||
style={style.playIcon}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Video
|
||||
ref={videoRef}
|
||||
source={source}
|
||||
paused={hasPaused}
|
||||
onLoad={onLoad}
|
||||
onProgress={onProgress}
|
||||
onError={onError}
|
||||
onEnd={onEnd}
|
||||
onAudioFocusChanged={onAudioFocusChanged}
|
||||
/>
|
||||
|
||||
<View style={style.progressBar}>
|
||||
<ProgressBar
|
||||
progress={progress}
|
||||
color={theme.buttonBg}
|
||||
withCursor={true}
|
||||
onSeek={onSeek}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Text style={style.timerText}>{timeInMinutes}</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={onDownloadPress}
|
||||
>
|
||||
<CompassIcon
|
||||
name='download-outline'
|
||||
size={24}
|
||||
style={style.downloadIcon}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
);
|
||||
};
|
||||
|
||||
export default AudioFile;
|
||||
|
|
@ -1,15 +1,20 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {forwardRef, useImperativeHandle, useRef, useState} from 'react';
|
||||
import React, {forwardRef, useImperativeHandle, useRef} from 'react';
|
||||
import {StyleSheet, TouchableOpacity, View} from 'react-native';
|
||||
|
||||
import Document, {type DocumentRef} from '@components/document';
|
||||
import ProgressBar from '@components/progress_bar';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {useDownloadFileAndPreview} from '@hooks/files';
|
||||
|
||||
import FileIcon from './file_icon';
|
||||
|
||||
export type DocumentFileRef = {
|
||||
handlePreviewPress: () => void;
|
||||
}
|
||||
|
||||
type DocumentFileProps = {
|
||||
backgroundColor?: string;
|
||||
disabled?: boolean;
|
||||
|
|
@ -29,8 +34,8 @@ const styles = StyleSheet.create({
|
|||
|
||||
const DocumentFile = forwardRef<DocumentRef, DocumentFileProps>(({backgroundColor, canDownloadFiles, disabled = false, file}: DocumentFileProps, ref) => {
|
||||
const theme = useTheme();
|
||||
const [progress, setProgress] = useState(0);
|
||||
const document = useRef<DocumentRef>(null);
|
||||
const {downloading, progress, toggleDownloadAndPreview} = useDownloadFileAndPreview();
|
||||
|
||||
const handlePreviewPress = async () => {
|
||||
document.current?.handlePreviewPress();
|
||||
|
|
@ -48,7 +53,7 @@ const DocumentFile = forwardRef<DocumentRef, DocumentFileProps>(({backgroundColo
|
|||
);
|
||||
|
||||
let fileAttachmentComponent = icon;
|
||||
if (progress) {
|
||||
if (downloading) {
|
||||
fileAttachmentComponent = (
|
||||
<>
|
||||
{icon}
|
||||
|
|
@ -66,7 +71,7 @@ const DocumentFile = forwardRef<DocumentRef, DocumentFileProps>(({backgroundColo
|
|||
<Document
|
||||
canDownloadFiles={canDownloadFiles}
|
||||
file={file}
|
||||
onProgress={setProgress}
|
||||
downloadAndPreviewFile={toggleDownloadAndPreview}
|
||||
ref={document}
|
||||
>
|
||||
<TouchableOpacity
|
||||
|
|
|
|||
|
|
@ -8,9 +8,10 @@ import Animated from 'react-native-reanimated';
|
|||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {useGalleryItem} from '@hooks/gallery';
|
||||
import {isDocument, isImage, isVideo} from '@utils/file';
|
||||
import {isAudio, isDocument, isImage, isVideo} from '@utils/file';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
import AudioFile from './audio_file';
|
||||
import DocumentFile from './document_file';
|
||||
import FileIcon from './file_icon';
|
||||
import FileInfo from './file_info';
|
||||
|
|
@ -62,6 +63,11 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
|||
width: 40,
|
||||
margin: 4,
|
||||
},
|
||||
audioFile: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -129,6 +135,18 @@ const File = ({
|
|||
);
|
||||
};
|
||||
|
||||
const touchableWithPreview = (
|
||||
<TouchableWithFeedback
|
||||
onPress={handlePreviewPress}
|
||||
disabled={isPressDisabled}
|
||||
type={'opacity'}
|
||||
>
|
||||
<FileIcon
|
||||
file={file}
|
||||
/>
|
||||
</TouchableWithFeedback>
|
||||
);
|
||||
|
||||
let fileComponent;
|
||||
if (isVideo(file) && publicLinkEnabled) {
|
||||
const renderVideoFile = (
|
||||
|
|
@ -216,19 +234,18 @@ const File = ({
|
|||
}
|
||||
</View>
|
||||
);
|
||||
} else {
|
||||
const touchableWithPreview = (
|
||||
<TouchableWithFeedback
|
||||
onPress={handlePreviewPress}
|
||||
disabled={isPressDisabled}
|
||||
type={'opacity'}
|
||||
>
|
||||
<FileIcon
|
||||
} else if (isAudio(file)) {
|
||||
const renderAudioFile = (
|
||||
<Animated.View style={[styles, asCard ? style.imageVideo : style.audioFile]}>
|
||||
<AudioFile
|
||||
file={file}
|
||||
canDownloadFiles={canDownloadFiles}
|
||||
/>
|
||||
</TouchableWithFeedback>
|
||||
</Animated.View>
|
||||
);
|
||||
|
||||
fileComponent = asCard ? renderCardWithImage(touchableWithPreview) : renderAudioFile;
|
||||
} else {
|
||||
fileComponent = renderCardWithImage(touchableWithPreview);
|
||||
}
|
||||
return fileComponent;
|
||||
|
|
|
|||
|
|
@ -1,67 +1,160 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useEffect, useState} from 'react';
|
||||
import {type LayoutChangeEvent, StyleSheet, type StyleProp, View, type ViewStyle} from 'react-native';
|
||||
import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
|
||||
import React, {useCallback, useEffect} from 'react';
|
||||
import {type LayoutChangeEvent, type StyleProp, View, type ViewStyle, StyleSheet} from 'react-native';
|
||||
import {Gesture, GestureDetector} from 'react-native-gesture-handler';
|
||||
import Animated, {clamp, runOnJS, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
|
||||
|
||||
import {useTheme} from '@context/theme';
|
||||
import {changeOpacity} from '@utils/theme';
|
||||
|
||||
type ProgressBarProps = {
|
||||
color: string;
|
||||
progress: number;
|
||||
withCursor?: boolean;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
onSeek?: (position: number) => void;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'relative',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 10,
|
||||
},
|
||||
progressBarContainer: {
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.16)',
|
||||
overflow: 'hidden',
|
||||
width: '100%',
|
||||
},
|
||||
progressBar: {
|
||||
flex: 1,
|
||||
},
|
||||
cursor: {
|
||||
position: 'absolute',
|
||||
borderRadius: 100,
|
||||
width: 15,
|
||||
height: 15,
|
||||
},
|
||||
});
|
||||
|
||||
const ProgressBar = ({color, progress, style}: ProgressBarProps) => {
|
||||
const [width, setWidth] = useState(0);
|
||||
const START_CURSOR_VALUE = 0;
|
||||
const END_CURSOR_VALUE = 1;
|
||||
|
||||
const ProgressBar = ({color, progress, withCursor, style, onSeek}: ProgressBarProps) => {
|
||||
const theme = useTheme();
|
||||
const widthValue = useSharedValue(0);
|
||||
const progressValue = useSharedValue(progress);
|
||||
const isGestureActive = useSharedValue(false);
|
||||
|
||||
// eslint-disable-next-line new-cap
|
||||
const panGesture = Gesture.Pan().
|
||||
onStart(() => {
|
||||
isGestureActive.value = true;
|
||||
}).
|
||||
onChange((e) => {
|
||||
if (onSeek) {
|
||||
const clampedSeekPosition = clamp(e.x / widthValue.value, START_CURSOR_VALUE, END_CURSOR_VALUE);
|
||||
runOnJS(onSeek)(clampedSeekPosition);
|
||||
}
|
||||
}).
|
||||
onEnd(() => {
|
||||
isGestureActive.value = false;
|
||||
}).
|
||||
onFinalize(() => {
|
||||
isGestureActive.value = false;
|
||||
});
|
||||
|
||||
// eslint-disable-next-line new-cap
|
||||
const tapGesture = Gesture.Tap().
|
||||
onStart(() => {
|
||||
isGestureActive.value = true;
|
||||
}).
|
||||
onEnd((e) => {
|
||||
if (onSeek) {
|
||||
const clampedSeekPosition = clamp(e.x / widthValue.value, START_CURSOR_VALUE, END_CURSOR_VALUE);
|
||||
runOnJS(onSeek)(clampedSeekPosition);
|
||||
}
|
||||
isGestureActive.value = false;
|
||||
}).
|
||||
onFinalize(() => {
|
||||
isGestureActive.value = false;
|
||||
});
|
||||
|
||||
// eslint-disable-next-line new-cap
|
||||
const composedGestures = Gesture.Race(tapGesture, panGesture);
|
||||
|
||||
const progressAnimatedStyle = useAnimatedStyle(() => {
|
||||
const translateX = ((progressValue.value * 0.5) - 0.5) * widthValue.value;
|
||||
const scaleX = progressValue.value ? progressValue.value : 0.0001;
|
||||
|
||||
return {
|
||||
transform: [
|
||||
{translateX: withTiming(((progressValue.value * 0.5) - 0.5) * width, {duration: 200})},
|
||||
{scaleX: withTiming(progressValue.value ? progressValue.value : 0.0001, {duration: 200})},
|
||||
{translateX: isGestureActive.value ? translateX : withTiming(translateX, {duration: 200})},
|
||||
{scaleX: isGestureActive.value ? scaleX : withTiming(scaleX, {duration: 200})},
|
||||
],
|
||||
width: widthValue.value,
|
||||
};
|
||||
}, [width]);
|
||||
});
|
||||
|
||||
const cursorAnimatedStyle = useAnimatedStyle(() => {
|
||||
const cursorWidth = 15;
|
||||
const leftPosition = (progressValue.value * widthValue.value) - (cursorWidth / 2);
|
||||
|
||||
return {
|
||||
left: isGestureActive.value ? leftPosition : withTiming(leftPosition, {duration: 100}),
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
progressValue.value = progress;
|
||||
}, [progress]);
|
||||
|
||||
const onLayout = useCallback((e: LayoutChangeEvent) => {
|
||||
setWidth(e.nativeEvent.layout.width);
|
||||
widthValue.value = e.nativeEvent.layout.width;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View
|
||||
onLayout={onLayout}
|
||||
style={[styles.container, style]}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.progressBar,
|
||||
{
|
||||
backgroundColor: color,
|
||||
width,
|
||||
},
|
||||
progressAnimatedStyle,
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
<GestureDetector gesture={composedGestures}>
|
||||
<View
|
||||
onTouchStart={(e) => e.stopPropagation()}
|
||||
style={styles.container}
|
||||
>
|
||||
<View
|
||||
onLayout={onLayout}
|
||||
style={[
|
||||
styles.progressBarContainer,
|
||||
{
|
||||
backgroundColor: withCursor ? changeOpacity(theme.centerChannelColor, 0.2) : 'rgba(255, 255, 255, 0.16)',
|
||||
},
|
||||
style,
|
||||
]}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.progressBar,
|
||||
{
|
||||
backgroundColor: color,
|
||||
},
|
||||
progressAnimatedStyle,
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{withCursor &&
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.cursor,
|
||||
{
|
||||
backgroundColor: color,
|
||||
},
|
||||
cursorAnimatedStyle,
|
||||
]}
|
||||
/>}
|
||||
</View>
|
||||
</GestureDetector>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ jest.mock('@utils/file', () => ({
|
|||
isGif: jest.fn(),
|
||||
isImage: jest.fn(),
|
||||
isVideo: jest.fn(),
|
||||
isAudio: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@context/server', () => ({
|
||||
|
|
|
|||
|
|
@ -1,14 +1,24 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {useEffect, useMemo, useState} from 'react';
|
||||
import {deleteAsync} from 'expo-file-system';
|
||||
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {Platform, StatusBar, type StatusBarStyle} from 'react-native';
|
||||
import FileViewer from 'react-native-file-viewer';
|
||||
import tinyColor from 'tinycolor2';
|
||||
|
||||
import {getLocalFileInfo} from '@actions/local/file';
|
||||
import {buildFilePreviewUrl, buildFileUrl} from '@actions/remote/file';
|
||||
import {buildFilePreviewUrl, buildFileUrl, downloadFile} from '@actions/remote/file';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {isGif, isImage, isVideo} from '@utils/file';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {alertDownloadFailed, alertFailedToOpenDocument} from '@utils/document';
|
||||
import {getFullErrorMessage, isErrorWithMessage} from '@utils/errors';
|
||||
import {fileExists, getLocalFilePathFromFile, isAudio, isGif, isImage, isVideo} from '@utils/file';
|
||||
import {getImageSize} from '@utils/gallery';
|
||||
import {logDebug} from '@utils/log';
|
||||
|
||||
import type {ClientResponse, ProgressPromise} from '@mattermost/react-native-network-client';
|
||||
import type ChannelBookmarkModel from '@typings/database/models/servers/channel_bookmark';
|
||||
|
||||
const getFileInfo = async (serverUrl: string, bookmarks: ChannelBookmarkModel[], publicLinkEnabled: boolean, cb: (files: FileInfo[]) => void) => {
|
||||
|
|
@ -55,7 +65,9 @@ export const useImageAttachments = (filesInfo: FileInfo[]) => {
|
|||
return filesInfo.reduce(({images, nonImages}: {images: FileInfo[]; nonImages: FileInfo[]}, file) => {
|
||||
const imageFile = isImage(file);
|
||||
const videoFile = isVideo(file);
|
||||
if (imageFile || videoFile) {
|
||||
const audioFile = isAudio(file);
|
||||
|
||||
if (imageFile || videoFile || audioFile) {
|
||||
let uri;
|
||||
if (file.localPath) {
|
||||
uri = file.localPath;
|
||||
|
|
@ -64,7 +76,7 @@ export const useImageAttachments = (filesInfo: FileInfo[]) => {
|
|||
if (!file.id) {
|
||||
return {images, nonImages};
|
||||
}
|
||||
uri = (isGif(file) || videoFile) ? buildFileUrl(serverUrl, file.id) : buildFilePreviewUrl(serverUrl, file.id);
|
||||
uri = (isGif(file) || videoFile || audioFile) ? buildFileUrl(serverUrl, file.id) : buildFilePreviewUrl(serverUrl, file.id);
|
||||
}
|
||||
images.push({...file, uri});
|
||||
} else {
|
||||
|
|
@ -85,3 +97,137 @@ export const useChannelBookmarkFiles = (bookmarks: ChannelBookmarkModel[], publi
|
|||
|
||||
return files;
|
||||
};
|
||||
|
||||
export const useDownloadFileAndPreview = () => {
|
||||
const serverUrl = useServerUrl();
|
||||
const intl = useIntl();
|
||||
const theme = useTheme();
|
||||
const downloadTask = useRef<ProgressPromise<ClientResponse>>();
|
||||
|
||||
const [progress, setProgress] = useState<number>(0);
|
||||
|
||||
const [preview, setPreview] = useState(false);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [didCancel, setDidCancel] = useState(false);
|
||||
|
||||
const setStatusBarColor = useCallback((style: StatusBarStyle = 'light-content') => {
|
||||
if (Platform.OS === 'ios') {
|
||||
if (style) {
|
||||
StatusBar.setBarStyle(style, true);
|
||||
} else {
|
||||
const headerColor = tinyColor(theme.sidebarHeaderBg);
|
||||
let barStyle: StatusBarStyle = 'light-content';
|
||||
if (headerColor.isLight() && Platform.OS === 'ios') {
|
||||
barStyle = 'dark-content';
|
||||
}
|
||||
StatusBar.setBarStyle(barStyle, true);
|
||||
}
|
||||
}
|
||||
}, [theme.sidebarHeaderBg]);
|
||||
|
||||
const onDonePreviewingFile = useCallback(() => {
|
||||
setProgress(0);
|
||||
setDownloading(false);
|
||||
setPreview(false);
|
||||
setStatusBarColor();
|
||||
}, [setStatusBarColor]);
|
||||
|
||||
const openDocument = useCallback(async (file: FileInfo) => {
|
||||
if (!didCancel && !preview) {
|
||||
let path = decodeURIComponent(file.localPath || '');
|
||||
let exists = false;
|
||||
if (path) {
|
||||
exists = await fileExists(path);
|
||||
}
|
||||
|
||||
if (!exists) {
|
||||
path = getLocalFilePathFromFile(serverUrl, file);
|
||||
}
|
||||
|
||||
setPreview(true);
|
||||
setStatusBarColor('dark-content');
|
||||
FileViewer.open(path!.replace('file://', ''), {
|
||||
displayName: decodeURIComponent(file.name),
|
||||
onDismiss: onDonePreviewingFile,
|
||||
showOpenWithDialog: true,
|
||||
showAppsSuggestions: true,
|
||||
}).then(() => {
|
||||
setDownloading(false);
|
||||
setProgress(0);
|
||||
}).catch(() => {
|
||||
alertFailedToOpenDocument(file, intl);
|
||||
onDonePreviewingFile();
|
||||
|
||||
if (path) {
|
||||
deleteAsync(path, {idempotent: true});
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [didCancel, preview, serverUrl, intl, onDonePreviewingFile, setStatusBarColor]);
|
||||
|
||||
const downloadAndPreviewFile = useCallback(async (file: FileInfo) => {
|
||||
setDidCancel(false);
|
||||
let path;
|
||||
let exists = false;
|
||||
|
||||
try {
|
||||
path = decodeURIComponent(file.localPath || '');
|
||||
if (path) {
|
||||
exists = await fileExists(path);
|
||||
}
|
||||
|
||||
if (!exists) {
|
||||
path = getLocalFilePathFromFile(serverUrl, file);
|
||||
exists = await fileExists(path);
|
||||
}
|
||||
|
||||
if (exists) {
|
||||
openDocument(file);
|
||||
} else {
|
||||
setDownloading(true);
|
||||
downloadTask.current = downloadFile(serverUrl, file.id!, path!);
|
||||
downloadTask.current?.progress?.(setProgress);
|
||||
|
||||
await downloadTask.current;
|
||||
setProgress(1);
|
||||
openDocument(file);
|
||||
}
|
||||
} catch (error) {
|
||||
if (path) {
|
||||
deleteAsync(path, {idempotent: true});
|
||||
}
|
||||
setDownloading(false);
|
||||
setProgress(0);
|
||||
|
||||
if (!isErrorWithMessage(error) || error.message !== 'cancelled') {
|
||||
logDebug('error on downloadAndPreviewFile', getFullErrorMessage(error));
|
||||
alertDownloadFailed(intl);
|
||||
}
|
||||
}
|
||||
}, [intl, openDocument, serverUrl]);
|
||||
|
||||
const toggleDownloadAndPreview = useCallback((file: FileInfo) => {
|
||||
if (downloading && progress < 1) {
|
||||
cancelDownload();
|
||||
} else if (downloading) {
|
||||
setProgress(0);
|
||||
cancelDownload();
|
||||
setDownloading(false);
|
||||
} else {
|
||||
downloadAndPreviewFile(file);
|
||||
}
|
||||
}, [downloading, progress, downloadAndPreviewFile]);
|
||||
|
||||
const cancelDownload = () => {
|
||||
setDidCancel(true);
|
||||
if (downloadTask.current?.cancel) {
|
||||
downloadTask.current.cancel();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
downloading,
|
||||
progress,
|
||||
toggleDownloadAndPreview,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
18
app/hooks/throttled.ts
Normal file
18
app/hooks/throttled.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {throttle} from 'lodash';
|
||||
import {useMemo, useRef} from 'react';
|
||||
|
||||
const useThrottled = (callback: Function, time: number) => {
|
||||
const callbackRef = useRef(callback);
|
||||
|
||||
callbackRef.current = callback;
|
||||
|
||||
return useMemo(
|
||||
() => throttle((...args: unknown[]) => callbackRef.current(...args), time),
|
||||
[time],
|
||||
);
|
||||
};
|
||||
|
||||
export default useThrottled;
|
||||
|
|
@ -71,6 +71,11 @@ const SUPPORTED_VIDEO_FORMAT = Platform.select({
|
|||
android: ['video/3gpp', 'video/x-matroska', 'video/mp4', 'video/webm', 'video/quicktime'],
|
||||
});
|
||||
|
||||
const SUPPORTED_AUDIO_FORMAT = Platform.select({
|
||||
ios: ['audio/mp4', 'audio/mpeg', 'audio/wav', 'audio/x-aiff'],
|
||||
android: ['audio/mp4', 'audio/mpeg', 'audio/ogg', 'audio/wav', 'audio/webm', 'audio/3gpp'],
|
||||
});
|
||||
|
||||
const types: Record<string, string> = {};
|
||||
const extensions: Record<string, readonly string[]> = {};
|
||||
|
||||
|
|
@ -308,6 +313,21 @@ export const isVideo = (file?: FileInfo | FileModel) => {
|
|||
return SUPPORTED_VIDEO_FORMAT!.includes(mime);
|
||||
};
|
||||
|
||||
export const isAudio = (file?: FileInfo | FileModel) => {
|
||||
if (!file) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mime = 'mime_type' in file ? file.mime_type : file.mimeType;
|
||||
if (mime && mime.includes(';')) {
|
||||
mime = mime.split(';')[0];
|
||||
} else if (!mime && file?.name) {
|
||||
mime = lookupMimeType(file.name);
|
||||
}
|
||||
|
||||
return SUPPORTED_AUDIO_FORMAT!.includes(mime);
|
||||
};
|
||||
|
||||
export function getFormattedFileSize(bytes: number): string {
|
||||
const fileSizes: Array<[string, number]> = [
|
||||
['TB', 1024 * 1024 * 1024 * 1024],
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@
|
|||
"apps.suggestion.no_static": "No matching options.",
|
||||
"apps.suggestion.no_suggestion": "No matching suggestions.",
|
||||
"archivedChannelMessage": "You are viewing an **archived channel**. New messages cannot be posted.",
|
||||
"audio.loading_error": "Error loading audio.",
|
||||
"autocomplete_selector.unknown_channel": "Unknown channel",
|
||||
"browse_channels.archivedChannels": "Archived Channels",
|
||||
"browse_channels.dropdownTitle": "Show",
|
||||
|
|
|
|||
Loading…
Reference in a new issue