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:
Lucas Reis 2025-02-19 07:17:30 -03:00 committed by GitHub
parent 4a9678ed01
commit 8e854a8bdd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 581 additions and 180 deletions

View file

@ -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

View file

@ -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,

View 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;

View file

@ -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

View file

@ -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;

View file

@ -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>
);
};

View file

@ -23,6 +23,7 @@ jest.mock('@utils/file', () => ({
isGif: jest.fn(),
isImage: jest.fn(),
isVideo: jest.fn(),
isAudio: jest.fn(),
}));
jest.mock('@context/server', () => ({

View file

@ -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
View 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;

View file

@ -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],

View file

@ -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",