diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index b3cb703225..d5a305a76e 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,5 @@ - @@ -10,6 +9,13 @@ + + + + + + + { + private final WeakReference weakContext; + private final String fromFile; + private final Uri toFile; + + protected SaveDataTask(ReactApplicationContext reactContext, String path, Uri destination) { + super(reactContext.getExceptionHandler()); + weakContext = new WeakReference<>(reactContext.getApplicationContext()); + fromFile = path; + toFile = destination; + } + + @Override + protected Object doInBackgroundGuarded() { + FileChannel source = null; + FileChannel dest = null; + try { + File input = new File(this.fromFile); + FileInputStream fileInputStream = new FileInputStream(input); + ParcelFileDescriptor pfd = weakContext.get().getContentResolver().openFileDescriptor(toFile, "w"); + FileOutputStream fileOutputStream = new FileOutputStream(pfd.getFileDescriptor()); + source = fileInputStream.getChannel(); + dest = fileOutputStream.getChannel(); + dest.transferFrom(source, 0, source.size()); + } catch (Exception e) { + e.printStackTrace(); + } finally { + if (source != null) { + try { + source.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + + if (dest != null) { + try { + dest.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + } + + return null; + } + + @Override + protected void onPostExecuteGuarded(Object o) { + + } + } } diff --git a/android/app/src/main/res/xml/file_viewer_provider_paths.xml b/android/app/src/main/res/xml/file_viewer_provider_paths.xml new file mode 100644 index 0000000000..99783cb5d4 --- /dev/null +++ b/android/app/src/main/res/xml/file_viewer_provider_paths.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/android/settings.gradle b/android/settings.gradle index dd9a86a5f5..e054de5ace 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -4,10 +4,11 @@ project(':lottie-react-native').projectDir = new File(rootProject.projectDir, '. include ':reactnativenotifications' project(':reactnativenotifications').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-notifications/lib/android/app') apply from: file("../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesSettingsGradle(settings) -include ':app' +include ':react-native-video' +project(':react-native-video').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-video/android-exoplayer') include ':watermelondb-jsi' -project(':watermelondb-jsi').projectDir = - new File(rootProject.projectDir, '../node_modules/@nozbe/watermelondb/native/android-jsi') +project(':watermelondb-jsi').projectDir = new File(rootProject.projectDir, '../node_modules/@nozbe/watermelondb/native/android-jsi') +include ':app' apply from: new File(["node", "--print", "require.resolve('expo/package.json')"].execute().text.trim(), "../scripts/autolinking.gradle") useExpoModules() \ No newline at end of file diff --git a/app/actions/local/file.ts b/app/actions/local/file.ts new file mode 100644 index 0000000000..d08fff4534 --- /dev/null +++ b/app/actions/local/file.ts @@ -0,0 +1,13 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import DatabaseManager from '@database/manager'; + +export const updateLocalFile = async (serverUrl: string, file: FileInfo) => { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + + return operator.handleFiles({files: [file], prepareRecordsOnly: false}); +}; diff --git a/app/actions/remote/file.ts b/app/actions/remote/file.ts index a5bddd2a71..19a97b7ecf 100644 --- a/app/actions/remote/file.ts +++ b/app/actions/remote/file.ts @@ -5,8 +5,16 @@ import {ClientResponse, ClientResponseError} from '@mattermost/react-native-netw import {Client} from '@client/rest'; import ClientError from '@client/rest/error'; +import {DOWNLOAD_TIMEOUT} from '@constants/network'; import NetworkManager from '@init/network_manager'; +import {forceLogoutIfNecessary} from './session'; + +export const downloadFile = (serverUrl: string, fileId: string, desitnation: string) => { // Let it throw and handle it accordingly + const client = NetworkManager.getClient(serverUrl); + return client.apiClient.download(client.getFileRoute(fileId), desitnation.replace('file://', ''), {timeoutInterval: DOWNLOAD_TIMEOUT}); +}; + export const uploadFile = ( serverUrl: string, file: FileInfo, @@ -24,3 +32,64 @@ export const uploadFile = ( } return {cancel: client.uploadPostAttachment(file, channelId, onProgress, onComplete, onError, skipBytes)}; }; + +export const fetchPublicLink = async (serverUrl: string, fileId: string) => { + let client: Client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return {error: error as ClientError}; + } + + try { + const publicLink = await client!.getFilePublicLink(fileId); + return publicLink; + } catch (error) { + forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); + return {error}; + } +}; + +export const buildFileUrl = (serverUrl: string, fileId: string, timestamp = 0) => { + let client: Client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return ''; + } + + return client.getFileUrl(fileId, timestamp); +}; + +export const buildAbsoluteUrl = (serverUrl: string, relativePath: string) => { + let client: Client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return ''; + } + + return client.getAbsoluteUrl(relativePath); +}; + +export const buildFilePreviewUrl = (serverUrl: string, fileId: string, timestamp = 0) => { + let client: Client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return ''; + } + + return client.getFilePreviewUrl(fileId, timestamp); +}; + +export const buildFileThumbnailUrl = (serverUrl: string, fileId: string, timestamp = 0) => { + let client: Client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return ''; + } + + return client.getFileThumbnailUrl(fileId, timestamp); +}; diff --git a/app/actions/remote/user.ts b/app/actions/remote/user.ts index 077f31b20c..3e30235c92 100644 --- a/app/actions/remote/user.ts +++ b/app/actions/remote/user.ts @@ -561,3 +561,14 @@ export const uploadUserProfileImage = async (serverUrl: string, localPath: strin } return {error: undefined}; }; + +export const buildProfileImageUrl = (serverUrl: string, userId: string, timestamp = 0) => { + let client: Client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return ''; + } + + return client.getProfilePictureUrl(userId, timestamp); +}; diff --git a/app/client/rest/files.ts b/app/client/rest/files.ts index 0e438244f2..1b5e2d1061 100644 --- a/app/client/rest/files.ts +++ b/app/client/rest/files.ts @@ -7,7 +7,7 @@ export interface ClientFilesMix { getFileUrl: (fileId: string, timestamp: number) => string; getFileThumbnailUrl: (fileId: string, timestamp: number) => string; getFilePreviewUrl: (fileId: string, timestamp: number) => string; - getFilePublicLink: (fileId: string) => Promise; + getFilePublicLink: (fileId: string) => Promise<{link: string}>; uploadPostAttachment: ( file: FileInfo, channelId: string, diff --git a/app/components/freeze_screen/index.tsx b/app/components/freeze_screen/index.tsx new file mode 100644 index 0000000000..a5186f2a5b --- /dev/null +++ b/app/components/freeze_screen/index.tsx @@ -0,0 +1,37 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Freeze} from 'react-freeze'; +import {View} from 'react-native'; + +import useFreeze from '@hooks/freeze'; + +type FreezePlaceholderProps = { + backgroundColor: string; +}; + +type FreezeScreenProps = { + children: React.ReactNode; +} + +const FreezePlaceholder = ({backgroundColor}: FreezePlaceholderProps) => { + return ; +}; + +const FreezeScreen = ({children}: FreezeScreenProps) => { + const {freeze, backgroundColor} = useFreeze(); + + const placeholder = (); + + return ( + + {children} + + ); +}; + +export default FreezeScreen; diff --git a/app/components/markdown/at_mention/index.tsx b/app/components/markdown/at_mention/index.tsx index c18c15e91d..a702fbac02 100644 --- a/app/components/markdown/at_mention/index.tsx +++ b/app/components/markdown/at_mention/index.tsx @@ -9,6 +9,7 @@ import Clipboard from '@react-native-community/clipboard'; import React, {useCallback, useMemo} from 'react'; import {useIntl} from 'react-intl'; import {GestureResponderEvent, StyleProp, StyleSheet, Text, TextStyle, View} from 'react-native'; +import {TouchableOpacity} from 'react-native-gesture-handler'; import {combineLatest, of as of$} from 'rxjs'; import {map, switchMap} from 'rxjs/operators'; @@ -172,7 +173,7 @@ const AtMention = ({ let isMention = false; let mention; let onLongPress; - let onPress; + let onPress: (e?: GestureResponderEvent) => void; let suffix; let suffixElement; let styleText; @@ -204,7 +205,7 @@ const AtMention = ({ if (canPress) { onLongPress = handleLongPress; - onPress = isSearchResult ? onPostPress : goToUserProfile; + onPress = (isSearchResult ? onPostPress : goToUserProfile) as (e?: GestureResponderEvent) => void; } if (suffix) { @@ -225,16 +226,17 @@ const AtMention = ({ } return ( - - - {'@' + mention} + + + {'@' + mention} + + {suffixElement} - {suffixElement} - + ); }; diff --git a/app/components/markdown/channel_mention/index.tsx b/app/components/markdown/channel_mention/index.tsx index 85067f7de2..3ddc43061a 100644 --- a/app/components/markdown/channel_mention/index.tsx +++ b/app/components/markdown/channel_mention/index.tsx @@ -7,6 +7,7 @@ import withObservables from '@nozbe/with-observables'; import React, {useCallback} from 'react'; import {useIntl} from 'react-intl'; import {StyleProp, Text, TextStyle} from 'react-native'; +import {TouchableOpacity} from 'react-native-gesture-handler'; import {map, switchMap} from 'rxjs/operators'; import {joinChannel, switchToChannelById} from '@actions/remote/channel'; @@ -111,15 +112,14 @@ const ChannelMention = ({ } return ( - - - {`~${channel.display_name}`} + + + + {`~${channel.display_name}`} + + {suffix} - {suffix} - + ); }; diff --git a/app/components/markdown/hashtag/index.tsx b/app/components/markdown/hashtag/index.tsx index fba1484d27..c0fd98498d 100644 --- a/app/components/markdown/hashtag/index.tsx +++ b/app/components/markdown/hashtag/index.tsx @@ -3,6 +3,7 @@ import React from 'react'; import {Text, TextStyle} from 'react-native'; +import {TouchableOpacity} from 'react-native-gesture-handler'; import {popToRoot, showSearchModal, dismissAllModals} from '@screens/navigation'; @@ -21,12 +22,11 @@ const Hashtag = ({hashtag, linkStyle}: HashtagProps) => { }; return ( - - {`#${hashtag}`} - + + + {`#${hashtag}`} + + ); }; diff --git a/app/components/markdown/index.tsx b/app/components/markdown/index.tsx index cfa3706062..59f903f8d6 100644 --- a/app/components/markdown/index.tsx +++ b/app/components/markdown/index.tsx @@ -42,6 +42,7 @@ type MarkdownProps = { isEdited?: boolean; isReplyPost?: boolean; isSearchResult?: boolean; + location?: string; mentionKeys?: UserMentionKey[]; minimumHashtagLength?: number; onPostPress?: (event: GestureResponderEvent) => void; @@ -190,8 +191,9 @@ class Markdown extends PureComponent { // We have enough problems rendering images as is, so just render a link inside of a table return ( @@ -200,11 +202,12 @@ class Markdown extends PureComponent { return ( @@ -283,9 +286,7 @@ class Markdown extends PureComponent { return ( - - {children} - + {children} ); }; diff --git a/app/components/markdown/markdown_code_block/index.tsx b/app/components/markdown/markdown_code_block/index.tsx index 43637444a8..8a4fff0682 100644 --- a/app/components/markdown/markdown_code_block/index.tsx +++ b/app/components/markdown/markdown_code_block/index.tsx @@ -6,10 +6,10 @@ import Clipboard from '@react-native-community/clipboard'; import React, {useCallback} from 'react'; import {useIntl} from 'react-intl'; import {Keyboard, StyleSheet, Text, TextStyle, View} from 'react-native'; +import {TouchableOpacity} from 'react-native-gesture-handler'; import FormattedText from '@components/formatted_text'; import SlideUpPanelItem, {ITEM_HEIGHT} from '@components/slide_up_panel_item'; -import TouchableWithFeedback from '@components/touchable_with_feedback'; import {useTheme} from '@context/theme'; import {bottomSheet, dismissBottomSheet, goToScreen} from '@screens/navigation'; import {getDisplayNameForLanguage} from '@utils/markdown'; @@ -226,10 +226,9 @@ const MarkdownCodeBlock = ({language = '', content, textStyle}: MarkdownCodeBloc }; return ( - @@ -245,7 +244,7 @@ const MarkdownCodeBlock = ({language = '', content, textStyle}: MarkdownCodeBloc {renderLanguageBlock()} - + ); }; diff --git a/app/components/markdown/markdown_image/index.tsx b/app/components/markdown/markdown_image/index.tsx index a169da5caf..ab69214ff5 100644 --- a/app/components/markdown/markdown_image/index.tsx +++ b/app/components/markdown/markdown_image/index.tsx @@ -3,11 +3,15 @@ import {useManagedConfig} from '@mattermost/react-native-emm'; import Clipboard from '@react-native-community/clipboard'; -import React, {useCallback, useRef, useState} from 'react'; +import React, {useCallback, useMemo, useRef, useState} from 'react'; import {useIntl} from 'react-intl'; import {Alert, Platform, StyleProp, StyleSheet, Text, TextStyle, View} from 'react-native'; +import {LongPressGestureHandler, TapGestureHandler} from 'react-native-gesture-handler'; +import Animated from 'react-native-reanimated'; import parseUrl from 'url-parse'; +import {GalleryInit} from '@app/context/gallery'; +import {useGalleryItem} from '@app/hooks/gallery'; import CompassIcon from '@components/compass_icon'; import FormattedText from '@components/formatted_text'; import ProgressiveImage from '@components/progressive_image'; @@ -17,7 +21,8 @@ import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import {useIsTablet} from '@hooks/device'; import {bottomSheet, dismissBottomSheet} from '@screens/navigation'; -import {openGallerWithMockFile} from '@utils/gallery'; +import {lookupMimeType} from '@utils/file'; +import {openGalleryAtIndex} from '@utils/gallery'; import {generateId} from '@utils/general'; import {calculateDimensions, getViewPortWidth, isGifTooLarge} from '@utils/images'; import {normalizeProtocol, tryOpenURL} from '@utils/url'; @@ -28,6 +33,7 @@ type MarkdownImageProps = { imagesMetadata?: Record; isReplyPost?: boolean; linkDestination?: string; + location?: string; postId: string; source: string; } @@ -50,42 +56,63 @@ const style = StyleSheet.create({ const MarkdownImage = ({ disabled, errorTextStyle, imagesMetadata, isReplyPost = false, - linkDestination, postId, source, + linkDestination, location, postId, source, }: MarkdownImageProps) => { const intl = useIntl(); const isTablet = useIsTablet(); const theme = useTheme(); const managedConfig = useManagedConfig(); - const genericFileId = useRef(generateId()).current; + const genericFileId = useRef(generateId('uid')).current; + const tapRef = useRef(); const metadata = imagesMetadata?.[source] || Object.values(imagesMetadata || {})[0]; const [failed, setFailed] = useState(isGifTooLarge(metadata)); const originalSize = {width: metadata?.width || 0, height: metadata?.height || 0}; const serverUrl = useServerUrl(); + const galleryIdentifier = `${postId}-${genericFileId}-${location}`; + const uri = useMemo(() => { + if (source.startsWith('/')) { + return serverUrl + source; + } - let uri = source; - if (uri.startsWith('/')) { - uri = serverUrl + uri; - } + return source; + }, [source, serverUrl]); - const link = decodeURIComponent(uri); - let filename = parseUrl(link.substr(link.lastIndexOf('/'))).pathname.replace('/', ''); - let extension = filename.split('.').pop(); - if (extension === filename) { - const ext = filename.indexOf('.') === -1 ? '.png' : filename.substring(filename.lastIndexOf('.')); - filename = `${filename}${ext}`; - extension = ext; - } + const fileInfo = useMemo(() => { + const link = decodeURIComponent(uri); + let filename = parseUrl(link.substr(link.lastIndexOf('/'))).pathname.replace('/', ''); + let extension = filename.split('.').pop(); + if (extension === filename) { + const ext = filename.indexOf('.') === -1 ? '.png' : filename.substring(filename.lastIndexOf('.')); + filename = `${filename}${ext}`; + extension = ext; + } - const fileInfo = { - id: genericFileId, - name: filename, - extension, - has_preview_image: true, - post_id: postId, - uri: link, - width: originalSize.width, - height: originalSize.height, - }; + return { + id: genericFileId, + name: filename, + extension, + has_preview_image: true, + post_id: postId, + uri: link, + width: originalSize.width, + height: originalSize.height, + }; + }, []); + + const handlePreviewImage = useCallback(() => { + const item: GalleryItemType = { + ...fileInfo, + mime_type: lookupMimeType(fileInfo.name), + type: 'image', + }; + openGalleryAtIndex(galleryIdentifier, 0, [item]); + }, []); + + const {ref, onGestureEvent, styles} = useGalleryItem( + galleryIdentifier, + 0, + handlePreviewImage, + ); const {height, width} = calculateDimensions(fileInfo.height, fileInfo.width, getViewPortWidth(isReplyPost, isTablet)); @@ -150,10 +177,6 @@ const MarkdownImage = ({ } }, [managedConfig, intl, theme]); - const handlePreviewImage = useCallback(() => { - openGallerWithMockFile(fileInfo.uri, postId, fileInfo.height, fileInfo.width, fileInfo.id); - }, []); - const handleOnError = useCallback(() => { setFailed(true); }, []); @@ -186,20 +209,30 @@ const MarkdownImage = ({ ); } else { image = ( - - - + + + + + + + + ); } } @@ -211,15 +244,23 @@ const MarkdownImage = ({ onLongPress={handleLinkLongPress} style={[{width, height}, style.container]} > - {image} + ); } return ( - - {image} - + + + {image} + + ); }; diff --git a/app/components/markdown/markdown_link/index.tsx b/app/components/markdown/markdown_link/index.tsx index 002200d2dc..a1b315046e 100644 --- a/app/components/markdown/markdown_link/index.tsx +++ b/app/components/markdown/markdown_link/index.tsx @@ -8,6 +8,7 @@ import Clipboard from '@react-native-community/clipboard'; import React, {Children, ReactElement, useCallback} from 'react'; import {useIntl} from 'react-intl'; import {Alert, StyleSheet, Text, View} from 'react-native'; +import {TouchableOpacity} from 'react-native-gesture-handler'; import {of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; import urlParse from 'url-parse'; @@ -164,12 +165,14 @@ const MarkdownLink = ({children, experimentalNormalizeMarkdownLinks, href, siteU const renderChildren = experimentalNormalizeMarkdownLinks ? parseChildren() : children; return ( - - {renderChildren} - + + {renderChildren} + + ); }; diff --git a/app/components/markdown/markdown_table/index.tsx b/app/components/markdown/markdown_table/index.tsx index be8a13fe42..fd6b82b2ff 100644 --- a/app/components/markdown/markdown_table/index.tsx +++ b/app/components/markdown/markdown_table/index.tsx @@ -4,11 +4,11 @@ import React, {PureComponent, ReactNode} from 'react'; import {injectIntl, IntlShape} from 'react-intl'; import {Dimensions, EventSubscription, LayoutChangeEvent, Platform, ScaledSize, ScrollView, View} from 'react-native'; +import {TouchableOpacity} from 'react-native-gesture-handler'; import LinearGradient from 'react-native-linear-gradient'; import CompassIcon from '@components/compass_icon'; import {CELL_MAX_WIDTH, CELL_MIN_WIDTH} from '@components/markdown/markdown_table_cell'; -import TouchableWithFeedback from '@components/touchable_with_feedback'; import DeviceTypes from '@constants/device'; import {goToScreen} from '@screens/navigation'; import {preventDoubleTap} from '@utils/tap'; @@ -244,8 +244,7 @@ class MarkdownTable extends PureComponent 0) { expandButton = ( - - + ); } return ( - + ); } } diff --git a/app/components/markdown/markdown_table_image/index.tsx b/app/components/markdown/markdown_table_image/index.tsx index 5380133712..d6626e8cce 100644 --- a/app/components/markdown/markdown_table_image/index.tsx +++ b/app/components/markdown/markdown_table_image/index.tsx @@ -3,36 +3,40 @@ import React, {memo, useCallback, useRef, useState} from 'react'; import {StyleSheet, View} from 'react-native'; +import {TapGestureHandler} from 'react-native-gesture-handler'; +import Animated from 'react-native-reanimated'; import parseUrl from 'url-parse'; import CompassIcon from '@components/compass_icon'; import ProgressiveImage from '@components/progressive_image'; -import TouchableWithFeedback from '@components/touchable_with_feedback'; import {useServerUrl} from '@context/server'; -import {openGallerWithMockFile} from '@utils/gallery'; +import {useGalleryItem} from '@hooks/gallery'; +import {fileToGalleryItem, openGalleryAtIndex} from '@utils/gallery'; import {generateId} from '@utils/general'; import {calculateDimensions, isGifTooLarge} from '@utils/images'; type MarkdownTableImageProps = { disabled?: boolean; imagesMetadata: Record; + location?: string; postId: string; serverURL?: string; source: string; } -const styles = StyleSheet.create({ +const style = StyleSheet.create({ container: { alignItems: 'center', flex: 1, }, }); -const MarkTableImage = ({disabled, imagesMetadata, postId, serverURL, source}: MarkdownTableImageProps) => { +const MarkTableImage = ({disabled, imagesMetadata, location, postId, serverURL, source}: MarkdownTableImageProps) => { const metadata = imagesMetadata[source]; - const fileId = useRef(generateId()).current; + const fileId = useRef(generateId('uid')).current; const [failed, setFailed] = useState(isGifTooLarge(metadata)); const currentServerUrl = useServerUrl(); + const galleryIdentifier = `${postId}-${fileId}-${location}`; const getImageSource = () => { let uri = source; @@ -78,9 +82,19 @@ const MarkTableImage = ({disabled, imagesMetadata, postId, serverURL, source}: M if (!file?.uri) { return; } - openGallerWithMockFile(file.uri, file.post_id, file.height, file.width, file.id); + const item: GalleryItemType = { + ...fileToGalleryItem(file), + type: 'image', + }; + openGalleryAtIndex(galleryIdentifier, 0, [item]); }, []); + const {ref, onGestureEvent, styles} = useGalleryItem( + galleryIdentifier, + 0, + handlePreviewImage, + ); + const onLoadFailed = useCallback(() => { setFailed(true); }, []); @@ -96,24 +110,29 @@ const MarkTableImage = ({disabled, imagesMetadata, postId, serverURL, source}: M } else { const {height, width} = calculateDimensions(metadata.height, metadata.width, 100, 100); image = ( - - - + + + + ); } return ( - + {image} ); diff --git a/app/components/post_draft/draft_input/index.tsx b/app/components/post_draft/draft_input/index.tsx index 3fa4a5255c..4f135c2a97 100644 --- a/app/components/post_draft/draft_input/index.tsx +++ b/app/components/post_draft/draft_input/index.tsx @@ -18,6 +18,7 @@ type Props = { testID?: string; channelId: string; rootId?: string; + currentUserId: string; // Cursor Position Handler updateCursorPosition: (pos: number) => void; @@ -77,6 +78,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => { export default function DraftInput({ testID, channelId, + currentUserId, files, maxMessageLength, rootId = '', @@ -139,6 +141,7 @@ export default function DraftInput({ sendMessage={sendMessage} /> { }); export default function Uploads({ + currentUserId, files, uploadFileError, channelId, rootId, }: Props) { + const galleryIdentifier = `${channelId}-uploadedItems-${rootId}`; const theme = useTheme(); const style = getStyleSheet(theme); const errorHeight = useSharedValue(ERROR_HEIGHT_MIN); const containerHeight = useSharedValue(CONTAINER_HEIGHT_MAX); + const filesForGallery = useRef(files.filter((f) => !f.failed && !DraftUploadManager.isUploading(f.clientId!))); const errorAnimatedStyle = useAnimatedStyle(() => { return { @@ -90,9 +95,13 @@ export default function Uploads({ }; }); - const fileContainerStyle = { + const fileContainerStyle = useMemo(() => ({ paddingBottom: files.length ? 5 : 0, - }; + }), [files.length]); + + useEffect(() => { + filesForGallery.current = files.filter((f) => !f.failed && !DraftUploadManager.isUploading(f.clientId!)); + }, [files]); useEffect(() => { if (uploadFileError) { @@ -111,19 +120,21 @@ export default function Uploads({ }, [files.length > 0]); const openGallery = useCallback((file: FileInfo) => { - const galleryFiles = files.filter((f) => !f.failed && !DraftUploadManager.isUploading(f.clientId!)); - const index = galleryFiles.indexOf(file); - openGalleryAtIndex(index, galleryFiles); - }, [files]); + const items = filesForGallery.current.map((f) => fileToGalleryItem(f, currentUserId)); + const index = filesForGallery.current.findIndex((f) => f.clientId === file.clientId); + openGalleryAtIndex(galleryIdentifier, index, items, true); + }, [currentUserId, files]); const buildFilePreviews = () => { - return files.map((file) => { + return files.map((file, index) => { return ( ); @@ -131,33 +142,35 @@ export default function Uploads({ }; return ( - - - + + - {buildFilePreviews()} - - + + {buildFilePreviews()} + + - - {Boolean(uploadFileError) && - + + {Boolean(uploadFileError) && + - - {uploadFileError} - + + {uploadFileError} + - - } - - + + } + + + ); } diff --git a/app/components/post_draft/uploads/upload_item/index.tsx b/app/components/post_draft/uploads/upload_item/index.tsx index 93efd940ff..0d98b2f4d1 100644 --- a/app/components/post_draft/uploads/upload_item/index.tsx +++ b/app/components/post_draft/uploads/upload_item/index.tsx @@ -2,7 +2,9 @@ // See LICENSE.txt for license information. import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import {StyleSheet, TouchableOpacity, View} from 'react-native'; +import {StyleSheet, View} from 'react-native'; +import {TapGestureHandler} from 'react-native-gesture-handler'; +import Animated from 'react-native-reanimated'; import {updateDraftFile} from '@actions/local/draft'; import FileIcon from '@components/post_list/post/body/files/file_icon'; @@ -11,6 +13,7 @@ import ProgressBar from '@components/progress_bar'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import useDidUpdate from '@hooks/did_update'; +import {useGalleryItem} from '@hooks/gallery'; import DraftUploadManager from '@init/draft_upload_manager'; import {isImage} from '@utils/file'; import {changeOpacity} from '@utils/theme'; @@ -19,13 +22,15 @@ import UploadRemove from './upload_remove'; import UploadRetry from './upload_retry'; type Props = { - file: FileInfo; channelId: string; - rootId: string; + galleryIdentifier: string; + index: number; + file: FileInfo; openGallery: (file: FileInfo) => void; + rootId: string; } -const styles = StyleSheet.create({ +const style = StyleSheet.create({ preview: { paddingTop: 5, marginLeft: 12, @@ -52,10 +57,8 @@ const styles = StyleSheet.create({ }); export default function UploadItem({ - file, - channelId, - rootId, - openGallery, + channelId, galleryIdentifier, index, file, + rootId, openGallery, }: Props) { const theme = useTheme(); const serverUrl = useServerUrl(); @@ -101,11 +104,14 @@ export default function UploadItem({ DraftUploadManager.registerProgressHandler(newFile.clientId!, setProgress); }, [serverUrl, channelId, rootId, file]); + const {styles, onGestureEvent, ref} = useGalleryItem(galleryIdentifier, index, handlePress); + const filePreviewComponent = useMemo(() => { if (isImage(file)) { return ( ); @@ -122,21 +128,21 @@ export default function UploadItem({ return ( - - - + + + {filePreviewComponent} - - + + {file.failed && } {loading && !file.failed && - + + {component} - + ); } diff --git a/app/components/post_list/post/body/add_members/index.tsx b/app/components/post_list/post/body/add_members/index.tsx index 01005edc75..06e4580b3c 100644 --- a/app/components/post_list/post/body/add_members/index.tsx +++ b/app/components/post_list/post/body/add_members/index.tsx @@ -6,6 +6,7 @@ import withObservables from '@nozbe/with-observables'; import React, {ReactNode} from 'react'; import {useIntl} from 'react-intl'; import {Text} from 'react-native'; +import {TouchableOpacity} from 'react-native-gesture-handler'; import {of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; @@ -181,16 +182,14 @@ const AddMembers = ({channelType, currentUser, post, theme}: AddMembersProps) => defaultMessage={outOfChannelMessageText} style={styles.message} /> - + - + { +const ImagePreview = ({expandedLink, isReplyPost, link, location, metadata, postId, theme}: ImagePreviewProps) => { + const galleryIdentifier = `${postId}-ImagePreview-${location}`; const [error, setError] = useState(false); const serverUrl = useServerUrl(); - const fileId = useRef(generateId()).current; + const fileId = useRef(generateId('uid')).current; const [imageUrl, setImageUrl] = useState(expandedLink || link); const isTablet = useIsTablet(); const imageProps = metadata.images![link]; @@ -63,9 +68,25 @@ const ImagePreview = ({expandedLink, isReplyPost, link, metadata, postId, theme} setError(true); }, []); - const onPress = useCallback(() => { - openGallerWithMockFile(imageUrl, postId, imageProps.height, imageProps.width, fileId); - }, [imageUrl]); + const onPress = () => { + const item: GalleryItemType = { + id: fileId, + postId, + uri: imageUrl, + width: imageProps.width, + height: imageProps.height, + name: extractFilenameFromUrl(imageUrl) || 'imagePreview.png', + mime_type: lookupMimeType(imageUrl) || 'images/png', + type: 'image', + }; + openGalleryAtIndex(galleryIdentifier, 0, [item]); + }; + + const {ref, onGestureEvent, styles} = useGalleryItem( + galleryIdentifier, + 0, + onPress, + ); useEffect(() => { if (!isImageLink(link) && expandedLink === undefined) { @@ -89,8 +110,8 @@ const ImagePreview = ({expandedLink, isReplyPost, link, metadata, postId, theme} if (error || !isValidUrl(expandedLink || link) || isGifTooLarge(imageProps)) { return ( - - + + @@ -99,23 +120,23 @@ const ImagePreview = ({expandedLink, isReplyPost, link, metadata, postId, theme} ); } - // Note that the onPress prop of TouchableWithoutFeedback only works if its child is a View return ( - - - - - + + + + + + + + + ); }; diff --git a/app/components/post_list/post/body/content/index.tsx b/app/components/post_list/post/body/content/index.tsx index e25324455d..db7a2a50f5 100644 --- a/app/components/post_list/post/body/content/index.tsx +++ b/app/components/post_list/post/body/content/index.tsx @@ -15,6 +15,7 @@ import type PostModel from '@typings/database/models/servers/post'; type ContentProps = { isReplyPost: boolean; + location: string; post: PostModel; theme: Theme; } @@ -27,7 +28,7 @@ const contentType: Record = { youtube: 'youtube', }; -const Content = ({isReplyPost, post, theme}: ContentProps) => { +const Content = ({isReplyPost, location, post, theme}: ContentProps) => { let type: string = post.metadata?.embeds?.[0].type as string; if (!type && post.props?.attachments?.length) { type = contentType.app_bindings; @@ -42,6 +43,7 @@ const Content = ({isReplyPost, post, theme}: ContentProps) => { return ( { return ( { return ( { /> } {Boolean(name) && - - {name} - + + + {name} + + } ); diff --git a/app/components/post_list/post/body/content/message_attachments/attachment_image/index.tsx b/app/components/post_list/post/body/content/message_attachments/attachment_image/index.tsx index ff6d97beaf..58f42f2890 100644 --- a/app/components/post_list/post/body/content/message_attachments/attachment_image/index.tsx +++ b/app/components/post_list/post/body/content/message_attachments/attachment_image/index.tsx @@ -3,16 +3,20 @@ import React, {useCallback, useRef, useState} from 'react'; import {View} from 'react-native'; +import {TapGestureHandler} from 'react-native-gesture-handler'; +import Animated from 'react-native-reanimated'; import FileIcon from '@components/post_list/post/body/files/file_icon'; import ProgressiveImage from '@components/progressive_image'; -import TouchableWithFeedback from '@components/touchable_with_feedback'; +import {GalleryInit} from '@context/gallery'; import {useIsTablet} from '@hooks/device'; -import {openGallerWithMockFile} from '@utils/gallery'; +import {useGalleryItem} from '@hooks/gallery'; +import {lookupMimeType} from '@utils/file'; +import {openGalleryAtIndex} from '@utils/gallery'; import {generateId} from '@utils/general'; import {isGifTooLarge, calculateDimensions, getViewPortWidth} from '@utils/images'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; -import {isValidUrl} from '@utils/url'; +import {extractFilenameFromUrl, isValidUrl} from '@utils/url'; const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { return { @@ -41,13 +45,15 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { export type Props = { imageMetadata: PostImage; imageUrl: string; + location: string; postId: string; theme: Theme; } -const AttachmentImage = ({imageUrl, imageMetadata, postId, theme}: Props) => { +const AttachmentImage = ({imageUrl, imageMetadata, location, postId, theme}: Props) => { + const galleryIdentifier = `${postId}-AttachmentImage-${location}`; const [error, setError] = useState(false); - const fileId = useRef(generateId()).current; + const fileId = useRef(generateId('uid')).current; const isTablet = useIsTablet(); const {height, width} = calculateDimensions(imageMetadata.height, imageMetadata.width, getViewPortWidth(false, isTablet)); const style = getStyleSheet(theme); @@ -56,9 +62,25 @@ const AttachmentImage = ({imageUrl, imageMetadata, postId, theme}: Props) => { setError(true); }, []); - const onPress = useCallback(() => { - openGallerWithMockFile(imageUrl, postId, imageMetadata.height, imageMetadata.width); - }, [imageUrl]); + const onPress = () => { + const item: GalleryItemType = { + id: fileId, + postId, + uri: imageUrl, + width: imageMetadata.width, + height: imageMetadata.height, + name: extractFilenameFromUrl(imageUrl) || 'attachmentImage.png', + mime_type: lookupMimeType(imageUrl) || 'images/png', + type: 'image', + }; + openGalleryAtIndex(galleryIdentifier, 0, [item]); + }; + + const {ref, onGestureEvent, styles} = useGalleryItem( + galleryIdentifier, + 0, + onPress, + ); if (error || !isValidUrl(imageUrl) || isGifTooLarge(imageMetadata)) { return ( @@ -73,24 +95,23 @@ const AttachmentImage = ({imageUrl, imageMetadata, postId, theme}: Props) => { } return ( - - - - - + + + + + + + + + ); }; diff --git a/app/components/post_list/post/body/content/message_attachments/attachment_title.tsx b/app/components/post_list/post/body/content/message_attachments/attachment_title.tsx index 326e25efe2..723fdff9a5 100644 --- a/app/components/post_list/post/body/content/message_attachments/attachment_title.tsx +++ b/app/components/post_list/post/body/content/message_attachments/attachment_title.tsx @@ -4,6 +4,7 @@ import React from 'react'; import {useIntl} from 'react-intl'; import {Alert, Text, View} from 'react-native'; +import {TouchableOpacity} from 'react-native-gesture-handler'; import Markdown from '@components/markdown'; import {makeStyleSheetFromTheme} from '@utils/theme'; @@ -59,12 +60,11 @@ const AttachmentTitle = ({link, theme, value}: Props) => { let title; if (link) { title = ( - - {value} - + + + {value} + + ); } else { title = ( diff --git a/app/components/post_list/post/body/content/message_attachments/index.tsx b/app/components/post_list/post/body/content/message_attachments/index.tsx index 9657afa846..68a8338985 100644 --- a/app/components/post_list/post/body/content/message_attachments/index.tsx +++ b/app/components/post_list/post/body/content/message_attachments/index.tsx @@ -8,8 +8,9 @@ import MessageAttachment from './message_attachment'; type Props = { attachments: MessageAttachment[]; - postId: string; + location: string; metadata?: PostMetadata; + postId: string; theme: Theme; } @@ -20,7 +21,7 @@ const styles = StyleSheet.create({ }, }); -const MessageAttachments = ({attachments, metadata, postId, theme}: Props) => { +const MessageAttachments = ({attachments, location, metadata, postId, theme}: Props) => { const content = [] as React.ReactNode[]; attachments.forEach((attachment, i) => { @@ -28,6 +29,7 @@ const MessageAttachments = ({attachments, metadata, postId, theme}: Props) => { { }; }); -export default function MessageAttachment({attachment, metadata, postId, theme}: Props) { +export default function MessageAttachment({attachment, location, metadata, postId, theme}: Props) { const style = getStyleSheet(theme); const blockStyles = getMarkdownBlockStyles(theme); const textStyles = getMarkdownTextStyles(theme); @@ -132,6 +133,7 @@ export default function MessageAttachment({attachment, metadata, postId, theme}: diff --git a/app/components/post_list/post/body/content/opengraph/index.tsx b/app/components/post_list/post/body/content/opengraph/index.tsx index 0e0c8199f7..f2658be295 100644 --- a/app/components/post_list/post/body/content/opengraph/index.tsx +++ b/app/components/post_list/post/body/content/opengraph/index.tsx @@ -7,10 +7,10 @@ import withObservables from '@nozbe/with-observables'; import React from 'react'; import {useIntl} from 'react-intl'; import {Alert, Text, View} from 'react-native'; +import {TouchableOpacity} from 'react-native-gesture-handler'; import {of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; -import TouchableWithFeedback from '@components/touchable_with_feedback'; import {Preferences} from '@constants'; import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; import {getPreferenceAsBool} from '@helpers/api/preference'; @@ -25,6 +25,7 @@ import type SystemModel from '@typings/database/models/servers/system'; type OpengraphProps = { isReplyPost: boolean; + location: string; metadata: PostMetadata; postId: string; showLinkPreviews: boolean; @@ -70,7 +71,7 @@ const selectOpenGraphData = (url: string, metadata: PostMetadata) => { })?.data; }; -const Opengraph = ({isReplyPost, metadata, postId, showLinkPreviews, theme}: OpengraphProps) => { +const Opengraph = ({isReplyPost, location, metadata, postId, showLinkPreviews, theme}: OpengraphProps) => { const intl = useIntl(); const link = metadata.embeds![0]!.url; const openGraphData = selectOpenGraphData(link, metadata); @@ -123,10 +124,9 @@ const Opengraph = ({isReplyPost, metadata, postId, showLinkPreviews, theme}: Ope if (title) { siteTitle = ( - {title as string} - + ); } @@ -163,6 +163,7 @@ const Opengraph = ({isReplyPost, metadata, postId, showLinkPreviews, theme}: Ope {hasImage && { if (removeLinkPreview) { return {showLinkPreviews: of$(false)}; @@ -198,4 +199,4 @@ const withOpenGraphInput = withObservables( return {showLinkPreviews}; }); -export default withDatabase(withOpenGraphInput(React.memo(Opengraph))); +export default withDatabase(enhanced(React.memo(Opengraph))); diff --git a/app/components/post_list/post/body/content/opengraph/opengraph_image/index.tsx b/app/components/post_list/post/body/content/opengraph/opengraph_image/index.tsx index cc55a6f652..069ed57e1f 100644 --- a/app/components/post_list/post/body/content/opengraph/opengraph_image/index.tsx +++ b/app/components/post_list/post/body/content/opengraph/opengraph_image/index.tsx @@ -1,21 +1,26 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback, useRef} from 'react'; -import {useWindowDimensions, View} from 'react-native'; +import React, {useMemo, useRef} from 'react'; +import {useWindowDimensions} from 'react-native'; import FastImage, {Source} from 'react-native-fast-image'; +import {TapGestureHandler} from 'react-native-gesture-handler'; +import Animated from 'react-native-reanimated'; -import TouchableWithFeedback from '@components/touchable_with_feedback'; import {Device as DeviceConstant, View as ViewConstants} from '@constants'; -import {openGallerWithMockFile} from '@utils/gallery'; +import {GalleryInit} from '@context/gallery'; +import {useGalleryItem} from '@hooks/gallery'; +import {lookupMimeType} from '@utils/file'; +import {openGalleryAtIndex} from '@utils/gallery'; import {generateId} from '@utils/general'; import {calculateDimensions} from '@utils/images'; import {BestImage, getNearestPoint} from '@utils/opengraph'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; -import {isValidUrl} from '@utils/url'; +import {extractFilenameFromUrl, isValidUrl} from '@utils/url'; type OpengraphImageProps = { isReplyPost: boolean; + location: string; metadata: PostMetadata; openGraphImages: never[]; postId: string; @@ -49,14 +54,16 @@ const getViewPostWidth = (isReplyPost: boolean, deviceHeight: number, deviceWidt return viewPortWidth - tabletOffset; }; -const OpengraphImage = ({isReplyPost, metadata, openGraphImages, postId, theme}: OpengraphImageProps) => { - const fileId = useRef(generateId()).current; +const OpengraphImage = ({isReplyPost, location, metadata, openGraphImages, postId, theme}: OpengraphImageProps) => { + const fileId = useRef(generateId('uid')).current; const dimensions = useWindowDimensions(); const style = getStyleSheet(theme); - const bestDimensions = { + const galleryIdentifier = `${postId}-OpenGraphImage-${location}`; + + const bestDimensions = useMemo(() => ({ height: MAX_IMAGE_HEIGHT, width: getViewPostWidth(isReplyPost, dimensions.height, dimensions.width), - }; + }), [isReplyPost, dimensions]); const bestImage = getNearestPoint(bestDimensions, openGraphImages, 'width', 'height') as BestImage; const imageUrl = (bestImage.secure_url || bestImage.url)!; const imagesMetadata = metadata.images; @@ -81,30 +88,50 @@ const OpengraphImage = ({isReplyPost, metadata, openGraphImages, postId, theme}: imageDimensions = calculateDimensions(ogImage.height, ogImage.width, getViewPostWidth(isReplyPost, dimensions.height, dimensions.width)); } - const onPress = useCallback(() => { - openGallerWithMockFile(imageUrl, postId, imageDimensions.height, imageDimensions.width, fileId); - }, []); + const onPress = () => { + const item: GalleryItemType = { + id: fileId, + postId, + uri: imageUrl, + width: imageDimensions.width, + height: imageDimensions.height, + name: extractFilenameFromUrl(imageUrl) || 'openGraph.png', + mime_type: lookupMimeType(imageUrl) || 'images/png', + type: 'image', + }; + openGalleryAtIndex(galleryIdentifier, 0, [item]); + }; const source: Source = {}; if (isValidUrl(imageUrl)) { source.uri = imageUrl; } + const {ref, onGestureEvent, styles} = useGalleryItem( + galleryIdentifier, + 0, + onPress, + ); + const dimensionsStyle = {width: imageDimensions.width, height: imageDimensions.height}; return ( - - - - - + + + + + + + + + ); }; diff --git a/app/components/post_list/post/body/content/youtube/index.tsx b/app/components/post_list/post/body/content/youtube/index.tsx index 15f78ea489..038c6f37e7 100644 --- a/app/components/post_list/post/body/content/youtube/index.tsx +++ b/app/components/post_list/post/body/content/youtube/index.tsx @@ -5,13 +5,13 @@ import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; import withObservables from '@nozbe/with-observables'; import React, {useCallback} from 'react'; import {useIntl} from 'react-intl'; -import {Alert, Image, Platform, StatusBar, StyleSheet} from 'react-native'; +import {Alert, Image, Platform, StatusBar, StyleSheet, View} from 'react-native'; +import {TouchableOpacity} from 'react-native-gesture-handler'; import {YouTubeStandaloneAndroid, YouTubeStandaloneIOS} from 'react-native-youtube'; import {of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; import ProgressiveImage from '@components/progressive_image'; -import TouchableWithFeedback from '@components/touchable_with_feedback'; import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; import {useIsTablet} from '@hooks/device'; import {emptyFunction} from '@utils/general'; @@ -157,10 +157,9 @@ const YouTube = ({googleDeveloperKey, isReplyPost, metadata}: YouTubeProps) => { } return ( - { resizeMode='cover' onError={emptyFunction} > - + - + - + ); }; diff --git a/app/components/post_list/post/body/failed/index.tsx b/app/components/post_list/post/body/failed/index.tsx index d48f6ce31e..3a6f897904 100644 --- a/app/components/post_list/post/body/failed/index.tsx +++ b/app/components/post_list/post/body/failed/index.tsx @@ -4,11 +4,11 @@ import React, {useCallback} from 'react'; import {useIntl} from 'react-intl'; import {StyleSheet, View} from 'react-native'; +import {TouchableOpacity} from 'react-native-gesture-handler'; import {removePost} from '@actions/local/post'; import CompassIcon from '@components/compass_icon'; import SlideUpPanelItem, {ITEM_HEIGHT} from '@components/slide_up_panel_item'; -import TouchableWithFeedback from '@components/touchable_with_feedback'; import {useServerUrl} from '@context/server'; import {bottomSheet, dismissBottomSheet} from '@screens/navigation'; @@ -75,17 +75,16 @@ const Failed = ({post, theme}: FailedProps) => { }, []); return ( - - + ); }; diff --git a/app/components/post_list/post/body/files/document_file.tsx b/app/components/post_list/post/body/files/document_file.tsx index 9e23b6966c..082c541295 100644 --- a/app/components/post_list/post/body/files/document_file.tsx +++ b/app/components/post_list/post/body/files/document_file.tsx @@ -7,16 +7,15 @@ import React, {forwardRef, useImperativeHandle, useRef, useState} from 'react'; import {useIntl} from 'react-intl'; import {Platform, StatusBar, StatusBarStyle, StyleSheet, View} from 'react-native'; import FileViewer from 'react-native-file-viewer'; +import {TouchableOpacity} from 'react-native-gesture-handler'; import tinyColor from 'tinycolor2'; import ProgressBar from '@components/progress_bar'; -import TouchableWithFeedback from '@components/touchable_with_feedback'; -import {Device} from '@constants'; import {DOWNLOAD_TIMEOUT} from '@constants/network'; import {useServerUrl} from '@context/server'; import NetworkManager from '@init/network_manager'; import {alertDownloadDocumentDisabled, alertDownloadFailed, alertFailedToOpenDocument} from '@utils/document'; -import {getLocalFilePathFromFile} from '@utils/file'; +import {fileExists, getLocalFilePathFromFile} from '@utils/file'; import FileIcon from './file_icon'; @@ -33,7 +32,6 @@ type DocumentFileProps = { theme: Theme; } -const {DOCUMENTS_PATH} = Device; const styles = StyleSheet.create({ progress: { justifyContent: 'flex-end', @@ -67,20 +65,17 @@ const DocumentFile = forwardRef(({background }; const downloadAndPreviewFile = async () => { - const path = getLocalFilePathFromFile(DOCUMENTS_PATH, serverUrl, file); setDidCancel(false); + let path; try { - let exists = false; - if (path) { - const info = await FileSystem.getInfoAsync(path); - exists = info.exists; - } + path = getLocalFilePathFromFile(serverUrl, file); + const exists = await fileExists(path); if (exists) { openDocument(); } else { setDownloading(true); - downloadTask.current = client?.apiClient.download(client?.getFileRoute(file.id!), path!, {timeoutInterval: DOWNLOAD_TIMEOUT}); + downloadTask.current = client?.apiClient.download(client?.getFileRoute(file.id!), path!.replace('file://', ''), {timeoutInterval: DOWNLOAD_TIMEOUT}); downloadTask.current?.progress?.(setProgress); await downloadTask.current; @@ -126,7 +121,7 @@ const DocumentFile = forwardRef(({background const openDocument = () => { if (!didCancel && !preview) { - const path = getLocalFilePathFromFile(DOCUMENTS_PATH, serverUrl, file); + const path = getLocalFilePathFromFile(serverUrl, file); setPreview(true); setStatusBarColor('dark-content'); FileViewer.open(path!, { @@ -190,12 +185,9 @@ const DocumentFile = forwardRef(({background } return ( - + {fileAttachmentComponent} - + ); }); diff --git a/app/components/post_list/post/body/files/file.tsx b/app/components/post_list/post/body/files/file.tsx index 27be5832ff..5d54974845 100644 --- a/app/components/post_list/post/body/files/file.tsx +++ b/app/components/post_list/post/body/files/file.tsx @@ -1,11 +1,14 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useRef} from 'react'; +import React, {useCallback, useRef} from 'react'; import {View} from 'react-native'; +import {TapGestureHandler} from 'react-native-gesture-handler'; +import Animated from 'react-native-reanimated'; import TouchableWithFeedback from '@components/touchable_with_feedback'; -import {isDocument, isImage} from '@utils/file'; +import {useGalleryItem} from '@hooks/gallery'; +import {isDocument, isImage, isVideo} from '@utils/file'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import DocumentFile, {DocumentFileRef} from './document_file'; @@ -13,17 +16,21 @@ import FileIcon from './file_icon'; import FileInfo from './file_info'; import ImageFile from './image_file'; import ImageFileOverlay from './image_file_overlay'; +import VideoFile from './video_file'; type FileProps = { canDownloadFiles: boolean; file: FileInfo; + galleryIdentifier: string; index: number; inViewPort: boolean; isSingleImage: boolean; nonVisibleImagesCount: number; onPress: (index: number) => void; + publicLinkEnabled: boolean; theme: Theme; wrapperWidth?: number; + updateFileForGallery: (idx: number, file: FileInfo) => void; }; const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { @@ -46,44 +53,73 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { }); const File = ({ - canDownloadFiles, file, index = 0, inViewPort = false, isSingleImage = false, - nonVisibleImagesCount = 0, onPress, theme, wrapperWidth = 300, + canDownloadFiles, file, galleryIdentifier, index, inViewPort = false, isSingleImage = false, + nonVisibleImagesCount = 0, onPress, publicLinkEnabled, theme, wrapperWidth = 300, updateFileForGallery, }: FileProps) => { const document = useRef(null); const style = getStyleSheet(theme); - const handlePress = () => { - onPress(index); - }; - - const handlePreviewPress = () => { + const handlePreviewPress = useCallback(() => { if (document.current) { document.current.handlePreviewPress(); } else { - handlePress(); + onPress(index); } - }; + }, [index]); + + const {styles, onGestureEvent, ref} = useGalleryItem(galleryIdentifier, index, handlePreviewPress); + + if (isVideo(file) && publicLinkEnabled) { + return ( + + + + {Boolean(nonVisibleImagesCount) && + + } + + + ); + } if (isImage(file)) { return ( - - - {Boolean(nonVisibleImagesCount) && - - } - + + + {Boolean(nonVisibleImagesCount) && + + } + + ); } diff --git a/app/components/post_list/post/body/files/file_info.tsx b/app/components/post_list/post/body/files/file_info.tsx index 6c1a5c2917..240b88ce65 100644 --- a/app/components/post_list/post/body/files/file_info.tsx +++ b/app/components/post_list/post/body/files/file_info.tsx @@ -3,8 +3,8 @@ import React from 'react'; import {Text, View} from 'react-native'; +import {TouchableOpacity} from 'react-native-gesture-handler'; -import TouchableWithFeedback from '@components/touchable_with_feedback'; import {getFormattedFileSize} from '@utils/file'; import {makeStyleSheetFromTheme} from '@utils/theme'; @@ -43,12 +43,8 @@ const FileInfo = ({file, onPress, theme}: FileInfoProps) => { const style = getStyleSheet(theme); return ( - - <> + + { {`${getFormattedFileSize(file.size)}`} - - + + ); }; diff --git a/app/components/post_list/post/body/files/index.tsx b/app/components/post_list/post/body/files/files.tsx similarity index 54% rename from app/components/post_list/post/body/files/index.tsx rename to app/components/post_list/post/body/files/files.tsx index 4ea49ba7ba..36e9adb65e 100644 --- a/app/components/post_list/post/body/files/index.tsx +++ b/app/components/post_list/post/body/files/files.tsx @@ -1,37 +1,32 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; -import React, {useEffect, useMemo, useRef, useState} from 'react'; +import React, {useEffect, useMemo, useState} from 'react'; import {DeviceEventEmitter, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'; -import {combineLatest, of as of$} from 'rxjs'; -import {map, switchMap} from 'rxjs/operators'; +import Animated, {useDerivedValue} from 'react-native-reanimated'; -import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; +import {buildFilePreviewUrl, buildFileUrl} from '@actions/remote/file'; +import {GalleryInit} from '@context/gallery'; import {useServerUrl} from '@context/server'; import {useIsTablet} from '@hooks/device'; -import NetworkManager from '@init/network_manager'; -import {isGif, isImage} from '@utils/file'; -import {openGalleryAtIndex} from '@utils/gallery'; +import {isGif, isImage, isVideo} from '@utils/file'; +import {fileToGalleryItem, openGalleryAtIndex} from '@utils/gallery'; import {getViewPortWidth} from '@utils/images'; import {preventDoubleTap} from '@utils/tap'; import File from './file'; -import type {Client} from '@client/rest'; -import type {WithDatabaseArgs} from '@typings/database/database'; import type FileModel from '@typings/database/models/servers/file'; -import type PostModel from '@typings/database/models/servers/post'; -import type SystemModel from '@typings/database/models/servers/system'; type FilesProps = { authorId: string; canDownloadFiles: boolean; failed?: boolean; files: FileModel[]; + location: string; isReplyPost: boolean; postId: string; + publicLinkEnabled: boolean; theme: Theme; } @@ -53,62 +48,55 @@ const styles = StyleSheet.create({ }, }); -const Files = ({authorId, canDownloadFiles, failed, files, isReplyPost, postId, theme}: FilesProps) => { +const Files = ({authorId, canDownloadFiles, failed, files, isReplyPost, location, postId, publicLinkEnabled, theme}: FilesProps) => { + const galleryIdentifier = `${postId}-fileAttachments-${location}`; const [inViewPort, setInViewPort] = useState(false); const serverUrl = useServerUrl(); const isTablet = useIsTablet(); - const imageAttachments = useRef([]).current; - const nonImageAttachments = useRef([]).current; - const filesInfo: FileInfo[] = useMemo(() => files.map((f) => ({ - id: f.id, - user_id: authorId, - post_id: postId, - create_at: 0, - delete_at: 0, - update_at: 0, - name: f.name, - extension: f.extension, - mini_preview: f.imageThumbnail, - size: f.size, - mime_type: f.mimeType, - height: f.height, - has_preview_image: Boolean(f.imageThumbnail), - localPath: f.localPath, - width: f.width, - })), [files]); - let client: Client | undefined; - try { - client = NetworkManager.getClient(serverUrl); - } catch { - // do nothing - } + const filesInfo: FileInfo[] = useMemo(() => files.map((f) => f.toFileInfo(authorId)), [authorId, files]); - if (!imageAttachments.length && !nonImageAttachments.length) { - filesInfo.reduce((info, file) => { - if (isImage(file)) { + const {images: imageAttachments, nonImages: nonImageAttachments} = useMemo(() => { + return filesInfo.reduce(({images, nonImages}: {images: FileInfo[]; nonImages: FileInfo[]}, file) => { + const imageFile = isImage(file); + const videoFile = isVideo(file); + if (imageFile || (videoFile && publicLinkEnabled)) { let uri; if (file.localPath) { uri = file.localPath; } else { - uri = isGif(file) ? client?.getFileUrl(file.id!, 0) : client?.getFilePreviewUrl(file.id!, 0); + uri = (isGif(file) || videoFile) ? buildFileUrl(serverUrl, file.id!) : buildFilePreviewUrl(serverUrl, file.id!); } - info.imageAttachments.push({...file, uri}); + images.push({...file, uri}); } else { - info.nonImageAttachments.push(file); - } - return info; - }, {imageAttachments, nonImageAttachments}); - } + if (videoFile) { + // fallback if public links are not enabled + file.uri = buildFileUrl(serverUrl, file.id!); + } + + nonImages.push(file); + } + return {images, nonImages}; + }, {images: [], nonImages: []}); + }, [files, publicLinkEnabled, serverUrl]); + + const filesForGallery = useDerivedValue(() => imageAttachments.concat(nonImageAttachments), + [imageAttachments, nonImageAttachments]); - const filesForGallery = useRef(imageAttachments.concat(nonImageAttachments)).current; const attachmentIndex = (fileId: string) => { - return filesForGallery.findIndex((file) => file.id === fileId) || 0; + return filesForGallery.value.findIndex((file) => file.id === fileId) || 0; }; const handlePreviewPress = preventDoubleTap((idx: number) => { - openGalleryAtIndex(idx, filesForGallery); + const items = filesForGallery.value.map((f) => fileToGalleryItem(f, authorId)); + openGalleryAtIndex(galleryIdentifier, idx, items); }); + const updateFileForGallery = (idx: number, file: FileInfo) => { + 'worklet'; + + filesForGallery.value[idx] = file; + }; + const isSingleImage = () => (files.length === 1 && isImage(files[0])); const renderItems = (items: FileInfo[], moreImagesCount = 0, includeGutter = false) => { @@ -125,13 +113,13 @@ const Files = ({authorId, canDownloadFiles, failed, files, isReplyPost, postId, if (idx !== 0 && includeGutter) { container = containerWithGutter; } - return ( @@ -179,30 +169,13 @@ const Files = ({authorId, canDownloadFiles, failed, files, isReplyPost, postId, }, []); return ( - - {renderImageRow()} - {renderItems(nonImageAttachments)} - + + + {renderImageRow()} + {renderItems(nonImageAttachments)} + + ); }; -const withCanDownload = withObservables(['post'], ({database, post}: {post: PostModel} & WithDatabaseArgs) => { - const enableMobileFileDownload = database.get(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe( - switchMap(({value}: {value: ClientConfig}) => of$(value.EnableMobileFileDownload !== 'false')), - ); - const complianceDisabled = database.get(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.LICENSE).pipe( - switchMap(({value}: {value: ClientLicense}) => of$(value.IsLicensed === 'false' || value.Compliance === 'false')), - ); - - const canDownloadFiles = combineLatest([enableMobileFileDownload, complianceDisabled]).pipe( - map(([download, compliance]) => compliance || download), - ); - - return { - authorId: of$(post.userId), - canDownloadFiles, - postId: of$(post.id), - }; -}); - -export default withDatabase(withCanDownload(React.memo(Files))); +export default React.memo(Files); diff --git a/app/components/post_list/post/body/files/image_file.tsx b/app/components/post_list/post/body/files/image_file.tsx index 90f5a51891..dab45fe003 100644 --- a/app/components/post_list/post/body/files/image_file.tsx +++ b/app/components/post_list/post/body/files/image_file.tsx @@ -1,51 +1,48 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback, useState} from 'react'; +import React, {useCallback, useEffect, useState} from 'react'; import {StyleProp, StyleSheet, useWindowDimensions, View, ViewStyle} from 'react-native'; +import LinearGradient from 'react-native-linear-gradient'; +import {buildFilePreviewUrl, buildFileThumbnailUrl} from '@actions/remote/file'; +import CompassIcon from '@components/compass_icon'; import ProgressiveImage from '@components/progressive_image'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; -import NetworkManager from '@init/network_manager'; +import {isGif as isGifImage} from '@utils/file'; import {calculateDimensions} from '@utils/images'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import FileIcon from './file_icon'; -import type {Client} from '@client/rest'; import type {ResizeMode} from 'react-native-fast-image'; type ImageFileProps = { backgroundColor?: string; file: FileInfo; + forwardRef: React.RefObject; inViewPort?: boolean; isSingleImage?: boolean; resizeMode?: ResizeMode; wrapperWidth?: number; } -type ProgressiveImageProps = { - defaultSource?: {uri: string}; - imageUri?: string; - inViewPort?: boolean; - thumbnailUri?: string; -} - const SMALL_IMAGE_MAX_HEIGHT = 48; const SMALL_IMAGE_MAX_WIDTH = 48; +const GRADIENT_COLORS = ['rgba(0, 0, 0, 0)', 'rgba(0, 0, 0, .32)']; +const GRADIENT_END = {x: 1, y: 1}; +const GRADIENT_LOCATIONS = [0.5, 1]; +const GRADIENT_START = {x: 0.5, y: 0.5}; const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ - imagePreview: { - ...StyleSheet.absoluteFillObject, + boxPlaceholder: { + paddingBottom: '100%', }, fileImageWrapper: { borderRadius: 5, overflow: 'hidden', }, - boxPlaceholder: { - paddingBottom: '100%', - }, failed: { justifyContent: 'center', alignItems: 'center', @@ -53,6 +50,15 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ borderRadius: 4, borderWidth: 1, }, + gifContainer: { + alignItems: 'flex-end', + justifyContent: 'flex-end', + padding: 8, + ...StyleSheet.absoluteFillObject, + }, + imagePreview: { + ...StyleSheet.absoluteFillObject, + }, smallImageBorder: { borderRadius: 5, }, @@ -70,21 +76,16 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ })); const ImageFile = ({ - backgroundColor, file, inViewPort, isSingleImage, + backgroundColor, file, forwardRef, inViewPort, isSingleImage, resizeMode = 'cover', wrapperWidth, }: ImageFileProps) => { - const serverUrl = useServerUrl(); - const [failed, setFailed] = useState(false); const dimensions = useWindowDimensions(); const theme = useTheme(); + const serverUrl = useServerUrl(); + const [isGif, setIsGif] = useState(isGifImage(file)); + const [failed, setFailed] = useState(false); const style = getStyleSheet(theme); let image; - let client: Client | undefined; - try { - client = NetworkManager.getClient(serverUrl); - } catch { - // do nothing - } const getImageDimensions = () => { if (isSingleImage) { @@ -108,14 +109,18 @@ const ImageFile = ({ if (file.mini_preview && file.mime_type) { props.thumbnailUri = `data:${file.mime_type};base64,${file.mini_preview}`; } else { - props.thumbnailUri = client?.getFileThumbnailUrl(file.id, 0); + props.thumbnailUri = buildFileThumbnailUrl(serverUrl, file.id); } - props.imageUri = client?.getFilePreviewUrl(file.id, 0); + props.imageUri = buildFilePreviewUrl(serverUrl, file.id); props.inViewPort = inViewPort; } return props; }; + useEffect(() => { + setIsGif(isGifImage(file)); + }, [file]); + if (file.height <= SMALL_IMAGE_MAX_HEIGHT || file.width <= SMALL_IMAGE_MAX_WIDTH) { let wrapperStyle: StyleProp = style.fileImageWrapper; if (isSingleImage) { @@ -129,6 +134,7 @@ const ImageFile = ({ image = ( + + + + + + ); + } return ( {!isSingleImage && } {image} + {gifIndicator} ); }; diff --git a/app/components/post_list/post/body/files/image_file_overlay.tsx b/app/components/post_list/post/body/files/image_file_overlay.tsx index b4d4ebdf2b..604a47c135 100644 --- a/app/components/post_list/post/body/files/image_file_overlay.tsx +++ b/app/components/post_list/post/body/files/image_file_overlay.tsx @@ -1,9 +1,10 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React from 'react'; +import React, {useMemo} from 'react'; import {PixelRatio, StyleSheet, Text, useWindowDimensions, View} from 'react-native'; +import {useIsTablet} from '@hooks/device'; import {makeStyleSheetFromTheme} from '@utils/theme'; type ImageFileOverlayProps = { @@ -11,37 +12,36 @@ type ImageFileOverlayProps = { value: number; } -const getStyleSheet = (scale: number, th: Theme) => { - const style = makeStyleSheetFromTheme((theme: Theme) => { - return { - moreImagesWrapper: { - ...StyleSheet.absoluteFillObject, - justifyContent: 'center', - alignItems: 'center', - backgroundColor: 'rgba(0, 0, 0, 0.6)', - borderRadius: 5, - }, - moreImagesText: { - color: theme.sidebarHeaderTextColor, - fontSize: Math.round(PixelRatio.roundToNearestPixel(24 * scale)), - fontFamily: 'OpenSans', - textAlign: 'center', - }, - }; - }); - - return style(th); -}; +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + moreImagesWrapper: { + ...StyleSheet.absoluteFillObject, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'rgba(0, 0, 0, 0.6)', + borderRadius: 5, + }, + moreImagesText: { + color: theme.sidebarHeaderTextColor, + fontFamily: 'OpenSans', + textAlign: 'center', + }, +})); const ImageFileOverlay = ({theme, value}: ImageFileOverlayProps) => { const dimensions = useWindowDimensions(); - const scale = dimensions.width / 320; - const style = getStyleSheet(scale, theme); - return null; + const isTablet = useIsTablet(); + const style = getStyleSheet(theme); + const textStyles = useMemo(() => { + const scale = isTablet ? dimensions.scale : 1; + return [ + style.moreImagesText, + {fontSize: Math.round(PixelRatio.roundToNearestPixel(24 * scale))}, + ]; + }, [isTablet]); return ( - + {`+${value}`} diff --git a/app/components/post_list/post/body/files/index.ts b/app/components/post_list/post/body/files/index.ts new file mode 100644 index 0000000000..1b69b25a4c --- /dev/null +++ b/app/components/post_list/post/body/files/index.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; +import withObservables from '@nozbe/with-observables'; +import {combineLatest, of as of$} from 'rxjs'; +import {map, switchMap} from 'rxjs/operators'; + +import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; + +import Files from './files'; + +import type {WithDatabaseArgs} from '@typings/database/database'; +import type PostModel from '@typings/database/models/servers/post'; +import type SystemModel from '@typings/database/models/servers/system'; + +const enhance = withObservables(['post'], ({database, post}: {post: PostModel} & WithDatabaseArgs) => { + const config = database.get(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG); + const enableMobileFileDownload = config.pipe( + switchMap(({value}: {value: ClientConfig}) => of$(value.EnableMobileFileDownload !== 'false')), + ); + + const publicLinkEnabled = config.pipe( + switchMap(({value}: {value: ClientConfig}) => of$(value.EnablePublicLink !== 'false')), + ); + + const complianceDisabled = database.get(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.LICENSE).pipe( + switchMap(({value}: {value: ClientLicense}) => of$(value.IsLicensed === 'false' || value.Compliance === 'false')), + ); + + const canDownloadFiles = combineLatest([enableMobileFileDownload, complianceDisabled]).pipe( + map(([download, compliance]) => compliance || download), + ); + + return { + authorId: of$(post.userId), + canDownloadFiles, + postId: of$(post.id), + publicLinkEnabled, + }; +}); + +export default withDatabase(enhance(Files)); diff --git a/app/components/post_list/post/body/files/video_file.tsx b/app/components/post_list/post/body/files/video_file.tsx new file mode 100644 index 0000000000..8a4489cc5c --- /dev/null +++ b/app/components/post_list/post/body/files/video_file.tsx @@ -0,0 +1,187 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {getThumbnailAsync} from 'expo-video-thumbnails'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {StyleSheet, useWindowDimensions, View} from 'react-native'; + +import {updateLocalFile} from '@actions/local/file'; +import {buildFilePreviewUrl, fetchPublicLink} from '@actions/remote/file'; +import CompassIcon from '@components/compass_icon'; +import ProgressiveImage from '@components/progressive_image'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import {fileExists} from '@utils/file'; +import {calculateDimensions} from '@utils/images'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +import FileIcon from './file_icon'; + +import type {ResizeMode} from 'react-native-fast-image'; + +type Props = { + index: number; + file: FileInfo; + forwardRef: React.RefObject; + inViewPort?: boolean; + isSingleImage?: boolean; + resizeMode?: ResizeMode; + wrapperWidth?: number; + updateFileForGallery: (idx: number, file: FileInfo) => void; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + imagePreview: { + ...StyleSheet.absoluteFillObject, + }, + fileImageWrapper: { + borderRadius: 5, + overflow: 'hidden', + }, + boxPlaceholder: { + paddingBottom: '100%', + }, + failed: { + justifyContent: 'center', + alignItems: 'center', + borderColor: changeOpacity(theme.centerChannelColor, 0.2), + borderRadius: 4, + borderWidth: 1, + }, + playContainer: { + alignItems: 'center', + justifyContent: 'center', + ...StyleSheet.absoluteFillObject, + }, + play: { + backgroundColor: changeOpacity('#000', 0.16), + borderRadius: 20, + }, +})); + +const VideoFile = ({ + index, file, forwardRef, inViewPort, isSingleImage, + resizeMode = 'cover', wrapperWidth, updateFileForGallery, +}: Props) => { + const serverUrl = useServerUrl(); + const [failed, setFailed] = useState(false); + const dimensions = useWindowDimensions(); + const theme = useTheme(); + const style = getStyleSheet(theme); + const mounted = useRef(false); + const [video, setVideo] = useState({...file}); + + const imageDimensions = useMemo(() => { + if (isSingleImage) { + const viewPortHeight = Math.max(dimensions.height, dimensions.width) * 0.45; + return calculateDimensions(video.height, video.width, wrapperWidth, viewPortHeight); + } + + return undefined; + }, [dimensions.height, dimensions.width, video.height, video.width, wrapperWidth]); + + const getThumbnail = async () => { + const data = {...file}; + try { + const exists = data.mini_preview ? await fileExists(data.mini_preview) : false; + if (!data.mini_preview || !exists) { + // We use the public link to avoid having to pass the token through a third party + // library + const publicUri = await fetchPublicLink(serverUrl, data.id!); + if (('link') in publicUri) { + const {uri, height, width} = await getThumbnailAsync(publicUri.link, {time: 2000}); + data.mini_preview = uri; + data.height = height; + data.width = width; + updateLocalFile(serverUrl, data); + if (mounted.current) { + setVideo(data); + } + } + } + } catch (error) { + data.mini_preview = buildFilePreviewUrl(serverUrl, data.id!); + if (mounted.current) { + setVideo(data); + } + } finally { + const {width: tw, height: th} = calculateDimensions( + data.height, + data.width, + dimensions.width - 60, // size of the gallery header probably best to set that as a constant + dimensions.height, + ); + data.height = th; + data.width = tw; + updateFileForGallery(index, data); + } + }; + + const handleError = useCallback(() => { + setFailed(true); + }, []); + + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + + useEffect(() => { + if (inViewPort) { + getThumbnail(); + } + }, [file, inViewPort]); + + const imageProps = () => { + const props: ProgressiveImageProps = { + imageUri: video.mini_preview, + inViewPort, + }; + + return props; + }; + + let thumbnail = ( + + ); + + if (failed) { + thumbnail = ( + + + + ); + } + + return ( + + {!isSingleImage && } + {thumbnail} + + + + + + + ); +}; + +export default VideoFile; diff --git a/app/components/post_list/post/body/index.tsx b/app/components/post_list/post/body/index.tsx index 257fb8f74e..ff50ac436d 100644 --- a/app/components/post_list/post/body/index.tsx +++ b/app/components/post_list/post/body/index.tsx @@ -151,6 +151,7 @@ const Body = ({ {hasContent && @@ -159,6 +160,7 @@ const Body = ({ - - + diff --git a/app/components/post_list/post/body/reactions/reaction.tsx b/app/components/post_list/post/body/reactions/reaction.tsx index ee13520e07..3d7535f906 100644 --- a/app/components/post_list/post/body/reactions/reaction.tsx +++ b/app/components/post_list/post/body/reactions/reaction.tsx @@ -3,9 +3,9 @@ import React, {useCallback} from 'react'; import {Platform, Text} from 'react-native'; +import {TouchableOpacity} from 'react-native-gesture-handler'; import Emoji from '@components/emoji'; -import TouchableWithFeedback from '@components/touchable_with_feedback'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; type ReactionProps = { @@ -49,12 +49,11 @@ const Reaction = ({count, emojiName, highlight, onPress, onLongPress, theme}: Re }, [highlight]); return ( - {count} - + ); }; diff --git a/app/components/post_list/post/body/reactions/reactions.tsx b/app/components/post_list/post/body/reactions/reactions.tsx index 3db60beec9..be8e22a0ee 100644 --- a/app/components/post_list/post/body/reactions/reactions.tsx +++ b/app/components/post_list/post/body/reactions/reactions.tsx @@ -4,10 +4,10 @@ import React, {useRef} from 'react'; import {useIntl} from 'react-intl'; import {View} from 'react-native'; +import {TouchableOpacity} from 'react-native-gesture-handler'; import {addReaction, removeReaction} from '@actions/remote/reactions'; import CompassIcon from '@components/compass_icon'; -import TouchableWithFeedback from '@components/touchable_with_feedback'; import {MAX_ALLOWED_REACTIONS} from '@constants/emoji'; import {useServerUrl} from '@context/server'; import {showModal, showModalOverCurrentContext} from '@screens/navigation'; @@ -131,18 +131,17 @@ const Reactions = ({currentUserId, canAddReaction, canRemoveReaction, disabled, const {reactionsByName, highlightedReactions} = buildReactionsMap(); if (!disabled && canAddReaction && reactionsByName.size < MAX_ALLOWED_REACTIONS) { addMoreReactions = ( - - + ); } diff --git a/app/components/post_list/post/header/display_name/index.tsx b/app/components/post_list/post/header/display_name/index.tsx index 76a2747ec0..96b72a8546 100644 --- a/app/components/post_list/post/header/display_name/index.tsx +++ b/app/components/post_list/post/header/display_name/index.tsx @@ -4,10 +4,10 @@ import React, {useCallback, useRef} from 'react'; import {useIntl} from 'react-intl'; import {Keyboard, Text, useWindowDimensions, View} from 'react-native'; +import {TouchableOpacity} from 'react-native-gesture-handler'; import CompassIcon from '@components/compass_icon'; import FormattedText from '@components/formatted_text'; -import TouchableWithFeedback from '@components/touchable_with_feedback'; import {showModal} from '@screens/navigation'; import {preventDoubleTap} from '@utils/tap'; import {makeStyleSheetFromTheme} from '@utils/theme'; @@ -114,20 +114,18 @@ const HeaderDisplayName = ({ ); } else if (displayName) { return ( - - - {displayName} - - + + + + {displayName} + + + ); } diff --git a/app/components/post_list/post/header/reply/index.tsx b/app/components/post_list/post/header/reply/index.tsx index ee7ab591a2..e06e16c272 100644 --- a/app/components/post_list/post/header/reply/index.tsx +++ b/app/components/post_list/post/header/reply/index.tsx @@ -3,9 +3,9 @@ import React, {useCallback} from 'react'; import {Text, View} from 'react-native'; +import {TouchableOpacity} from 'react-native-gesture-handler'; import CompassIcon from '@components/compass_icon'; -import TouchableWithFeedback from '@components/touchable_with_feedback'; import {SEARCH} from '@constants/screens'; import {goToScreen} from '@screens/navigation'; import {preventDoubleTap} from '@utils/tap'; @@ -57,10 +57,9 @@ const HeaderReply = ({commentCount, location, post, theme}: HeaderReplyProps) => testID='post_header.reply' style={style.replyWrapper} > - {commentCount} } - + ); }; diff --git a/app/components/post_list/post/post.tsx b/app/components/post_list/post/post.tsx index 7bcb9fdf98..1edd0ea79f 100644 --- a/app/components/post_list/post/post.tsx +++ b/app/components/post_list/post/post.tsx @@ -4,12 +4,12 @@ import React, {ReactNode, useMemo, useRef} from 'react'; import {useIntl} from 'react-intl'; import {Keyboard, Platform, StyleProp, View, ViewStyle} from 'react-native'; +import {TouchableHighlight} from 'react-native-gesture-handler'; import {showPermalink} from '@actions/local/permalink'; import {removePost} from '@actions/local/post'; import SystemAvatar from '@components/system_avatar'; import SystemHeader from '@components/system_header'; -import TouchableWithFeedback from '@components/touchable_with_feedback'; import * as Screens from '@constants/screens'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; @@ -64,7 +64,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { consecutivePostContainer: { marginBottom: 10, marginRight: 10, - marginLeft: Platform.select({ios: 35, android: 34}), + marginLeft: Platform.select({ios: 34, android: 33}), marginTop: 10, }, container: {flexDirection: 'row'}, @@ -270,13 +270,11 @@ const Post = ({ testID={testID} style={[styles.postStyle, style, highlightedStyle]} > - <> - + ); }; diff --git a/app/components/post_list/post/system_message/__snapshots__/system_message_helpers.test.js.snap b/app/components/post_list/post/system_message/__snapshots__/system_message_helpers.test.js.snap index f7c4d4e13a..604a2c23d2 100644 --- a/app/components/post_list/post/system_message/__snapshots__/system_message_helpers.test.js.snap +++ b/app/components/post_list/post/system_message/__snapshots__/system_message_helpers.test.js.snap @@ -12,43 +12,61 @@ exports[`renderSystemMessage uses renderer for Channel Display Name update 1`] = ] } > - - + - @username - - - + + @username + + + + + - updated the channel display name from: old displayname to: new displayname - + } + testID="markdown_text" + > + updated the channel display name from: old displayname to: new displayname `; @@ -65,43 +83,61 @@ exports[`renderSystemMessage uses renderer for Channel Header update 1`] = ` ] } > - - + - @username - - - + + @username + + + + + - updated the channel header from: old header to: new header - + } + testID="markdown_text" + > + updated the channel header from: old header to: new header `; @@ -134,43 +170,61 @@ exports[`renderSystemMessage uses renderer for Guest added and join to channel 1 ] } > - - + - @username - - - + + @username + + + + + - joined the channel as a guest. - + } + testID="markdown_text" + > + joined the channel as a guest. `; @@ -187,66 +241,104 @@ exports[`renderSystemMessage uses renderer for Guest added and join to channel 2 ] } > - - - - @other.user - - - + - added to the channel as a guest by - - - @username. + + @other.user + - + + + + added to the channel as a guest by + + + + + @username. + + + + `; @@ -262,21 +354,19 @@ exports[`renderSystemMessage uses renderer for OLD archived channel without a us ] } > - - - archived the channel - + } + testID="markdown_text" + > + archived the channel `; @@ -293,43 +383,61 @@ exports[`renderSystemMessage uses renderer for archived channel 1`] = ` ] } > - - + - @username - - - + + @username + + + + + - archived the channel - + } + testID="markdown_text" + > + archived the channel `; @@ -346,43 +454,61 @@ exports[`renderSystemMessage uses renderer for unarchived channel 1`] = ` ] } > - - + - @username - - - + + @username + + + + + - unarchived the channel - + } + testID="markdown_text" + > + unarchived the channel `; diff --git a/app/components/post_list/post_list.tsx b/app/components/post_list/post_list.tsx deleted file mode 100644 index dedafc0ba0..0000000000 --- a/app/components/post_list/post_list.tsx +++ /dev/null @@ -1,233 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React, {ReactElement, useCallback, useEffect, useRef} from 'react'; -import {DeviceEventEmitter, FlatList, Platform, RefreshControl, StyleProp, StyleSheet, ViewStyle, ViewToken} from 'react-native'; - -import CombinedUserActivity from '@components/post_list/combined_user_activity'; -import DateSeparator from '@components/post_list/date_separator'; -import NewMessagesLine from '@components/post_list/new_message_line'; -import Post from '@components/post_list/post'; -import {useTheme} from '@context/theme'; -import {emptyFunction} from '@utils/general'; -import {getDateForDateLine, isCombinedUserActivityPost, isDateLine, isStartOfNewMessages, preparePostList} from '@utils/post_list'; - -import type PostModel from '@typings/database/models/servers/post'; - -type RefreshProps = { - children: ReactElement; - enabled: boolean; - onRefresh: () => void; - refreshing: boolean; -} - -type Props = { - channelId: string; - contentContainerStyle?: StyleProp; - currentTimezone: string | null; - currentUsername: string; - isTimezoneEnabled: boolean; - lastViewedAt: number; - posts: PostModel[]; - shouldShowJoinLeaveMessages: boolean; - footer?: ReactElement; - testID: string; -} - -type ViewableItemsChanged = { - viewableItems: ViewToken[]; - changed: ViewToken[]; -} - -const style = StyleSheet.create({ - container: { - flex: 1, - scaleY: -1, - }, - scale: { - ...Platform.select({ - android: { - scaleY: -1, - }, - }), - }, -}); - -export const VIEWABILITY_CONFIG = { - itemVisiblePercentThreshold: 1, - minimumViewTime: 100, -}; - -const keyExtractor = (item: string | PostModel) => (typeof item === 'string' ? item : item.id); - -const styles = StyleSheet.create({ - flex: { - flex: 1, - }, - content: { - marginHorizontal: 20, - }, -}); - -const PostListRefreshControl = ({children, enabled, onRefresh, refreshing}: RefreshProps) => { - const props = { - onRefresh, - refreshing, - }; - - if (Platform.OS === 'android') { - return ( - - {children} - - ); - } - - const refreshControl = ; - - return React.cloneElement( - children, - {refreshControl, inverted: true}, - ); -}; - -const PostList = ({channelId, contentContainerStyle, currentTimezone, currentUsername, footer, isTimezoneEnabled, lastViewedAt, posts, shouldShowJoinLeaveMessages, testID}: Props) => { - const listRef = useRef(null); - const theme = useTheme(); - const orderedPosts = preparePostList(posts, lastViewedAt, true, currentUsername, shouldShowJoinLeaveMessages, isTimezoneEnabled, currentTimezone, false); - - useEffect(() => { - listRef.current?.scrollToOffset({offset: 0, animated: false}); - }, [channelId, listRef.current]); - - const onViewableItemsChanged = useCallback(({viewableItems}: ViewableItemsChanged) => { - if (!viewableItems.length) { - return; - } - - const viewableItemsMap = viewableItems.reduce((acc: Record, {item, isViewable}) => { - if (isViewable) { - acc[item.id] = true; - } - return acc; - }, {}); - - DeviceEventEmitter.emit('scrolled', viewableItemsMap); - }, []); - - const renderItem = useCallback(({item, index}) => { - if (typeof item === 'string') { - if (isStartOfNewMessages(item)) { - // postIds includes a date item after the new message indicator so 2 - // needs to be added to the index for the length check to be correct. - const moreNewMessages = orderedPosts.length === index + 2; - - // The date line and new message line each count for a line. So the - // goal of this is to check for the 3rd previous, which for the start - // of a thread would be null as it doesn't exist. - const checkForPostId = index < orderedPosts.length - 3; - - return ( - - ); - } else if (isDateLine(item)) { - return ( - - ); - } - - if (isCombinedUserActivityPost(item)) { - const postProps = { - currentUsername, - postId: item, - style: Platform.OS === 'ios' ? style.scale : style.container, - testID: `${testID}.combined_user_activity`, - showJoinLeave: shouldShowJoinLeaveMessages, - theme, - }; - - return (); - } - } - - let previousPost: PostModel|undefined; - let nextPost: PostModel|undefined; - if (index < posts.length - 1) { - const prev = orderedPosts.slice(index + 1).find((v) => typeof v !== 'string'); - if (prev) { - previousPost = prev as PostModel; - } - } - - if (index > 0) { - const next = orderedPosts.slice(0, index); - for (let i = next.length - 1; i >= 0; i--) { - const v = next[i]; - if (typeof v !== 'string') { - nextPost = v; - break; - } - } - } - - const postProps = { - highlightPinnedOrFlagged: true, - location: 'Channel', - nextPost, - previousPost, - shouldRenderReplyButton: true, - }; - - return ( - - ); - }, [orderedPosts, theme]); - - return ( - - - - ); -}; - -export default PostList; diff --git a/app/components/progressive_image/index.tsx b/app/components/progressive_image/index.tsx index eb542793d8..adfbfb25cb 100644 --- a/app/components/progressive_image/index.tsx +++ b/app/components/progressive_image/index.tsx @@ -1,9 +1,10 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {ReactNode, useEffect, useRef, useState} from 'react'; -import {Animated, ImageBackground, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'; -import FastImage, {ImageStyle, ResizeMode, Source} from 'react-native-fast-image'; +import React, {ReactNode, useEffect, useState} from 'react'; +import {ImageBackground, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'; +import FastImage, {ImageStyle, ResizeMode} from 'react-native-fast-image'; +import Animated, {interpolate, useAnimatedStyle, useDerivedValue, useSharedValue, withTiming} from 'react-native-reanimated'; import {useTheme} from '@context/theme'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; @@ -11,20 +12,19 @@ import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import Thumbnail from './thumbnail'; const AnimatedImageBackground = Animated.createAnimatedComponent(ImageBackground); + +// @ts-expect-error FastImage does work with Animated.createAnimatedComponent const AnimatedFastImage = Animated.createAnimatedComponent(FastImage); -type ProgressiveImageProps = { +type Props = ProgressiveImageProps & { children?: ReactNode | ReactNode[]; - defaultSource?: Source; // this should be provided by the component + forwardRef?: React.RefObject; id: string; imageStyle?: StyleProp; - imageUri?: string; - inViewPort?: boolean; isBackgroundImage?: boolean; onError: () => void; resizeMode?: ResizeMode; style?: StyleProp; - thumbnailUri?: string; tintDefaultSource?: boolean; }; @@ -43,22 +43,34 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => { }); const ProgressiveImage = ({ - children, defaultSource, id, imageStyle, imageUri, inViewPort, isBackgroundImage, onError, resizeMode = 'contain', - style = {}, thumbnailUri, tintDefaultSource, -}: ProgressiveImageProps) => { - const intensity = useRef(new Animated.Value(0)).current; + children, defaultSource, forwardRef, id, imageStyle, imageUri, inViewPort, isBackgroundImage, + onError, resizeMode = 'contain', style = {}, thumbnailUri, tintDefaultSource, +}: Props) => { const [showHighResImage, setShowHighResImage] = useState(false); const theme = useTheme(); const styles = getStyleSheet(theme); + const intensity = useSharedValue(0); + + const defaultOpacity = useDerivedValue(() => ( + interpolate( + intensity.value, + [0, 100], + [0.5, 0], + ) + ), []); const onLoadImageEnd = () => { - Animated.timing(intensity, { - duration: 300, - toValue: 100, - useNativeDriver: true, - }).start(); + intensity.value = withTiming(100, {duration: 300}); }; + const animatedOpacity = useAnimatedStyle(() => ({ + opacity: interpolate( + intensity.value, + [200, 100], + [0.2, 1], + ), + })); + useEffect(() => { if (inViewPort) { setShowHighResImage(true); @@ -84,6 +96,7 @@ const ProgressiveImage = ({ return ( diff --git a/app/components/progressive_image/thumbnail.tsx b/app/components/progressive_image/thumbnail.tsx index 91789cf450..1161006d6e 100644 --- a/app/components/progressive_image/thumbnail.tsx +++ b/app/components/progressive_image/thumbnail.tsx @@ -2,14 +2,16 @@ // See LICENSE.txt for license information. import React from 'react'; -import {Animated, StyleProp, StyleSheet} from 'react-native'; +import {StyleProp, StyleSheet} from 'react-native'; import FastImage, {ImageStyle, Source} from 'react-native-fast-image'; +import Animated, {SharedValue} from 'react-native-reanimated'; +// @ts-expect-error FastImage does work with Animated.createAnimatedComponent const AnimatedFastImage = Animated.createAnimatedComponent(FastImage); type ThumbnailProps = { onError: () => void; - opacity?: number | Animated.AnimatedInterpolation | Animated.AnimatedValue; + opacity?: SharedValue; source?: Source; style: StyleProp; } @@ -34,7 +36,7 @@ const Thumbnail = ({onError, opacity, style, source}: ThumbnailProps) => { resizeMode='contain' onError={onError} source={require('@assets/images/thumb.png')} - style={[style, {opacity}]} + style={[style, {opacity: opacity?.value}]} testID='progressive_image.thumbnail' tintColor={tintColor} /> diff --git a/app/components/toast/index.tsx b/app/components/toast/index.tsx new file mode 100644 index 0000000000..31c0ce9a1f --- /dev/null +++ b/app/components/toast/index.tsx @@ -0,0 +1,82 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useMemo} from 'react'; +import {StyleProp, Text, useWindowDimensions, View, ViewStyle} from 'react-native'; +import Animated, {AnimatedStyleProp} from 'react-native-reanimated'; + +import {changeOpacity, makeStyleSheetFromTheme} from '@app/utils/theme'; +import CompassIcon from '@components/compass_icon'; +import {useTheme} from '@context/theme'; +import {typography} from '@utils/typography'; + +type ToastProps = { + animatedStyle: AnimatedStyleProp; + children?: React.ReactNode; + iconName?: string; + message?: string; + style: StyleProp; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + center: { + alignItems: 'center', + width: '100%', + opacity: 0, + }, + container: { + alignItems: 'center', + backgroundColor: theme.onlineIndicator, + borderRadius: 8, + elevation: 6, + flex: 1, + flexDirection: 'row', + height: 56, + paddingLeft: 20, + paddingRight: 10, + shadowColor: changeOpacity('#000', 0.12), + shadowOffset: {width: 0, height: 4}, + shadowRadius: 6, + }, + flex: {flex: 1}, + text: { + color: theme.buttonColor, + marginLeft: 10, + ...typography('Body', 100, 'SemiBold'), + }, +})); + +const Toast = ({animatedStyle, children, style, iconName, message}: ToastProps) => { + const theme = useTheme(); + const styles = getStyleSheet(theme); + const dim = useWindowDimensions(); + const containerStyle = useMemo(() => { + const totalMargin = 40; + const width = Math.min(dim.height, dim.width, 400) - totalMargin; + + return [styles.container, {width}, style]; + }, [dim, styles.container, style]); + + return ( + + + {Boolean(iconName) && + + } + {Boolean(message) && + + {message} + + } + {children} + + + ); +}; + +export default Toast; + diff --git a/app/constants/events.ts b/app/constants/events.ts index f9eccd0f8e..3ae3b42be5 100644 --- a/app/constants/events.ts +++ b/app/constants/events.ts @@ -8,6 +8,8 @@ export default keyMirror({ CHANNEL_DELETED: null, CLOSE_BOTTOM_SHEET: null, CONFIG_CHANGED: null, + FREEZE_SCREEN: null, + GALLERY_ACTIONS: null, LEAVE_CHANNEL: null, LEAVE_TEAM: null, LOADING_CHANNEL_POSTS: null, diff --git a/app/constants/gallery.ts b/app/constants/gallery.ts new file mode 100644 index 0000000000..2029798154 --- /dev/null +++ b/app/constants/gallery.ts @@ -0,0 +1,5 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export const GALLERY_FOOTER_HEIGHT = 75; +export const VIDEO_INSET = 100; diff --git a/app/constants/screens.ts b/app/constants/screens.ts index d1b235161e..419e27d85c 100644 --- a/app/constants/screens.ts +++ b/app/constants/screens.ts @@ -16,6 +16,7 @@ export const CUSTOM_STATUS = 'CustomStatus'; export const EDIT_PROFILE = 'EditProfile'; export const EDIT_SERVER = 'EditServer'; export const FORGOT_PASSWORD = 'ForgotPassword'; +export const GALLERY = 'Gallery'; export const HOME = 'Home'; export const INTEGRATION_SELECTOR = 'IntegrationSelector'; export const IN_APP_NOTIFICATION = 'InAppNotification'; @@ -47,6 +48,7 @@ export default { EDIT_PROFILE, EDIT_SERVER, FORGOT_PASSWORD, + GALLERY, HOME, INTEGRATION_SELECTOR, IN_APP_NOTIFICATION, diff --git a/app/context/gallery/index.tsx b/app/context/gallery/index.tsx new file mode 100644 index 0000000000..31759bc0a0 --- /dev/null +++ b/app/context/gallery/index.tsx @@ -0,0 +1,164 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useEffect, useLayoutEffect} from 'react'; +import Animated, {makeMutable, runOnUI} from 'react-native-reanimated'; + +export interface GalleryManagerItem { + index: number; + ref: React.RefObject; +} + +export interface GalleryManagerItems { + [index: number]: GalleryManagerItem; +} + +interface GalleryInitProps { + children: JSX.Element; + galleryIdentifier: string; +} + +class Gallery { + private init = false; + private timeout: NodeJS.Timeout | null = null; + + public refsByIndexSV: Animated.SharedValue = makeMutable({}); + + public sharedValues: GalleryManagerSharedValues = { + width: makeMutable(0), + height: makeMutable(0), + x: makeMutable(0), + y: makeMutable(0), + opacity: makeMutable(1), + activeIndex: makeMutable(0), + targetWidth: makeMutable(0), + targetHeight: makeMutable(0), + }; + + public items = new Map(); + + public get isInitialized() { + return this.init; + } + + public resolveItem(index: number) { + return this.items.get(index); + } + + public initialize() { + this.init = true; + } + + public reset() { + this.init = false; + this.items.clear(); + this.refsByIndexSV.value = {}; + } + + public resetSharedValues() { + const { + width, + height, + opacity, + activeIndex, + x, + y, + } = this.sharedValues; + + runOnUI(() => { + 'worklet'; + + width.value = 0; + height.value = 0; + opacity.value = 1; + activeIndex.value = -1; + x.value = 0; + y.value = 0; + })(); + } + + public registerItem(index: number, ref: React.RefObject) { + if (this.items.has(index)) { + return; + } + + this.addItem(index, ref); + } + + private addItem(index: number, ref: GalleryManagerItem['ref']) { + this.items.set(index, { + index, + ref, + }); + + if (this.timeout !== null) { + clearTimeout(this.timeout); + } + + this.timeout = setTimeout(() => { + this.refsByIndexSV.value = this.convertMapToObject(this.items); + + this.timeout = null; + }, 16); + } + + private convertMapToObject>(map: T) { + const obj: Record = {}; + for (const [key, value] of map) { + obj[key] = value; + } + return obj; + } +} + +class GalleryManager { + private galleries: Record = {}; + + public get(identifier: string): Gallery { + if (this.galleries[identifier]) { + return this.galleries[identifier]; + } + + const gallery = new Gallery(); + this.galleries[identifier] = gallery; + return gallery; + } + + public remove(identifier: string): boolean { + return delete this.galleries[identifier]; + } +} + +const galleryManager = new GalleryManager(); + +export function useGallery(galleryIdentifier: string) { + const gallery = galleryManager.get(galleryIdentifier); + + if (!gallery) { + throw new Error( + 'Cannot retrieve gallery manager from the context. Did you forget to wrap the app with GalleryProvider?', + ); + } + + return gallery; +} + +export function GalleryInit({children, galleryIdentifier}: GalleryInitProps) { + const gallery = useGallery(galleryIdentifier); + + useLayoutEffect(() => { + gallery.initialize(); + + return () => { + gallery.reset(); + }; + }, []); + + useEffect(() => { + return () => { + galleryManager.remove(galleryIdentifier); + }; + }, []); + + return children; +} diff --git a/app/database/models/server/file.ts b/app/database/models/server/file.ts index 5050e457c0..7d51584714 100644 --- a/app/database/models/server/file.ts +++ b/app/database/models/server/file.ts @@ -54,4 +54,19 @@ export default class FileModel extends Model { /** post : The related Post record for this file */ @immutableRelation(POST, 'post_id') post!: Relation; + + toFileInfo = (authorId: string): FileInfo => ({ + id: this.id, + user_id: authorId, + post_id: this.postId, + name: this.name, + extension: this.extension, + mini_preview: this.imageThumbnail, + size: this.size, + mime_type: this.mimeType, + height: this.height, + has_preview_image: Boolean(this.imageThumbnail), + localPath: this.localPath, + width: this.width, + }); } diff --git a/app/database/operator/server_data_operator/transformers/channel.ts b/app/database/operator/server_data_operator/transformers/channel.ts index c1303d2de8..0c95c49a0b 100644 --- a/app/database/operator/server_data_operator/transformers/channel.ts +++ b/app/database/operator/server_data_operator/transformers/channel.ts @@ -36,7 +36,7 @@ export const transformChannelRecord = ({action, database, value}: TransformerArg channel.createAt = raw.create_at; channel.creatorId = raw.creator_id; channel.deleteAt = raw.delete_at; - channel.displayName = raw.display_name; + channel.displayName = raw.display_name || record?.displayName || ''; channel.isGroupConstrained = Boolean(raw.group_constrained); channel.name = raw.name; channel.shared = Boolean(raw.shared); diff --git a/app/database/operator/server_data_operator/transformers/post.ts b/app/database/operator/server_data_operator/transformers/post.ts index 39971e6e68..c9aa731605 100644 --- a/app/database/operator/server_data_operator/transformers/post.ts +++ b/app/database/operator/server_data_operator/transformers/post.ts @@ -109,9 +109,9 @@ export const transformFileRecord = ({action, database, value}: TransformerArgs): file.extension = raw.extension; file.size = raw.size; file.mimeType = raw?.mime_type ?? ''; - file.width = raw?.width ?? 0; - file.height = raw?.height ?? 0; - file.imageThumbnail = raw?.mini_preview ?? ''; + file.width = raw?.width || record?.width || 0; + file.height = raw?.height || record?.height || 0; + file.imageThumbnail = raw?.mini_preview || record?.imageThumbnail || ''; file.localPath = raw?.localPath ?? ''; }; diff --git a/app/hooks/freeze.ts b/app/hooks/freeze.ts new file mode 100644 index 0000000000..41e5105581 --- /dev/null +++ b/app/hooks/freeze.ts @@ -0,0 +1,28 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useEffect, useState} from 'react'; +import {DeviceEventEmitter, Platform} from 'react-native'; + +import {Events} from '@constants'; + +const useFreeze = () => { + const [freeze, setFreeze] = useState(false); + const [backgroundColor, setBackgroundColor] = useState('#000'); + + useEffect(() => { + const freezeListener = DeviceEventEmitter.addListener(Events.FREEZE_SCREEN, (shouldFreeze: boolean, color = '#000') => { + // kept until this https://github.com/software-mansion/react-freeze/issues/7 is fixed + if (Platform.OS === 'ios') { + setFreeze(shouldFreeze); + setBackgroundColor(color); + } + }); + + return () => freezeListener.remove(); + }); + + return {freeze, backgroundColor}; +}; + +export default useFreeze; diff --git a/app/hooks/gallery.ts b/app/hooks/gallery.ts new file mode 100644 index 0000000000..2b59ef2edd --- /dev/null +++ b/app/hooks/gallery.ts @@ -0,0 +1,257 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useCallback, useEffect, useRef} from 'react'; +import {Platform} from 'react-native'; +import { + Easing, makeRemote, runOnJS, useAnimatedRef, useAnimatedStyle, useEvent, + useSharedValue, + withTiming, WithTimingConfig, +} from 'react-native-reanimated'; + +import {useGallery} from '@context/gallery'; +import {measureItem} from '@utils/gallery'; + +import type {GestureHandlerGestureEvent} from 'react-native-gesture-handler'; + +function useRemoteContext(initialValue: T) { + const initRef = useRef<{ context: T } | null>(null); + if (initRef.current === null) { + initRef.current = { + context: makeRemote(initialValue ?? {}), + }; + } + const {context} = initRef.current; + + return context; +} + +function diff(context: any, name: string, value: any) { + 'worklet'; + + if (!context.___diffs) { + context.___diffs = {}; + } + + if (!context.___diffs[name]) { + context.___diffs[name] = { + stash: 0, + prev: null, + }; + } + + const d = context.___diffs[name]; + + d.stash = d.prev === null ? 0 : value - d.prev; + d.prev = value; + + return d.stash; +} + +export function useCreateAnimatedGestureHandler(handlers: GestureHandlers) { + const context = useRemoteContext({ + __initialized: false, + }); + const isAndroid = Platform.OS === 'android'; + + const handler = (event: T['nativeEvent']) => { + 'worklet'; + + const FAILED = 1; + const BEGAN = 2; + const CANCELLED = 3; + const ACTIVE = 4; + const END = 5; + + if (handlers.onInit && !context.__initialized) { + context.__initialized = true; + handlers.onInit(event, context); + } + + if (handlers.onGesture) { + handlers.onGesture(event, context); + } + + const stateDiff = diff(context, 'pinchState', event.state); + + const pinchBeganAndroid = stateDiff === ACTIVE - BEGAN ? event.state === ACTIVE : false; + + const isBegan = isAndroid ? pinchBeganAndroid : (event.state === BEGAN || event.oldState === BEGAN) && + (event.velocityX !== 0 || event.velocityY !== 0); + + if (isBegan) { + if (handlers.shouldHandleEvent) { + context._shouldSkip = !handlers.shouldHandleEvent(event, context); + } else { + context._shouldSkip = false; + } + } else if (typeof context._shouldSkip === 'undefined') { + return; + } + + if (!context._shouldSkip && !context._shouldCancel) { + if (handlers.onEvent) { + handlers.onEvent(event, context); + } + + if (handlers.shouldCancel) { + context._shouldCancel = handlers.shouldCancel(event, context); + + if (context._shouldCancel) { + if (handlers.onEnd) { + handlers.onEnd(event, context, true); + } + return; + } + } + + if (handlers.beforeEach) { + handlers.beforeEach(event, context); + } + + if (isBegan && handlers.onStart) { + handlers.onStart(event, context); + } + + if (event.state === ACTIVE && handlers.onActive) { + handlers.onActive(event, context); + } + if (event.oldState === ACTIVE && event.state === END && handlers.onEnd) { + handlers.onEnd(event, context, false); + } + if (event.oldState === ACTIVE && event.state === FAILED && handlers.onFail) { + handlers.onFail(event, context); + } + if (event.oldState === ACTIVE && event.state === CANCELLED && handlers.onCancel) { + handlers.onCancel(event, context); + } + if (event.oldState === ACTIVE) { + if (handlers.onFinish) { + handlers.onFinish( + event, + context, + event.state === CANCELLED || event.state === FAILED, + ); + } + } + + if (handlers.afterEach) { + handlers.afterEach(event, context); + } + } + + // clean up context + if (event.oldState === ACTIVE) { + context._shouldSkip = undefined; + context._shouldCancel = undefined; + } + }; + + return handler; +} + +export function useAnimatedGestureHandler( + handlers: GestureHandlers, +): OnGestureEvent { + const handler = useCallback( + useCreateAnimatedGestureHandler(handlers), + [], + ); + + return useEvent<(event: T['nativeEvent']) => void, OnGestureEvent>( + handler, ['onGestureHandlerStateChange', 'onGestureHandlerEvent'], false, + ); +} + +export function useGalleryControls() { + const controlsHidden = useSharedValue(false); + + const translateYConfig: WithTimingConfig = { + duration: 400, + easing: Easing.bezier(0.33, 0.01, 0, 1), + }; + + const headerStyles = useAnimatedStyle(() => ({ + opacity: controlsHidden.value ? withTiming(0) : withTiming(1), + transform: [ + { + translateY: controlsHidden.value ? withTiming(-100, translateYConfig) : withTiming(0, translateYConfig), + }, + ], + position: 'absolute', + top: 0, + width: '100%', + zIndex: 1, + })); + + const footerStyles = useAnimatedStyle(() => ({ + opacity: controlsHidden.value ? withTiming(0) : withTiming(1), + transform: [ + { + translateY: controlsHidden.value ? withTiming(100, translateYConfig) : withTiming(0, translateYConfig), + }, + ], + position: 'absolute', + bottom: 0, + width: '100%', + zIndex: 1, + })); + + const setControlsHidden = useCallback((hidden: boolean) => { + 'worklet'; + + if (controlsHidden.value === hidden) { + return; + } + + controlsHidden.value = hidden; + }, []); + + return { + controlsHidden, + headerStyles, + footerStyles, + setControlsHidden, + }; +} + +export function useGalleryItem( + identifier: string, + index: number, + onPress: (identifier: string, itemIndex: number) => void, +) { + const gallery = useGallery(identifier); + const ref = useAnimatedRef(); + const {opacity, activeIndex} = gallery!.sharedValues; + + const styles = useAnimatedStyle(() => { + return { + opacity: activeIndex.value === index ? opacity.value : 1, + }; + }, []); + + useEffect(() => { + gallery.registerItem(index, ref); + }, []); + + const onGestureEvent = useAnimatedGestureHandler({ + onFinish: (_evt, _ctx, isCanceledOrFailed) => { + if (isCanceledOrFailed) { + return; + } + + activeIndex.value = index; + + // measure the images + // width/height and position to animate from it to the full screen one + measureItem(ref, gallery.sharedValues); + runOnJS(onPress)(identifier, index); + }, + }); + + return { + ref, + styles, + onGestureEvent, + }; +} diff --git a/app/screens/channel/channel.tsx b/app/screens/channel/channel.tsx index 9f23908fc3..ffb2c3f234 100644 --- a/app/screens/channel/channel.tsx +++ b/app/screens/channel/channel.tsx @@ -7,6 +7,7 @@ import {DeviceEventEmitter, Keyboard, Platform, View} from 'react-native'; import {Edge, SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context'; import CompassIcon from '@components/compass_icon'; +import FreezeScreen from '@components/freeze_screen'; import NavigationHeader from '@components/navigation_header'; import PostDraft from '@components/post_draft'; import {Navigation} from '@constants'; @@ -106,7 +107,7 @@ const Channel = ({channelId, componentId, displayName, isOwnDirectMessage, membe const channelIsSet = Boolean(channelId); return ( - <> + } - + ); }; diff --git a/app/screens/gallery/document_renderer/document_renderer.tsx b/app/screens/gallery/document_renderer/document_renderer.tsx new file mode 100644 index 0000000000..fb8118c26a --- /dev/null +++ b/app/screens/gallery/document_renderer/document_renderer.tsx @@ -0,0 +1,125 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useMemo, useState} from 'react'; +import {useIntl} from 'react-intl'; +import {DeviceEventEmitter, StyleSheet, Text, View} from 'react-native'; +import {RectButton, TouchableWithoutFeedback} from 'react-native-gesture-handler'; +import Animated from 'react-native-reanimated'; + +import FileIcon from '@components/post_list/post/body/files/file_icon'; +import {Events, Preferences} from '@constants'; +import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles'; +import {isDocument} from '@utils/file'; +import {galleryItemToFileInfo} from '@utils/gallery'; +import {changeOpacity} from '@utils/theme'; +import {typography} from '@utils/typography'; + +import DownloadWithAction from '../footer/download_with_action'; + +type Props = { + canDownloadFiles: boolean; + item: GalleryItemType; + onShouldHideControls: (hide: boolean) => void; +} + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + justifyContent: 'center', + flex: 1, + maxWidth: 600, + }, + filename: { + color: '#FFF', + ...typography('Body', 200, 'SemiBold'), + marginVertical: 8, + paddingHorizontal: 25, + textAlign: 'center', + }, + unsupported: { + color: '#FFF', + ...typography('Body', 100, 'SemiBold'), + marginTop: 10, + paddingHorizontal: 25, + opacity: 0.64, + textAlign: 'center', + }, +}); + +const DocumentRenderer = ({canDownloadFiles, item, onShouldHideControls}: Props) => { + const {formatMessage} = useIntl(); + const file = useMemo(() => galleryItemToFileInfo(item), [item]); + const [controls, setControls] = useState(true); + const [enabled, setEnabled] = useState(true); + const isSupported = useMemo(() => isDocument(file), [file]); + const optionText = isSupported ? formatMessage({ + id: 'gallery.open_file', + defaultMessage: 'Open file', + }) : formatMessage({ + id: 'gallery.unsupported', + defaultMessage: "Preview isn't supported for this file type. Try downloading or sharing to open it in another app.", + }); + + const handleHideControls = useCallback(() => { + onShouldHideControls(controls); + setControls(!controls); + }, [controls, onShouldHideControls]); + + const setGalleryAction = useCallback((action: GalleryAction) => { + DeviceEventEmitter.emit(Events.GALLERY_ACTIONS, action); + if (action === 'none') { + setEnabled(true); + } + }, []); + + const handleOpenFile = useCallback(() => { + setEnabled(false); + }, []); + + return ( + <> + + + + + {item.name} + + {!isSupported && + {optionText} + } + {isSupported && canDownloadFiles && + + + + {optionText} + + + + } + + + {!enabled && + + } + + ); +}; + +export default DocumentRenderer; diff --git a/app/screens/gallery/document_renderer/index.ts b/app/screens/gallery/document_renderer/index.ts new file mode 100644 index 0000000000..b41bcf2485 --- /dev/null +++ b/app/screens/gallery/document_renderer/index.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; +import withObservables from '@nozbe/with-observables'; +import {combineLatest, of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; + +import DocumentRenderer from './document_renderer'; + +import type {WithDatabaseArgs} from '@typings/database/database'; +import type SystemModel from '@typings/database/models/servers/system'; + +const {CONFIG, LICENSE} = SYSTEM_IDENTIFIERS; +const {SERVER: {SYSTEM}} = MM_TABLES; + +const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { + const enableMobileFileDownload = database.get(SYSTEM).findAndObserve(CONFIG).pipe( + switchMap(({value}) => of$(value.EnableMobileFileDownload !== 'false')), + ); + const complianceDisabled = database.get(SYSTEM).findAndObserve(LICENSE).pipe( + switchMap(({value}) => of$(value.IsLicensed === 'false' || value.Compliance === 'false')), + ); + const canDownloadFiles = combineLatest([enableMobileFileDownload, complianceDisabled]).pipe( + switchMap(([download, compliance]) => of$(compliance || download)), + ); + + return { + canDownloadFiles, + }; +}); + +export default withDatabase(enhanced(DocumentRenderer)); diff --git a/app/screens/gallery/footer/actions/action.tsx b/app/screens/gallery/footer/actions/action.tsx new file mode 100644 index 0000000000..28caddecb8 --- /dev/null +++ b/app/screens/gallery/footer/actions/action.tsx @@ -0,0 +1,54 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import {Platform, Pressable, PressableAndroidRippleConfig, PressableStateCallbackType, StyleProp, ViewStyle} from 'react-native'; + +import CompassIcon from '@components/compass_icon'; +import {changeOpacity} from '@utils/theme'; + +type Props = { + disabled: boolean; + iconName: string; + onPress: () => void; + style?: StyleProp; +} + +const pressedStyle = ({pressed}: PressableStateCallbackType) => { + let opacity = 1; + if (Platform.OS === 'ios' && pressed) { + opacity = 0.5; + } + + return [{opacity}]; +}; + +const androidRippleConfig: PressableAndroidRippleConfig = {borderless: true, radius: 24, color: '#FFF'}; + +const Action = ({disabled, iconName, onPress, style}: Props) => { + const pressableStyle = useCallback((pressed: PressableStateCallbackType) => ([ + pressedStyle(pressed), + style, + ]), [style]); + + return ( + + + + ); +}; + +export default Action; diff --git a/app/screens/gallery/footer/actions/index.tsx b/app/screens/gallery/footer/actions/index.tsx new file mode 100644 index 0000000000..b03daa88ed --- /dev/null +++ b/app/screens/gallery/footer/actions/index.tsx @@ -0,0 +1,64 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useManagedConfig} from '@mattermost/react-native-emm'; +import React from 'react'; +import {StyleSheet, View} from 'react-native'; + +import Action from './action'; + +type Props = { + canDownloadFiles: boolean; + disabled: boolean; + enablePublicLinks: boolean; + fileId: string; + onCopyPublicLink: () => void; + onDownload: () => void; + onShare: () => void; +} + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + }, + action: { + marginLeft: 24, + }, +}); + +const Actions = ({ + canDownloadFiles, disabled, enablePublicLinks, fileId, + onCopyPublicLink, onDownload, onShare, +}: Props) => { + const managedConfig = useManagedConfig(); + const canCopyPublicLink = !fileId.startsWith('uid') && enablePublicLinks && managedConfig.copyPasteProtection !== 'true'; + + return ( + + {canCopyPublicLink && + } + {canDownloadFiles && + <> + + + + } + + ); +}; + +export default Actions; diff --git a/app/screens/gallery/footer/avatar/index.tsx b/app/screens/gallery/footer/avatar/index.tsx new file mode 100644 index 0000000000..4e5ea30ef7 --- /dev/null +++ b/app/screens/gallery/footer/avatar/index.tsx @@ -0,0 +1,78 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useMemo} from 'react'; +import {StyleSheet, View} from 'react-native'; +import FastImage from 'react-native-fast-image'; + +import {buildAbsoluteUrl} from '@actions/remote/file'; +import {buildProfileImageUrl} from '@actions/remote/user'; +import CompassIcon from '@components/compass_icon'; +import {useServerUrl} from '@context/server'; +import {changeOpacity} from '@utils/theme'; + +type Props = { + authorId?: string; + overrideIconUrl?: string; +} + +const styles = StyleSheet.create({ + avatarContainer: { + backgroundColor: 'rgba(255, 255, 255, 0.4)', + padding: 2, + width: 34, + height: 34, + }, + avatar: { + height: 32, + width: 32, + }, + avatarRadius: { + borderRadius: 18, + }, +}); + +const Avatar = ({authorId, overrideIconUrl}: Props) => { + const serverUrl = useServerUrl(); + const avatarUri = useMemo(() => { + try { + if (overrideIconUrl) { + return buildAbsoluteUrl(serverUrl, overrideIconUrl); + } else if (authorId) { + const pictureUrl = buildProfileImageUrl(serverUrl, authorId); + return `${serverUrl}${pictureUrl}`; + } + + return undefined; + } catch { + return undefined; + } + }, [serverUrl, authorId, overrideIconUrl]); + + let picture; + if (avatarUri) { + picture = ( + + ); + } else { + picture = ( + + ); + } + + return ( + + {picture} + + ); +}; + +export default Avatar; + diff --git a/app/screens/gallery/footer/copy_public_link/index.tsx b/app/screens/gallery/footer/copy_public_link/index.tsx new file mode 100644 index 0000000000..855e60596a --- /dev/null +++ b/app/screens/gallery/footer/copy_public_link/index.tsx @@ -0,0 +1,91 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import Clipboard from '@react-native-community/clipboard'; +import React, {useEffect, useRef, useState} from 'react'; +import {useIntl} from 'react-intl'; +import {StyleSheet} from 'react-native'; +import {useAnimatedStyle, withTiming} from 'react-native-reanimated'; + +import {fetchPublicLink} from '@actions/remote/file'; +import Toast from '@components/toast'; +import {GALLERY_FOOTER_HEIGHT} from '@constants/gallery'; +import {useServerUrl} from '@context/server'; + +type Props = { + item: GalleryItemType; + setAction: (action: GalleryAction) => void; +} + +const styles = StyleSheet.create({ + error: { + backgroundColor: '#D24B4E', + }, + toast: { + backgroundColor: '#3DB887', // intended hardcoded color + }, +}); + +const CopyPublicLink = ({item, setAction}: Props) => { + const {formatMessage} = useIntl(); + const serverUrl = useServerUrl(); + const [showToast, setShowToast] = useState(); + const [error, setError] = useState(''); + const mounted = useRef(false); + + const animatedStyle = useAnimatedStyle(() => ({ + position: 'absolute', + bottom: GALLERY_FOOTER_HEIGHT + 8, + opacity: withTiming(showToast ? 1 : 0, {duration: 300}), + })); + + const copyLink = async () => { + try { + const publicLink = await fetchPublicLink(serverUrl, item.id!); + if ('link' in publicLink) { + Clipboard.setString(publicLink.link); + } else { + setError(formatMessage({id: 'gallery.copy_link.failed', defaultMessage: 'Failed to copy link to clipboard'})); + } + } catch { + setError(formatMessage({id: 'gallery.copy_link.failed', defaultMessage: 'Failed to copy link to clipboard'})); + } finally { + setShowToast(true); + setTimeout(() => { + if (mounted.current) { + setShowToast(false); + } + }, 3000); + } + }; + + useEffect(() => { + mounted.current = true; + copyLink(); + + return () => { + mounted.current = false; + }; + }, []); + + useEffect(() => { + if (showToast === false) { + setTimeout(() => { + if (mounted.current) { + setAction('none'); + } + }, 350); + } + }, [showToast]); + + return ( + + ); +}; + +export default CopyPublicLink; diff --git a/app/screens/gallery/footer/details/index.tsx b/app/screens/gallery/footer/details/index.tsx new file mode 100644 index 0000000000..ae3afc00d6 --- /dev/null +++ b/app/screens/gallery/footer/details/index.tsx @@ -0,0 +1,79 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useMemo} from 'react'; +import {StyleSheet, Text, View} from 'react-native'; + +import FormattedText from '@components/formatted_text'; +import {typography} from '@utils/typography'; + +type Props = { + channelName: string; + isDirectChannel: boolean; + ownPost: boolean; + userDisplayName: string; +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + flexDirection: 'column', + marginHorizontal: 12, + }, + chanelText: { + color: '#FFFFFF', + ...typography('Body', 75), + marginTop: 3, + opacity: 0.56, + }, + userText: { + color: '#FFFFFF', + ...typography('Body', 200, 'SemiBold'), + }, +}); + +const Details = ({channelName, isDirectChannel, ownPost, userDisplayName}: Props) => { + const displayName = useMemo(() => ({displayName: userDisplayName}), [userDisplayName]); + const channelDisplayName = useMemo(() => + ({channelName: `${isDirectChannel ? '@' : '~'}${channelName}`}), + [channelName, isDirectChannel]); + + let userElement = ( + + {userDisplayName} + + ); + + if (ownPost) { + userElement = ( + + ); + } + + return ( + + {userElement} + + + ); +}; + +export default Details; diff --git a/app/screens/gallery/footer/download_with_action/index.tsx b/app/screens/gallery/footer/download_with_action/index.tsx new file mode 100644 index 0000000000..6a22177ad8 --- /dev/null +++ b/app/screens/gallery/footer/download_with_action/index.tsx @@ -0,0 +1,325 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import CameraRoll from '@react-native-community/cameraroll'; +import * as FileSystem from 'expo-file-system'; +import React, {useEffect, useRef, useState} from 'react'; +import {useIntl} from 'react-intl'; +import {NativeModules, Platform, StyleSheet, Text, View} from 'react-native'; +import DeviceInfo from 'react-native-device-info'; +import FileViewer from 'react-native-file-viewer'; +import {TouchableOpacity} from 'react-native-gesture-handler'; +import {useAnimatedStyle, withTiming} from 'react-native-reanimated'; +import Share from 'react-native-share'; + +import {downloadFile} from '@actions/remote/file'; +import {typography} from '@app/utils/typography'; +import CompassIcon from '@components/compass_icon'; +import ProgressBar from '@components/progress_bar'; +import Toast from '@components/toast'; +import {GALLERY_FOOTER_HEIGHT} from '@constants/gallery'; +import {useServerUrl} from '@context/server'; +import {alertFailedToOpenDocument} from '@utils/document'; +import {fileExists, getLocalFilePathFromFile, hasWriteStoragePermission} from '@utils/file'; +import {galleryItemToFileInfo} from '@utils/gallery'; + +import type {ClientResponse, ProgressPromise} from '@mattermost/react-native-network-client'; + +type Props = { + action: GalleryAction; + item: GalleryItemType; + setAction: (action: GalleryAction) => void; +} + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + flex: 1, + flexDirection: 'row', + }, + toast: { + backgroundColor: '#3F4350', // intended hardcoded color + }, + error: { + backgroundColor: '#D24B4E', + }, + fileSaved: { + backgroundColor: '#3DB887', + }, + option: { + alignItems: 'flex-end', + justifyContent: 'center', + flex: 1, + marginTop: 8, + }, + progress: { + marginTop: -10, + width: '85%', + }, + title: { + color: '#FFF', + ...typography('Body', 75, 'SemiBold'), + }, +}); + +const DownloadWithAction = ({action, item, setAction}: Props) => { + const intl = useIntl(); + const serverUrl = useServerUrl(); + const [showToast, setShowToast] = useState(); + const [error, setError] = useState(''); + const [saved, setSaved] = useState(false); + const [progress, setProgress] = useState(0); + const mounted = useRef(false); + const downloadPromise = useRef>(); + + let title; + let iconName; + let message; + let toastStyle = styles.toast; + + switch (action) { + case 'sharing': + title = intl.formatMessage({id: 'gallery.preparing', defaultMessage: 'Preparing...'}); + break; + case 'opening': + title = intl.formatMessage({id: 'gallery.opening', defaultMessage: 'Opening...'}); + break; + default: + title = intl.formatMessage({id: 'gallery.downloading', defaultMessage: 'Downloading...'}); + break; + } + + if (error) { + iconName = 'alert-circle-outline'; + message = error; + toastStyle = styles.error; + } else if (saved) { + iconName = 'check'; + toastStyle = styles.fileSaved; + + switch (item.type) { + case 'image': + message = intl.formatMessage({id: 'gallery.image_saved', defaultMessage: 'Image saved'}); + break; + case 'video': + message = intl.formatMessage({id: 'gallery.video_saved', defaultMessage: 'Video saved'}); + break; + } + } + + const animatedStyle = useAnimatedStyle(() => ({ + position: 'absolute', + bottom: GALLERY_FOOTER_HEIGHT + 8, + opacity: withTiming(showToast ? 1 : 0, {duration: 300}), + })); + + const cancel = async () => { + try { + await downloadPromise.current?.cancel?.(); + const path = getLocalFilePathFromFile(serverUrl, galleryItemToFileInfo(item)); + await FileSystem.deleteAsync(path, {idempotent: true}); + + downloadPromise.current = undefined; + } catch { + // do nothing + } finally { + if (mounted.current) { + setShowToast(false); + } + } + }; + + const openFile = async (response: ClientResponse) => { + if (mounted.current) { + if (response.data?.path) { + const path = response.data.path as string; + FileViewer.open(path, { + displayName: item.name, + showAppsSuggestions: true, + showOpenWithDialog: true, + }).catch(() => { + const file = galleryItemToFileInfo(item); + alertFailedToOpenDocument(file, intl); + }); + } + setShowToast(false); + } + }; + + const saveFile = async (path: string) => { + if (mounted.current) { + if (Platform.OS === 'android') { + try { + await NativeModules.MattermostManaged.saveFile(path.replace('file://', '/')); + } catch { + // do nothing in case the user decides not to save the file + } + setAction('none'); + return; + } + + Share.open({ + url: path, + saveToFiles: true, + }).catch(() => { + // do nothing + }); + + setAction('none'); + } + }; + + const saveImageOrVideo = async (path: string) => { + if (mounted.current) { + try { + const applicationName = DeviceInfo.getApplicationName(); + await CameraRoll.save(path, { + type: item.type === 'image' ? 'photo' : 'video', + album: applicationName, + }); + setSaved(true); + } catch { + setError(intl.formatMessage({id: 'gallery.save_failed', defaultMessage: 'Unable to save the file'})); + } + } + }; + + const save = async (response: ClientResponse) => { + if (response.data?.path) { + const path = response.data.path as string; + const hasPermission = await hasWriteStoragePermission(intl); + + if (hasPermission) { + switch (item.type) { + case 'file': + saveFile(path); + break; + default: + saveImageOrVideo(path); + break; + } + } + } + }; + + const shareFile = async (response: ClientResponse) => { + if (mounted.current) { + if (response.data?.path) { + const path = response.data.path as string; + Share.open({ + message: '', + title: '', + url: path, + showAppsToView: true, + }).catch(() => { + // do nothing + }); + } + setShowToast(false); + } + }; + + const startDownload = async () => { + try { + const path = getLocalFilePathFromFile(serverUrl, galleryItemToFileInfo(item)); + if (path) { + const exists = await fileExists(path); + let actionToExecute: (request: ClientResponse) => Promise; + switch (action) { + case 'sharing': + actionToExecute = shareFile; + break; + case 'opening': + actionToExecute = openFile; + break; + default: + actionToExecute = save; + break; + } + if (exists) { + setProgress(100); + actionToExecute({ + code: 200, + ok: true, + data: {path}, + }); + } else { + downloadPromise.current = downloadFile(serverUrl, item.id!, path); + downloadPromise.current?.then(actionToExecute).catch(() => { + setError(intl.formatMessage({id: 'download.error', defaultMessage: 'Unable to download the file. Try again later'})); + }); + downloadPromise.current?.progress?.(setProgress); + } + } + } catch (e) { + setShowToast(false); + } + }; + + useEffect(() => { + mounted.current = true; + setShowToast(true); + startDownload(); + + return () => { + mounted.current = false; + }; + }, []); + + useEffect(() => { + let t: NodeJS.Timeout; + if (error || saved) { + t = setTimeout(() => { + setShowToast(false); + }, 3500); + } + + return () => clearTimeout(t); + }, [error, saved]); + + useEffect(() => { + let t: NodeJS.Timeout; + if (showToast === false) { + t = setTimeout(() => { + if (mounted.current) { + setAction('none'); + } + }, 350); + } + + return () => clearTimeout(t); + }, [showToast]); + + return ( + + {!error && !saved && + + + {title} + + + + + + + + + } + + ); +}; + +export default DownloadWithAction; diff --git a/app/screens/gallery/footer/footer.tsx b/app/screens/gallery/footer/footer.tsx new file mode 100644 index 0000000000..0b1e1912f6 --- /dev/null +++ b/app/screens/gallery/footer/footer.tsx @@ -0,0 +1,154 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useEffect, useState} from 'react'; +import {DeviceEventEmitter, StyleSheet, View, ViewStyle} from 'react-native'; +import Animated, {AnimatedStyleProp} from 'react-native-reanimated'; +import {SafeAreaView, Edge} from 'react-native-safe-area-context'; + +import {Events} from '@constants'; +import {GALLERY_FOOTER_HEIGHT} from '@constants/gallery'; +import {changeOpacity} from '@utils/theme'; +import {typography} from '@utils/typography'; +import {displayUsername} from '@utils/user'; + +import Actions from './actions'; +import Avatar from './avatar'; +import CopyPublicLink from './copy_public_link'; +import Details from './details'; +import DownloadWithAction from './download_with_action'; + +import type PostModel from '@typings/database/models/servers/post'; +import type UserModel from '@typings/database/models/servers/user'; + +type Props = { + author?: UserModel; + canDownloadFiles: boolean; + channelName: string; + currentUserId: string; + enablePostIconOverride: boolean; + enablePostUsernameOverride: boolean; + enablePublicLink: boolean; + hideActions: boolean; + isDirectChannel: boolean; + item: GalleryItemType; + post?: PostModel; + style: AnimatedStyleProp; + teammateNameDisplay: string; +} + +const AnimatedSafeAreaView = Animated.createAnimatedComponent(SafeAreaView); +const edges: Edge[] = ['left', 'right']; +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + backgroundColor: changeOpacity('#000', 0.6), + borderTopColor: changeOpacity('#fff', 0.4), + borderTopWidth: 1, + flex: 1, + flexDirection: 'row', + justifyContent: 'center', + height: GALLERY_FOOTER_HEIGHT, + paddingHorizontal: 20, + }, + details: {flex: 3, flexDirection: 'row'}, + icon: { + alignItems: 'center', + justifyContent: 'center', + height: '100%', + }, + title: { + ...typography('Heading', 300), + color: 'white', + }, +}); + +const Footer = ({ + author, canDownloadFiles, channelName, currentUserId, + enablePostIconOverride, enablePostUsernameOverride, enablePublicLink, + hideActions, isDirectChannel, item, post, style, teammateNameDisplay, +}: Props) => { + const showActions = !hideActions && Boolean(item.id) && !item.id?.startsWith('uid'); + const [action, setAction] = useState('none'); + + let overrideIconUrl; + if (enablePostIconOverride && post?.props?.use_user_icon !== 'true' && post?.props?.override_icon_url) { + overrideIconUrl = post.props.override_icon_url; + } + + let userDisplayName; + if (enablePostUsernameOverride && post?.props?.override_username) { + userDisplayName = post?.props.override_username as string; + } else { + userDisplayName = displayUsername(author, undefined, teammateNameDisplay); + } + + const handleCopyLink = useCallback(() => { + setAction('copying'); + }, []); + + const handleDownload = useCallback(async () => { + setAction('downloading'); + }, []); + + const handleShare = useCallback(() => { + setAction('sharing'); + }, []); + + useEffect(() => { + const listener = DeviceEventEmitter.addListener(Events.GALLERY_ACTIONS, (value: GalleryAction) => { + setAction(value); + }); + + return () => listener.remove(); + }, []); + + return ( + + {['downloading', 'sharing'].includes(action) && + + } + {action === 'copying' && + + } + + + +
+ + {showActions && + + } + + + ); +}; + +export default Footer; diff --git a/app/screens/gallery/footer/index.ts b/app/screens/gallery/footer/index.ts new file mode 100644 index 0000000000..c2f36c9f46 --- /dev/null +++ b/app/screens/gallery/footer/index.ts @@ -0,0 +1,94 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Q} from '@nozbe/watermelondb'; +import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; +import withObservables from '@nozbe/with-observables'; +import {combineLatest, of as of$, Observable} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import {General, Preferences} from '@constants'; +import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; +import {getTeammateNameDisplaySetting} from '@helpers/api/preference'; + +import Footer from './footer'; + +import type {WithDatabaseArgs} from '@typings/database/database'; +import type ChannelModel from '@typings/database/models/servers/channel'; +import type PostModel from '@typings/database/models/servers/post'; +import type PreferenceModel from '@typings/database/models/servers/preference'; +import type SystemModel from '@typings/database/models/servers/system'; +import type UserModel from '@typings/database/models/servers/user'; + +type FooterProps = WithDatabaseArgs & { + item: GalleryItemType; +} + +const {CONFIG, CURRENT_CHANNEL_ID, CURRENT_USER_ID, LICENSE} = SYSTEM_IDENTIFIERS; +const {SERVER: {CHANNEL, POST, PREFERENCE, SYSTEM, USER}} = MM_TABLES; + +const enhanced = withObservables(['item'], ({database, item}: FooterProps) => { + const post: Observable = + item.postId ? database.get(POST).findAndObserve(item.postId) : of$(undefined); + + const currentChannelId = database.get(SYSTEM).findAndObserve(CURRENT_CHANNEL_ID).pipe( + switchMap(({value}) => of$(value)), + ); + + const currentUserId = database.get(SYSTEM).findAndObserve(CURRENT_USER_ID).pipe( + switchMap(({value}) => of$(value)), + ); + + const config = database.get(SYSTEM).findAndObserve(CONFIG).pipe( + switchMap(({value}) => of$(value as ClientConfig)), + ); + const license = database.get(SYSTEM).findAndObserve(LICENSE).pipe( + switchMap(({value}) => of$(value as ClientLicense)), + ); + const preferences = database.get(PREFERENCE).query(Q.where('category', Preferences.CATEGORY_DISPLAY_SETTINGS)).observe(); + const teammateNameDisplay = combineLatest([preferences, config, license]).pipe( + switchMap(([prefs, cfg, lcs]) => of$(getTeammateNameDisplaySetting(prefs, cfg, lcs))), + ); + + const author = post.pipe( + switchMap((p) => { + const id = p?.userId || item.authorId; + if (id) { + return database.get(USER).findAndObserve(id); + } + + return of$(undefined); + }), + ); + + const channel = combineLatest([currentChannelId, post]).pipe( + switchMap(([cId, p]) => { + return p?.channel.observe() || database.get(CHANNEL).findAndObserve(cId); + }), + ); + const enablePostUsernameOverride = config.pipe(switchMap((c) => of$(c.EnablePostUsernameOverride === 'true'))); + const enablePostIconOverride = config.pipe(switchMap((c) => of$(c.EnablePostIconOverride === 'true'))); + const enablePublicLink = config.pipe(switchMap((c) => of$(c.EnablePublicLink === 'true'))); + const enableMobileFileDownload = config.pipe(switchMap((c) => of$(c.EnableMobileFileDownload !== 'false'))); + const complianceDisabled = license.pipe(switchMap((l) => of$(l.IsLicensed === 'false' || l.Compliance === 'false'))); + const canDownloadFiles = combineLatest([enableMobileFileDownload, complianceDisabled]).pipe( + switchMap(([download, compliance]) => of$(compliance || download)), + ); + const channelName = channel.pipe(switchMap((c: ChannelModel) => of$(c.displayName))); + const isDirectChannel = channel.pipe(switchMap((c: ChannelModel) => of$(c.type === General.DM_CHANNEL))); + + return { + author, + canDownloadFiles, + channelName, + currentUserId, + enablePostIconOverride, + enablePostUsernameOverride, + enablePublicLink, + isDirectChannel, + post, + teammateNameDisplay, + }; +}); + +export default withDatabase(enhanced(Footer)); diff --git a/app/screens/gallery/gallery.tsx b/app/screens/gallery/gallery.tsx new file mode 100644 index 0000000000..1a09ef4027 --- /dev/null +++ b/app/screens/gallery/gallery.tsx @@ -0,0 +1,213 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react'; +import {BackHandler, StyleProp} from 'react-native'; +import FastImage, {ImageStyle} from 'react-native-fast-image'; +import Animated, {runOnJS, runOnUI, useAnimatedReaction} from 'react-native-reanimated'; + +import {useGallery} from '@context/gallery'; +import {freezeOtherScreens, measureItem} from '@utils/gallery'; + +import DocumentRenderer from './document_renderer'; +import {ImageRendererProps} from './image_renderer'; +import LightboxSwipeout, {LightboxSwipeoutRef, RenderItemInfo} from './lightbox_swipeout'; +import Backdrop, {BackdropProps} from './lightbox_swipeout/backdrop'; +import VideoRenderer from './video_renderer'; +import GalleryViewer from './viewer'; + +// @ts-expect-error FastImage does work with Animated.createAnimatedComponent +const AnimatedImage = Animated.createAnimatedComponent(FastImage); + +interface GalleryProps { + galleryIdentifier: string; + initialIndex: number; + items: GalleryItemType[]; + onIndexChange?: (index: number) => void; + onHide: () => void; + targetDimensions: { width: number; height: number }; + onShouldHideControls: (hide: boolean) => void; +} + +export interface GalleryRef { + close: () => void; +} + +const Gallery = forwardRef(({ + galleryIdentifier, + initialIndex, + items, + onHide, + targetDimensions, + onShouldHideControls, + onIndexChange, +}: GalleryProps, ref) => { + const {refsByIndexSV, sharedValues} = useGallery(galleryIdentifier); + const [localIndex, setLocalIndex] = useState(initialIndex); + const lightboxRef = useRef(null); + const item = items[localIndex]; + + const close = () => { + lightboxRef.current?.closeLightbox(); + }; + + const onLocalIndex = (index: number) => { + setLocalIndex(index); + onIndexChange?.(index); + }; + + useEffect(() => { + runOnUI(() => { + 'worklet'; + + const tw = targetDimensions.width; + sharedValues.targetWidth.value = tw; + const scaleFactor = item.width / targetDimensions.width; + const th = item.height / scaleFactor; + sharedValues.targetHeight.value = th; + })(); + }, [item, targetDimensions.width]); + + useEffect(() => { + const listener = BackHandler.addEventListener('hardwareBackPress', () => { + lightboxRef.current?.closeLightbox(); + return true; + }); + + return () => listener.remove(); + }, []); + + useImperativeHandle(ref, () => ({ + close, + })); + + useAnimatedReaction( + () => { + return sharedValues.activeIndex.value; + }, + (index) => { + const galleryItems = refsByIndexSV.value; + + if (index > -1 && galleryItems[index]) { + measureItem(galleryItems[index].ref, sharedValues); + } + }, + ); + + const onIndexChangeWorklet = useCallback((nextIndex: number) => { + 'worklet'; + + runOnJS(onLocalIndex)(nextIndex); + sharedValues.activeIndex.value = nextIndex; + }, []); + + const renderBackdropComponent = useCallback( + ({animatedStyles, translateY}: BackdropProps) => { + return ( + + ); + }, + [], + ); + + function onSwipeActive(translateY: number) { + 'worklet'; + + if (Math.abs(translateY) > 8) { + onShouldHideControls(true); + } + } + + function onSwipeFailure() { + 'worklet'; + + runOnJS(freezeOtherScreens)(true); + onShouldHideControls(false); + } + + function hideLightboxItem() { + 'worklet'; + + sharedValues.width.value = 0; + sharedValues.height.value = 0; + sharedValues.opacity.value = 1; + sharedValues.activeIndex.value = -1; + sharedValues.x.value = 0; + sharedValues.y.value = 0; + + runOnJS(onHide)(); + } + + const onRenderItem = useCallback((info: RenderItemInfo) => { + if (item.type === 'video' && item.posterUri) { + return ( + >>} + /> + ); + } + + return null; + }, [item]); + + const onRenderPage = useCallback((props: ImageRendererProps, idx: number) => { + switch (props.item.type) { + case 'video': + return ( + + ); + case 'file': + return ( + + ); + default: + return null; + } + }, []); + + return ( + + {({onGesture, shouldHandleEvent}) => ( + + )} + + ); +}); + +Gallery.displayName = 'Gallery'; + +export default Gallery; diff --git a/app/screens/gallery/header/index.tsx b/app/screens/gallery/header/index.tsx new file mode 100644 index 0000000000..a273cfaee6 --- /dev/null +++ b/app/screens/gallery/header/index.tsx @@ -0,0 +1,82 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useMemo} from 'react'; +import {StyleSheet, useWindowDimensions, View, ViewStyle} from 'react-native'; +import {TouchableOpacity} from 'react-native-gesture-handler'; +import Animated, {AnimatedStyleProp} from 'react-native-reanimated'; +import {SafeAreaView, Edge} from 'react-native-safe-area-context'; + +import CompassIcon from '@components/compass_icon'; +import FormattedText from '@components/formatted_text'; +import {useDefaultHeaderHeight} from '@hooks/header'; +import {changeOpacity} from '@utils/theme'; +import {typography} from '@utils/typography'; + +type Props = { + index: number; + onClose: () => void; + style: AnimatedStyleProp; + total: number; +} + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + backgroundColor: changeOpacity('#000', 0.6), + borderBottomColor: changeOpacity('#fff', 0.4), + borderBottomWidth: 1, + flexDirection: 'row', + }, + icon: { + alignItems: 'center', + justifyContent: 'center', + height: '100%', + }, + title: { + ...typography('Heading', 300), + color: 'white', + }, +}); + +const edges: Edge[] = ['left', 'right', 'top']; +const AnimatedSafeAreaView = Animated.createAnimatedComponent(SafeAreaView); + +const Header = ({index, onClose, style, total}: Props) => { + const {width} = useWindowDimensions(); + const height = useDefaultHeaderHeight(); + const containerStyle = useMemo(() => [styles.container, {height}], [height]); + const iconStyle = useMemo(() => [{width: height}, styles.icon], [height]); + const titleStyle = useMemo(() => ({width: width - (height * 2)}), [height, width]); + const titleValue = useMemo(() => ({index: index + 1, total}), [index, total]); + + return ( + + + + + + + + + + + ); +}; + +export default Header; diff --git a/app/screens/gallery/image_renderer/index.tsx b/app/screens/gallery/image_renderer/index.tsx new file mode 100644 index 0000000000..3b65b97955 --- /dev/null +++ b/app/screens/gallery/image_renderer/index.tsx @@ -0,0 +1,55 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useMemo} from 'react'; + +import {PagerProps} from '../pager'; +import {RenderPageProps} from '../pager/page'; + +import ImageTransformer, {ImageTransformerProps} from './transformer'; + +export interface Handlers { + onTap?: ImageTransformerProps['onTap']; + onDoubleTap?: ImageTransformerProps['onDoubleTap']; + onInteraction?: ImageTransformerProps['onInteraction']; + onPagerTranslateChange?: (translateX: number) => void; + onGesture?: PagerProps['onGesture']; + onPagerEnabledGesture?: PagerProps['onEnabledGesture']; + shouldPagerHandleGestureEvent?: PagerProps['shouldHandleGestureEvent']; + onShouldHideControls?: (shouldHide: boolean) => void; + } + +export type ImageRendererProps = RenderPageProps & Handlers + +function ImageRenderer({ + height, + isPageActive, + isPagerInProgress, + item, + onDoubleTap, + onInteraction, + onPageStateChange, + onTap, + pagerRefs, + width, +}: ImageRendererProps) { + const targetDimensions = useMemo(() => ({height, width}), [height, width]); + + return ( + + ); +} + +export default ImageRenderer; diff --git a/app/screens/gallery/image_renderer/transformer.tsx b/app/screens/gallery/image_renderer/transformer.tsx new file mode 100644 index 0000000000..0fb2d14857 --- /dev/null +++ b/app/screens/gallery/image_renderer/transformer.tsx @@ -0,0 +1,583 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useEffect, useRef, useState} from 'react'; +import {StyleSheet} from 'react-native'; +import FastImage, {Source} from 'react-native-fast-image'; +import { + PanGestureHandler, PanGestureHandlerGestureEvent, PinchGestureHandler, PinchGestureHandlerGestureEvent, + State, TapGestureHandler, TapGestureHandlerGestureEvent, +} from 'react-native-gesture-handler'; +import Animated, { + cancelAnimation, Easing, useAnimatedReaction, useAnimatedStyle, useDerivedValue, + useSharedValue, + withDecay, withSpring, WithSpringConfig, withTiming, WithTimingConfig, +} from 'react-native-reanimated'; + +import {useAnimatedGestureHandler} from '@hooks/gallery'; +import {clamp, workletNoop} from '@utils/gallery'; +import * as vec from '@utils/gallery/vectors'; +import {calculateDimensions} from '@utils/images'; + +const styles = StyleSheet.create({ + container: { + flex: 1, + width: '100%', + }, + wrapper: { + ...StyleSheet.absoluteFillObject, + justifyContent: 'center', + alignItems: 'center', + }, +}); + +const springConfig: WithSpringConfig = { + stiffness: 1000, + damping: 500, + mass: 3, + overshootClamping: true, + restDisplacementThreshold: 0.01, + restSpeedThreshold: 0.01, +}; + +const timingConfig: WithTimingConfig = { + duration: 250, + easing: Easing.bezier(0.33, 0.01, 0, 1), +}; + +export interface RenderImageProps { + width: number; + height: number; + source: Source; + onLoad: () => void; +} + +export type InteractionType = 'scale' | 'pan'; + +export interface ImageTransformerReusableProps { + onDoubleTap?: (isScaled: boolean) => void; + onInteraction?: (type: InteractionType) => void; + onTap?: (isScaled: boolean) => void; +} + +export interface ImageTransformerProps extends ImageTransformerReusableProps { + enabled?: boolean; + height: number; + isActive?: Animated.SharedValue; + onStateChange?: (isActive: boolean) => void; + outerGestureHandlerActive?: Animated.SharedValue; + outerGestureHandlerRefs?: Array>; + source: Source | string; + targetDimensions: { width: number; height: number }; + width: number; +} + +const DOUBLE_TAP_SCALE = 3; +const MAX_SCALE = 3; +const MIN_SCALE = 0.7; +const OVER_SCALE = 0.5; + +function checkIsNotUsed(handlerState: Animated.SharedValue) { + 'worklet'; + + return ( + handlerState.value !== State.UNDETERMINED && + handlerState.value !== State.END + ); +} + +const ImageTransformer = ( + { + enabled = true, height, isActive, + onDoubleTap = workletNoop, onInteraction = workletNoop, onStateChange = workletNoop, onTap = workletNoop, + outerGestureHandlerActive, outerGestureHandlerRefs = [], source, + targetDimensions, width, + }: ImageTransformerProps) => { + const imageSource = typeof source === 'string' ? {uri: source} : source; + const [pinchEnabled, setPinchEnabledState] = useState(true); + const interactionsEnabled = useSharedValue(false); + + const setInteractionsEnabled = useCallback((value: boolean) => { + interactionsEnabled.value = value; + }, []); + + const onLoadImageSuccess = useCallback(() => { + setInteractionsEnabled(true); + }, []); + + const disablePinch = useCallback(() => { + setPinchEnabledState(false); + }, []); + + useEffect(() => { + if (!pinchEnabled) { + setPinchEnabledState(true); + } + }, [pinchEnabled]); + + const pinchRef = useRef(null); + const panRef = useRef(null); + const tapRef = useRef(null); + const doubleTapRef = useRef(null); + + const panState = useSharedValue(State.UNDETERMINED); + const pinchState = useSharedValue(State.UNDETERMINED); + + const scale = useSharedValue(1); + const scaleOffset = useSharedValue(1); + const translation = vec.useSharedVector(0, 0); + const panVelocity = vec.useSharedVector(0, 0); + const scaleTranslation = vec.useSharedVector(0, 0); + const offset = vec.useSharedVector(0, 0); + + const canvas = vec.create(targetDimensions.width, targetDimensions.height); + + const {width: targetWidth, height: targetHeight} = calculateDimensions( + height, + width, + targetDimensions.width, + targetDimensions.height, + ); + + const image = vec.create(targetWidth, targetHeight); + + const canPanVertically = useDerivedValue(() => { + return targetDimensions.height < targetHeight * scale.value; + }, []); + + function resetSharedState(animated?: boolean) { + 'worklet'; + + if (animated) { + scale.value = withTiming(1, timingConfig); + scaleOffset.value = 1; + + vec.set(offset, () => withTiming(0, timingConfig)); + } else { + scale.value = 1; + scaleOffset.value = 1; + vec.set(translation, 0); + vec.set(scaleTranslation, 0); + vec.set(offset, 0); + } + } + + const maybeRunOnEnd = () => { + 'worklet'; + + const target = vec.create(0, 0); + + const fixedScale = clamp(MIN_SCALE, scale.value, MAX_SCALE); + const scaledImage = image.y * fixedScale; + const rightBoundary = (canvas.x / 2) * (fixedScale - 1); + + let topBoundary = 0; + + if (canvas.y < scaledImage) { + topBoundary = Math.abs(scaledImage - canvas.y) / 2; + } + + const maxVector = vec.create(rightBoundary, topBoundary); + const minVector = vec.invert(maxVector); + + if (!canPanVertically.value) { + offset.y.value = withSpring(target.y, springConfig); + } + + // we should handle this only if pan or pinch handlers has been used already + if ( + (checkIsNotUsed(panState) || checkIsNotUsed(pinchState)) && + pinchState.value !== State.CANCELLED + ) { + return; + } + + if ( + vec.eq(offset, 0) && + vec.eq(translation, 0) && + vec.eq(scaleTranslation, 0) && + scale.value === 1 + ) { + // we don't need to run any animations + return; + } + + if (scale.value <= 1) { + // just center it + vec.set(offset, () => withTiming(0, timingConfig)); + return; + } + + vec.set(target, vec.clamp(offset, minVector, maxVector)); + + const deceleration = 0.9915; + + const isInBoundaryX = target.x === offset.x.value; + const isInBoundaryY = target.y === offset.y.value; + + if (isInBoundaryX) { + if ( + Math.abs(panVelocity.x.value) > 0 && + scale.value <= MAX_SCALE + ) { + offset.x.value = withDecay({ + velocity: panVelocity.x.value, + clamp: [minVector.x, maxVector.x], + deceleration, + }); + } + } else { + offset.x.value = withSpring(target.x, springConfig); + } + + if (isInBoundaryY) { + if ( + Math.abs(panVelocity.y.value) > 0 && + scale.value <= MAX_SCALE && + offset.y.value !== minVector.y && + offset.y.value !== maxVector.y + ) { + offset.y.value = withDecay({ + velocity: panVelocity.y.value, + clamp: [minVector.y, maxVector.y], + deceleration, + }); + } + } else { + offset.y.value = withSpring(target.y, springConfig); + } + }; + + const onPanEvent = useAnimatedGestureHandler; pan: vec.Vector}>({ + onInit: (_, ctx) => { + ctx.panOffset = vec.create(0, 0); + }, + + shouldHandleEvent: () => { + return ( + scale.value > 1 && + (typeof outerGestureHandlerActive === 'undefined' ? true : !outerGestureHandlerActive.value) && + interactionsEnabled.value + ); + }, + + beforeEach: (evt, ctx) => { + ctx.pan = vec.create(evt.translationX, evt.translationY); + const velocity = vec.create(evt.velocityX, evt.velocityY); + + vec.set(panVelocity, velocity); + }, + + onStart: (_, ctx) => { + cancelAnimation(offset.x); + cancelAnimation(offset.y); + ctx.panOffset = vec.create(0, 0); + onInteraction('pan'); + }, + + onActive: (evt, ctx) => { + panState.value = evt.state; + + if (scale.value > 1) { + if (evt.numberOfPointers > 1) { + // store pan offset during the pan with two fingers (during the pinch) + vec.set(ctx.panOffset, ctx.pan); + } else { + // subtract the offset and assign fixed pan + const nextTranslate = vec.add(ctx.pan, vec.invert(ctx.panOffset)); + translation.x.value = nextTranslate.x; + + if (canPanVertically.value) { + translation.y.value = nextTranslate.y; + } + } + } + }, + + onEnd: (evt, ctx) => { + panState.value = evt.state; + + vec.set(ctx.panOffset, 0); + vec.set(offset, vec.add(offset, translation)); + vec.set(translation, 0); + + maybeRunOnEnd(); + + vec.set(panVelocity, 0); + }, + }); + + useAnimatedReaction( + () => { + if (typeof isActive === 'undefined') { + return true; + } + + return isActive.value; + }, + (currentActive) => { + if (!currentActive) { + resetSharedState(); + } + }, + ); + + const onScaleEvent = useAnimatedGestureHandler< + PinchGestureHandlerGestureEvent, + { + origin: vec.Vector; + adjustFocal: vec.Vector; + gestureScale: number; + nextScale: number; + } + >({ + onInit: (_, ctx) => { + ctx.origin = vec.create(0, 0); + ctx.gestureScale = 1; + ctx.adjustFocal = vec.create(0, 0); + }, + + shouldHandleEvent: (evt) => { + return ( + evt.numberOfPointers === 2 && + (typeof outerGestureHandlerActive === 'undefined' ? true : !outerGestureHandlerActive.value) && + interactionsEnabled.value + ); + }, + + beforeEach: (evt, ctx) => { + // calculate the overall scale value + // also limits this.event.scale + ctx.nextScale = clamp(evt.scale * scaleOffset.value, MIN_SCALE, MAX_SCALE + OVER_SCALE); + + if (ctx.nextScale > MIN_SCALE && ctx.nextScale < MAX_SCALE + OVER_SCALE) { + ctx.gestureScale = evt.scale; + } + + // this is just to be able to use with vectors + const focal = vec.create(evt.focalX, evt.focalY); + const CENTER = vec.divide(canvas, 2); + + // since it works even when you release one finger + if (evt.numberOfPointers === 2) { + // focal with translate offset + // it alow us to scale into different point even then we pan the image + ctx.adjustFocal = vec.sub(focal, vec.add(CENTER, offset)); + } else if (evt.state === State.ACTIVE && evt.numberOfPointers !== 2) { + disablePinch(); + } + }, + + afterEach: (evt, ctx) => { + if (evt.state === State.END || evt.state === State.CANCELLED) { + return; + } + + scale.value = ctx.nextScale; + }, + + onStart: (_, ctx) => { + onInteraction('scale'); + cancelAnimation(offset.x); + cancelAnimation(offset.y); + vec.set(ctx.origin, ctx.adjustFocal); + }, + + onActive: (evt, ctx) => { + pinchState.value = evt.state; + + const pinch = vec.sub(ctx.adjustFocal, ctx.origin); + + const nextTranslation = vec.add(pinch, ctx.origin, vec.multiply(-1, ctx.gestureScale, ctx.origin)); + + vec.set(scaleTranslation, nextTranslation); + }, + + onFinish: (evt, ctx) => { + // reset gestureScale value + ctx.gestureScale = 1; + pinchState.value = evt.state; + + // store scale value + scaleOffset.value = scale.value; + + vec.set(offset, vec.add(offset, scaleTranslation)); + vec.set(scaleTranslation, 0); + + if (scaleOffset.value < 1) { + // make sure we don't add stuff below the 1 + scaleOffset.value = 1; + + // this runs the timing animation + scale.value = withTiming(1, timingConfig); + } else if (scaleOffset.value > MAX_SCALE) { + scaleOffset.value = MAX_SCALE; + scale.value = withTiming(MAX_SCALE, timingConfig); + } + + maybeRunOnEnd(); + }, + }); + + const onTapEvent = useAnimatedGestureHandler({ + shouldHandleEvent: (evt) => { + return ( + evt.numberOfPointers === 1 && + (typeof outerGestureHandlerActive === 'undefined' ? true : !outerGestureHandlerActive.value) && + interactionsEnabled.value && scale.value === 1 + ); + }, + + onStart: () => { + cancelAnimation(offset.x); + cancelAnimation(offset.y); + }, + + onActive: () => { + onTap(scale.value > 1); + }, + + onEnd: () => { + maybeRunOnEnd(); + }, + }); + + function handleScaleTo(x: number, y: number) { + 'worklet'; + + scale.value = withTiming(DOUBLE_TAP_SCALE, timingConfig); + scaleOffset.value = DOUBLE_TAP_SCALE; + + const targetImageSize = vec.multiply(image, DOUBLE_TAP_SCALE); + + const CENTER = vec.divide(canvas, 2); + const imageCenter = vec.divide(image, 2); + + const focal = vec.create(x, y); + + const origin = vec.multiply( + -1, + vec.sub(vec.divide(targetImageSize, 2), CENTER), + ); + + const koef = vec.sub( + vec.multiply(vec.divide(1, imageCenter), focal), + 1, + ); + + const target = vec.multiply(origin, koef); + + if (targetImageSize.y < canvas.y) { + target.y = 0; + } + + offset.x.value = withTiming(target.x, timingConfig); + offset.y.value = withTiming(target.y, timingConfig); + } + + const onDoubleTapEvent = useAnimatedGestureHandler({ + shouldHandleEvent: (evt) => { + return ( + evt.numberOfPointers === 1 && + (typeof outerGestureHandlerActive === 'undefined' ? true : !outerGestureHandlerActive.value) && + interactionsEnabled.value + ); + }, + + onActive: ({x, y}) => { + onDoubleTap(scale.value > 1); + + if (scale.value > 1) { + resetSharedState(true); + } else { + handleScaleTo(x, y); + } + }, + }); + + const animatedStyles = useAnimatedStyle(() => { + const noOffset = offset.x.value === 0 && offset.y.value === 0; + const noTranslation = translation.x.value === 0 && translation.y.value === 0; + const noScaleTranslation = scaleTranslation.x.value === 0 && scaleTranslation.y.value === 0; + const isInactive = scale.value === 1 && noOffset && noTranslation && noScaleTranslation; + + onStateChange(isInactive); + + return { + transform: [ + { + translateX: + scaleTranslation.x.value + + translation.x.value + + offset.x.value, + }, + { + translateY: + scaleTranslation.y.value + + translation.y.value + + offset.y.value, + }, + {scale: scale.value}, + ], + }; + }, []); + + return ( + + + + + + + + + + + + + + + + + + + + ); +}; + +export default React.memo(ImageTransformer); + diff --git a/app/screens/gallery/index.tsx b/app/screens/gallery/index.tsx new file mode 100644 index 0000000000..7aeb56dffa --- /dev/null +++ b/app/screens/gallery/index.tsx @@ -0,0 +1,102 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useMemo, useRef, useState} from 'react'; +import {NativeModules, useWindowDimensions, Platform} from 'react-native'; +import {Navigation} from 'react-native-navigation'; + +import {Screens} from '@constants'; +import {useTheme} from '@context/theme'; +import {useIsTablet} from '@hooks/device'; +import {useGalleryControls} from '@hooks/gallery'; +import {dismissModal} from '@screens/navigation'; +import {freezeOtherScreens} from '@utils/gallery'; + +import Footer from './footer'; +import Gallery, {GalleryRef} from './gallery'; +import Header from './header'; + +type Props = { + galleryIdentifier: string; + hideActions: boolean; + initialIndex: number; + items: GalleryItemType[]; +} + +const GalleryScreen = ({galleryIdentifier, hideActions, initialIndex, items}: Props) => { + const dim = useWindowDimensions(); + const isTablet = useIsTablet(); + const theme = useTheme(); + const [localIndex, setLocalIndex] = useState(initialIndex); + const {setControlsHidden, headerStyles, footerStyles} = useGalleryControls(); + const dimensions = useMemo(() => ({width: dim.width, height: dim.height}), [dim.width]); + const galleryRef = useRef(null); + + const onClose = useCallback(() => { + // We keep the un freeze here as we want + // the screen to be visible when the gallery + // starts to dismiss as the hanlder for shouldHandleEvent + // of the lightbox is not called + freezeOtherScreens(false); + requestAnimationFrame(() => { + galleryRef.current?.close(); + }); + }, []); + + const close = useCallback(() => { + if (Platform.OS === 'ios' && !isTablet) { + // We need both the navigation & the module + Navigation.setDefaultOptions({ + layout: { + orientation: ['portrait'], + }, + }); + NativeModules.MattermostManaged.lockPortrait(); + } + freezeOtherScreens(false); + requestAnimationFrame(async () => { + dismissModal({ + componentId: Screens.GALLERY, + layout: { + orientation: isTablet ? undefined : ['portrait'], + }, + statusBar: { + visible: true, + backgroundColor: theme.sidebarBg, + }, + }); + }); + }, [isTablet]); + + const onIndexChange = useCallback((index: number) => { + setLocalIndex(index); + }, []); + + return ( + <> +
+ +