diff --git a/app/components/files/image_file.tsx b/app/components/files/image_file.tsx index 0831f2eeab..0610a479fb 100644 --- a/app/components/files/image_file.tsx +++ b/app/components/files/image_file.tsx @@ -21,7 +21,7 @@ import type {ResizeMode} from 'react-native-fast-image'; type ImageFileProps = { backgroundColor?: string; file: FileInfo; - forwardRef: React.RefObject; + forwardRef?: React.RefObject; inViewPort?: boolean; isSingleImage?: boolean; resizeMode?: ResizeMode; diff --git a/app/components/files/video_file.tsx b/app/components/files/video_file.tsx index 166116e3ac..7b7ef0f863 100644 --- a/app/components/files/video_file.tsx +++ b/app/components/files/video_file.tsx @@ -22,12 +22,12 @@ import type {ResizeMode} from 'react-native-fast-image'; type Props = { index: number; file: FileInfo; - forwardRef: React.RefObject; + forwardRef?: React.RefObject; inViewPort?: boolean; isSingleImage?: boolean; resizeMode?: ResizeMode; wrapperWidth: number; - updateFileForGallery: (idx: number, file: FileInfo) => void; + updateFileForGallery?: (idx: number, file: FileInfo) => void; } const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ @@ -118,7 +118,7 @@ const VideoFile = ({ ); data.height = th; data.width = tw; - updateFileForGallery(index, data); + updateFileForGallery?.(index, data); } }; diff --git a/app/constants/files.ts b/app/constants/files.ts index 632d3f2f17..84762ecf51 100644 --- a/app/constants/files.ts +++ b/app/constants/files.ts @@ -28,7 +28,7 @@ export const VALID_IMAGE_MIME_TYPES = [ 'application/x-win-bitmap', ] as const; -const Files: Record = { +export const Files: Record = { AUDIO_TYPES: ['mp3', 'wav', 'wma', 'm4a', 'flac', 'aac', 'ogg'], CODE_TYPES: ['as', 'applescript', 'osascript', 'scpt', 'bash', 'sh', 'zsh', 'clj', 'boot', 'cl2', 'cljc', 'cljs', 'cljs.hl', 'cljscm', 'cljx', 'hic', 'coffee', '_coffee', 'cake', 'cjsx', 'cson', 'iced', 'cpp', 'c', 'cc', 'h', 'c++', 'h++', 'hpp', 'cs', 'csharp', 'css', 'd', 'di', 'dart', 'delphi', 'dpr', 'dfm', 'pas', 'pascal', 'freepascal', 'lazarus', 'lpr', 'lfm', 'diff', 'django', 'jinja', 'dockerfile', 'docker', 'erl', 'f90', 'f95', 'fsharp', 'fs', 'gcode', 'nc', 'go', 'groovy', 'handlebars', 'hbs', 'html.hbs', 'html.handlebars', 'hs', 'hx', 'java', 'jsp', 'js', 'jsx', 'json', 'jl', 'kt', 'ktm', 'kts', 'less', 'lisp', 'lua', 'mk', 'mak', 'md', 'mkdown', 'mkd', 'matlab', 'm', 'mm', 'objc', 'obj-c', 'ml', 'perl', 'pl', 'php', 'php3', 'php4', 'php5', 'php6', 'ps', 'ps1', 'pp', 'py', 'gyp', 'r', 'ruby', 'rb', 'gemspec', 'podspec', 'thor', 'irb', 'rs', 'scala', 'scm', 'sld', 'scss', 'st', 'sql', 'swift', 'ts', 'tex', 'vbnet', 'vb', 'bas', 'vbs', 'v', 'veo', 'xml', 'html', 'xhtml', 'rss', 'atom', 'xsl', 'plist', 'yaml'], IMAGE_TYPES: ['jpg', 'gif', 'bmp', 'png', 'jpeg', 'tiff', 'tif', 'svg', 'psd', 'xcf'], diff --git a/app/hooks/files.ts b/app/hooks/files.ts index 52ef662305..6daa81947a 100644 --- a/app/hooks/files.ts +++ b/app/hooks/files.ts @@ -22,12 +22,13 @@ export const useImageAttachments = (filesInfo: FileInfo[], publicLinkEnabled: bo } images.push({...file, uri}); } else { + let uri = file.uri; if (videoFile) { - // fallback if public links are not enabled - file.uri = buildFileUrl(serverUrl, file.id!); + // fallback if public links are not enabled + uri = buildFileUrl(serverUrl, file.id!); } - nonImages.push(file); + nonImages.push({...file, uri}); } return {images, nonImages}; }, {images: [], nonImages: []}); diff --git a/app/screens/home/search/results/file_options/file_options.tsx b/app/screens/home/search/results/file_options/file_options.tsx new file mode 100644 index 0000000000..b377a597f0 --- /dev/null +++ b/app/screens/home/search/results/file_options/file_options.tsx @@ -0,0 +1,90 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import React, {useCallback, useState} from 'react'; +import {useIntl} from 'react-intl'; +import {View, StyleSheet} from 'react-native'; + +import {showPermalink} from '@actions/remote/permalink'; +import OptionItem from '@components/option_item'; +import {useServerUrl} from '@context/server'; +import CopyPublicLink from '@screens/gallery/footer/copy_public_link'; +import DownloadWithAction from '@screens/gallery/footer/download_with_action'; + +import Header from './header'; + +const styles = StyleSheet.create({ + toast: { + marginTop: 100, + alignItems: 'center', + }, +}); + +type Props = { + canDownloadFiles: boolean; + enablePublicLink: boolean; + fileInfo: FileInfo; +} +const FileOptions = ({fileInfo, canDownloadFiles, enablePublicLink}: Props) => { + const intl = useIntl(); + const serverUrl = useServerUrl(); + const [action, setAction] = useState('none'); + + const galleryItem = {...fileInfo, type: 'image'} as GalleryItemType; + + const handleDownload = useCallback(() => { + setAction('downloading'); + }, []); + + const handleCopyLink = useCallback(() => { + setAction('copying'); + }, []); + + const handlePermalink = useCallback(() => { + showPermalink(serverUrl, '', fileInfo.post_id, intl); + }, [serverUrl, fileInfo.post_id, intl]); + + return ( + +
+ {canDownloadFiles && + + } + + {enablePublicLink && + + } + + {action === 'downloading' && + + } + {action === 'copying' && + + } + + + ); +}; + +export default FileOptions; diff --git a/app/screens/home/search/results/file_options/header.tsx b/app/screens/home/search/results/file_options/header.tsx new file mode 100644 index 0000000000..0e9a45c1ac --- /dev/null +++ b/app/screens/home/search/results/file_options/header.tsx @@ -0,0 +1,89 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import React from 'react'; +import {View, Text} from 'react-native'; + +import FormattedDate from '@components/formatted_date'; +import {useTheme} from '@context/theme'; +import {getFormattedFileSize} from '@utils/file'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +import Icon, {ICON_SIZE} from './icon'; + +const format = 'MMM DD YYYY HH:MM A'; + +const HEADER_MARGIN = 8; +const FILE_ICON_MARGIN = 8; +const INFO_MARGIN = 8; +export const HEADER_HEIGHT = HEADER_MARGIN + + ICON_SIZE + + FILE_ICON_MARGIN + + (28 * 2) + //400 line height times two lines + (INFO_MARGIN * 2) + + 24; // 200 line height + +const getStyleSheet = makeStyleSheetFromTheme((theme) => { + return { + headerContainer: { + marginBottom: HEADER_MARGIN, + }, + fileIconContainer: { + marginBottom: FILE_ICON_MARGIN, + alignSelf: 'flex-start', + }, + nameText: { + color: theme.centerChannelColor, + ...typography('Heading', 400, 'SemiBold'), + }, + infoContainer: { + marginVertical: INFO_MARGIN, + alignItems: 'center', + flexDirection: 'row', + }, + infoText: { + flexDirection: 'row', + color: changeOpacity(theme.centerChannelColor, 0.64), + ...typography('Body', 200, 'Regular'), + }, + date: { + color: changeOpacity(theme.centerChannelColor, 0.64), + ...typography('Body', 200, 'Regular'), + }, + }; +}); + +type Props = { + fileInfo: FileInfo; +} +const Header = ({fileInfo}: Props) => { + const theme = useTheme(); + const style = getStyleSheet(theme); + + const size = getFormattedFileSize(fileInfo.size); + + return ( + + + + + + {fileInfo.name} + + + {`${size} • `} + + + + ); +}; + +export default Header; diff --git a/app/screens/home/search/results/file_options/icon.tsx b/app/screens/home/search/results/file_options/icon.tsx new file mode 100644 index 0000000000..791b57a2a9 --- /dev/null +++ b/app/screens/home/search/results/file_options/icon.tsx @@ -0,0 +1,57 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import React from 'react'; +import {View, StyleSheet} from 'react-native'; + +import FileIcon from '@components/files/file_icon'; +import ImageFile from '@components/files/image_file'; +import VideoFile from '@components/files/video_file'; +import {isImage, isVideo} from '@utils/file'; + +export const ICON_SIZE = 72; + +const styles = StyleSheet.create({ + imageVideo: { + height: ICON_SIZE, + width: ICON_SIZE, + }, +}); + +type Props = { + fileInfo: FileInfo; +} +const Icon = ({fileInfo}: Props) => { + switch (true) { + case isImage(fileInfo): + return ( + + + + ); + case isVideo(fileInfo): + return ( + + + + ); + default: + return ( + + ); + } +}; + +export default Icon; diff --git a/app/screens/home/search/results/file_options/index.ts b/app/screens/home/search/results/file_options/index.ts new file mode 100644 index 0000000000..072bc86aff --- /dev/null +++ b/app/screens/home/search/results/file_options/index.ts @@ -0,0 +1,29 @@ +// 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 {observeConfigBooleanValue, observeLicense} from '@queries/servers/system'; + +import FileOptions from './file_options'; + +import type {WithDatabaseArgs} from '@typings/database/database'; + +const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { + const license = observeLicense(database); + const enablePublicLink = observeConfigBooleanValue(database, 'EnablePublicLink'); + const enableMobileFileDownload = observeConfigBooleanValue(database, 'EnableMobileFileDownload'); + + 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)), + ); + return { + canDownloadFiles, + enablePublicLink, + }; +}); + +export default withDatabase(enhanced(FileOptions)); diff --git a/app/screens/home/search/results/results.tsx b/app/screens/home/search/results/results.tsx index 28c73862ed..536e27e906 100644 --- a/app/screens/home/search/results/results.tsx +++ b/app/screens/home/search/results/results.tsx @@ -1,10 +1,12 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback, useMemo} from 'react'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {StyleSheet, FlatList, ListRenderItemInfo, StyleProp, View, ViewStyle} from 'react-native'; import Animated from 'react-native-reanimated'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; +import {ITEM_HEIGHT} from '@app/components/option_item'; import File from '@components/files/file'; import NoResultsWithTerm from '@components/no_results_with_term'; import DateSeparator from '@components/post_list/date_separator'; @@ -13,13 +15,19 @@ import {Screens} from '@constants'; import {useTheme} from '@context/theme'; import {useIsTablet} from '@hooks/device'; import {useImageAttachments} from '@hooks/files'; +import {bottomSheet, dismissBottomSheet} from '@screens/navigation'; +import NavigationStore from '@store/navigation_store'; import {isImage, isVideo} from '@utils/file'; import {fileToGalleryItem, openGalleryAtIndex} from '@utils/gallery'; +import {bottomSheetSnapPoint} from '@utils/helpers'; import {getViewPortWidth} from '@utils/images'; import {getDateForDateLine, isDateLine, selectOrderedPosts} from '@utils/post_list'; import {TabTypes, TabType} from '@utils/search'; import {preventDoubleTap} from '@utils/tap'; +import FileOptions from './file_options'; +import {HEADER_HEIGHT} from './file_options/header'; + import type ChannelModel from '@typings/database/models/servers/channel'; import type PostModel from '@typings/database/models/servers/post'; @@ -61,6 +69,9 @@ const SearchResults = ({ }: Props) => { const theme = useTheme(); const isTablet = useIsTablet(); + const insets = useSafeAreaInsets(); + const [lastViewedIndex, setLastViewedIndex] = useState(undefined); + const paddingTop = useMemo(() => ({paddingTop: scrollPaddingTop, flexGrow: 1}), [scrollPaddingTop]); const orderedPosts = useMemo(() => selectOrderedPosts(posts, 0, false, '', '', false, isTimezoneEnabled, currentTimezone, false).reverse(), [posts]); const {images: imageAttachments, nonImages: nonImageAttachments} = useImageAttachments(fileInfos, publicLinkEnabled); @@ -100,11 +111,49 @@ const SearchResults = ({ openGalleryAtIndex(galleryIdentifier, idx, items); }), [orderedFilesForGallery]); - const handleOptionsPress = useCallback(preventDoubleTap(() => { - // hook up in another PR - // https://github.com/mattermost/mattermost-mobile/pull/6420 - // https://mattermost.atlassian.net/browse/MM-44939 - }), []); + const snapPoints = useMemo(() => { + let numberOptions = 1; + if (canDownloadFiles) { + numberOptions += 1; + } + if (publicLinkEnabled) { + numberOptions += 1; + } + return [bottomSheetSnapPoint(numberOptions, ITEM_HEIGHT, insets.bottom) + HEADER_HEIGHT, 10]; + }, [canDownloadFiles, publicLinkEnabled]); + + const handleOptionsPress = useCallback((item: number) => { + setLastViewedIndex(item); + const renderContent = () => { + return ( + + ); + }; + bottomSheet({ + closeButtonId: 'close-search-file-options', + renderContent, + snapPoints, + theme, + title: '', + }); + }, [orderedFilesForGallery, snapPoints, theme]); + + // This effect handles the case where a user has the FileOptions Modal + // open and the server changes the ability to download files or copy public + // links. Reopen the Bottom Sheet again so the new options are added or + // removed. + useEffect(() => { + if (lastViewedIndex === undefined) { + return; + } + if (NavigationStore.getNavigationTopComponentId() === 'BottomSheet') { + dismissBottomSheet().then(() => { + handleOptionsPress(lastViewedIndex); + }); + } + }, [canDownloadFiles, publicLinkEnabled]); const renderItem = useCallback(({item}: ListRenderItemInfo) => { if (typeof item === 'string') {