diff --git a/app/actions/remote/search.ts b/app/actions/remote/search.ts index 1b8e694bad..c94faf4081 100644 --- a/app/actions/remote/search.ts +++ b/app/actions/remote/search.ts @@ -12,23 +12,8 @@ import {logError} from '@utils/log'; import {fetchPostAuthors, fetchMissingChannelsFromPosts} from './post'; import {forceLogoutIfNecessary} from './session'; -import type {Client} from '@client/rest'; import type Model from '@nozbe/watermelondb/Model'; -type FileSearchRequest = { - error?: unknown; - file_infos?: {[id: string]: FileInfo}; - next_file_info_id?: string; - order?: string[]; - prev_file_info_id?: string; -} - -type PostSearchRequest = { - error?: unknown; - order?: string[]; - posts?: Post[]; -} - export async function fetchRecentMentions(serverUrl: string): Promise { try { const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); @@ -130,27 +115,21 @@ export const searchPosts = async (serverUrl: string, params: PostSearchParams): } }; -export const searchFiles = async (serverUrl: string, teamId: string, params: FileSearchParams): Promise => { - const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; - - if (!operator) { - return {error: `${serverUrl} database not found`}; - } - - let client: Client; +export const searchFiles = async (serverUrl: string, teamId: string, params: FileSearchParams): Promise<{files?: FileInfo[]; channels?: string[]; error?: unknown}> => { try { - client = NetworkManager.getClient(serverUrl); - } catch (error) { - return {error}; - } - - let data; - try { - data = await client.searchFiles(teamId, params.terms); + const client = NetworkManager.getClient(serverUrl); + const result = await client.searchFiles(teamId, params.terms); + const files = result?.file_infos ? Object.values(result.file_infos) : []; + const allChannelIds = files.reduce((acc, f) => { + if (f.channel_id) { + acc.push(f.channel_id); + } + return acc; + }, []); + const channels = [...new Set(allChannelIds)]; + return {files, channels}; } catch (error) { forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); return {error}; } - - return data; }; diff --git a/app/client/rest/files.ts b/app/client/rest/files.ts index cd665a637c..c4a3f62726 100644 --- a/app/client/rest/files.ts +++ b/app/client/rest/files.ts @@ -16,8 +16,8 @@ export interface ClientFilesMix { onError: (response: ClientResponseError) => void, skipBytes?: number, ) => () => void; - searchFiles: (teamId: string, terms: string) => Promise; - searchFilesWithParams: (teamId: string, FileSearchParams: string) => Promise; + searchFiles: (teamId: string, terms: string) => Promise; + searchFilesWithParams: (teamId: string, FileSearchParams: string) => Promise; } const ClientFiles = (superclass: any) => class extends superclass { diff --git a/app/components/autocomplete_selector/index.tsx b/app/components/autocomplete_selector/index.tsx index 00a382ab5d..8a9c1f8b55 100644 --- a/app/components/autocomplete_selector/index.tsx +++ b/app/components/autocomplete_selector/index.tsx @@ -7,7 +7,7 @@ import React, {useCallback, useEffect, useState} from 'react'; import {IntlShape, useIntl} from 'react-intl'; import {Text, View} from 'react-native'; -import CompasIcon from '@components/compass_icon'; +import CompassIcon from '@components/compass_icon'; import Footer from '@components/settings/footer'; import Label from '@components/settings/label'; import TouchableWithFeedback from '@components/touchable_with_feedback'; @@ -210,7 +210,7 @@ function AutoCompleteSelector({ > {itemText || title} - void; + publicLinkEnabled: boolean; + channelName?: string; + onOptionsPress?: (index: number) => void; + theme: Theme; + wrapperWidth?: number; + showDate?: boolean; + updateFileForGallery: (idx: number, file: FileInfo) => void; + asCard?: boolean; +}; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + fileWrapper: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + marginTop: 10, + borderWidth: 1, + borderColor: changeOpacity(theme.centerChannelColor, 0.24), + borderRadius: 4, + }, + iconWrapper: { + marginTop: 8, + marginRight: 7, + marginBottom: 8, + marginLeft: 6, + }, + imageVideo: { + height: 40, + width: 40, + margin: 4, + }, + }; +}); + +const File = ({ + asCard = false, + canDownloadFiles, + channelName, + file, + galleryIdentifier, + inViewPort = false, + index, + isSingleImage = false, + nonVisibleImagesCount = 0, + onOptionsPress, + onPress, + publicLinkEnabled, + showDate = false, + theme, + updateFileForGallery, + wrapperWidth = 300, +}: FileProps) => { + const document = useRef(null); + const style = getStyleSheet(theme); + + const handlePreviewPress = useCallback(() => { + if (document.current) { + document.current.handlePreviewPress(); + } else { + onPress(index); + } + }, [index]); + + const {styles, onGestureEvent, ref} = useGalleryItem(galleryIdentifier, index, handlePreviewPress); + + const handleOnOptionsPress = useCallback(() => { + onOptionsPress?.(index); + }, [index, onOptionsPress]); + + const renderOptionsButton = () => { + if (onOptionsPress) { + return ( + + ); + } + return null; + }; + + const fileInfo = ( + + ); + + const renderImageFileOverlay = ( + + ); + + const renderImageFile = ( + + + + {Boolean(nonVisibleImagesCount) && renderImageFileOverlay} + + + ); + + const renderVideoFile = ( + + + + {Boolean(nonVisibleImagesCount) && renderImageFileOverlay} + + + ); + + const renderDocumentFile = ( + + + + ); + + const renderCardWithImage = (fileIcon: JSX.Element) => { + return ( + + + {fileIcon} + + {fileInfo} + {renderOptionsButton()} + + ); + }; + + let fileComponent; + if (isVideo(file) && publicLinkEnabled) { + fileComponent = asCard ? renderCardWithImage(renderVideoFile) : renderVideoFile; + } else if (isImage(file)) { + fileComponent = asCard ? renderCardWithImage(renderImageFile) : renderImageFile; + } else if (isDocument(file)) { + fileComponent = ( + + {renderDocumentFile} + {fileInfo} + {renderOptionsButton()} + + ); + } else { + const touchableWithPreview = ( + + + + ); + + fileComponent = renderCardWithImage(touchableWithPreview); + } + return fileComponent; +}; + +export default File; diff --git a/app/components/post_list/post/body/files/file_icon.tsx b/app/components/files/file_icon.tsx similarity index 100% rename from app/components/post_list/post/body/files/file_icon.tsx rename to app/components/files/file_icon.tsx diff --git a/app/components/files/file_info.tsx b/app/components/files/file_info.tsx new file mode 100644 index 0000000000..68a99cdea3 --- /dev/null +++ b/app/components/files/file_info.tsx @@ -0,0 +1,102 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Text, TouchableOpacity, View} from 'react-native'; + +import FormattedDate from '@components/formatted_date'; +import {getFormattedFileSize} from '@utils/file'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +type FileInfoProps = { + file: FileInfo; + showDate: boolean; + channelName?: string ; + onPress: () => void; + theme: Theme; +} +const FORMAT = ' • MMM DD HH:MM A'; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + attachmentContainer: { + flex: 1, + justifyContent: 'center', + }, + fileDownloadContainer: { + flexDirection: 'row', + marginTop: 3, + }, + fileStatsContainer: { + flexGrow: 1, + flexDirection: 'row', + }, + infoText: { + color: changeOpacity(theme.centerChannelColor, 0.64), + ...typography('Body', 75, 'Regular'), + }, + fileName: { + marginTop: -4, + flexDirection: 'column', + flexWrap: 'wrap', + color: theme.centerChannelColor, + paddingRight: 10, + ...typography('Body', 200, 'SemiBold'), + }, + channelWrapper: { + flexShrink: 1, + marginRight: 4, + backgroundColor: changeOpacity(theme.centerChannelColor, 0.08), + paddingHorizontal: 4, + borderRadius: 4, + }, + channelText: { + ...typography('Body', 50, 'SemiBold'), + color: changeOpacity(theme.centerChannelColor, 0.72), + }, + }; +}); + +const FileInfo = ({file, channelName, showDate, onPress, theme}: FileInfoProps) => { + const style = getStyleSheet(theme); + return ( + + + + {file.name.trim()} + + + {channelName && + + + {channelName} + + + } + + + {`${getFormattedFileSize(file.size)}`} + + {showDate && + + } + + + + + ); +}; + +export default FileInfo; diff --git a/app/components/files/file_options_icon.tsx b/app/components/files/file_options_icon.tsx new file mode 100644 index 0000000000..0a77f2bf8b --- /dev/null +++ b/app/components/files/file_options_icon.tsx @@ -0,0 +1,39 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {TouchableOpacity, StyleSheet} from 'react-native'; + +import CompassIcon from '@components/compass_icon'; +import {useTheme} from '@context/theme'; +import {changeOpacity} from '@utils/theme'; + +type Props = { + onPress: () => void; +} + +const styles = StyleSheet.create({ + threeDotContainer: { + alignItems: 'flex-end', + marginHorizontal: 20, + }, +}); + +const hitSlop = {top: 5, bottom: 5, left: 5, right: 5}; + +export default function FileOptionsIcon({onPress}: Props) { + const theme = useTheme(); + return ( + + + + ); +} diff --git a/app/components/post_list/post/body/files/files.tsx b/app/components/files/files.tsx similarity index 80% rename from app/components/post_list/post/body/files/files.tsx rename to app/components/files/files.tsx index 5592c8aff4..3b17dd77bf 100644 --- a/app/components/post_list/post/body/files/files.tsx +++ b/app/components/files/files.tsx @@ -1,16 +1,15 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useEffect, useMemo, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {DeviceEventEmitter, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'; import Animated, {useDerivedValue} from 'react-native-reanimated'; -import {buildFilePreviewUrl, buildFileUrl} from '@actions/remote/file'; import {Events} from '@constants'; import {GalleryInit} from '@context/gallery'; -import {useServerUrl} from '@context/server'; import {useIsTablet} from '@hooks/device'; -import {isGif, isImage, isVideo} from '@utils/file'; +import {useImageAttachments} from '@hooks/files'; +import {isImage, isVideo} from '@utils/file'; import {fileToGalleryItem, openGalleryAtIndex} from '@utils/gallery'; import {getViewPortWidth} from '@utils/images'; import {preventDoubleTap} from '@utils/tap'; @@ -50,32 +49,9 @@ const styles = StyleSheet.create({ const Files = ({canDownloadFiles, failed, filesInfo, isReplyPost, layoutWidth, location, postId, publicLinkEnabled, theme}: FilesProps) => { const galleryIdentifier = `${postId}-fileAttachments-${location}`; const [inViewPort, setInViewPort] = useState(false); - const serverUrl = useServerUrl(); const isTablet = useIsTablet(); - 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) || videoFile) ? buildFileUrl(serverUrl, file.id!) : buildFilePreviewUrl(serverUrl, file.id!); - } - images.push({...file, uri}); - } else { - if (videoFile) { - // fallback if public links are not enabled - file.uri = buildFileUrl(serverUrl, file.id!); - } - - nonImages.push(file); - } - return {images, nonImages}; - }, {images: [], nonImages: []}); - }, [filesInfo, publicLinkEnabled, serverUrl]); + const {images: imageAttachments, nonImages: nonImageAttachments} = useImageAttachments(filesInfo, publicLinkEnabled); const filesForGallery = useDerivedValue(() => imageAttachments.concat(nonImageAttachments), [imageAttachments, nonImageAttachments]); diff --git a/app/components/post_list/post/body/files/image_file.tsx b/app/components/files/image_file.tsx similarity index 100% rename from app/components/post_list/post/body/files/image_file.tsx rename to app/components/files/image_file.tsx diff --git a/app/components/post_list/post/body/files/image_file_overlay.tsx b/app/components/files/image_file_overlay.tsx similarity index 100% rename from app/components/post_list/post/body/files/image_file_overlay.tsx rename to app/components/files/image_file_overlay.tsx diff --git a/app/components/post_list/post/body/files/index.ts b/app/components/files/index.ts similarity index 100% rename from app/components/post_list/post/body/files/index.ts rename to app/components/files/index.ts diff --git a/app/components/post_list/post/body/files/video_file.tsx b/app/components/files/video_file.tsx similarity index 100% rename from app/components/post_list/post/body/files/video_file.tsx rename to app/components/files/video_file.tsx diff --git a/app/components/no_results_with_term/index.tsx b/app/components/no_results_with_term/index.tsx index ba4699be47..b5c8ae9cc0 100644 --- a/app/components/no_results_with_term/index.tsx +++ b/app/components/no_results_with_term/index.tsx @@ -7,6 +7,7 @@ import {View} from 'react-native'; import FormattedText from '@components/formatted_text'; import {useTheme} from '@context/theme'; import {t} from '@i18n'; +import {TabTypes, TabType} from '@utils/search'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {typography} from '@utils/typography'; @@ -15,7 +16,7 @@ import SearchIllustration from './search_illustration'; type Props = { term: string; - type?: 'default' | 'messages' | 'files'; + type?: TabType; }; const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => { @@ -45,18 +46,13 @@ const NoResultsWithTerm = ({term, type}: Props) => { const [defaultMessage, setDefaultMessage] = useState('No results for “{term}”'); useEffect(() => { - if (type === 'files') { - setTitleId(t('mobile.no_results_with_term.files')); - setDefaultMessage('No files matching “{term}”'); - } else if (type === 'messages') { - setTitleId(t('mobile.no_results_with_term.messages')); - setDefaultMessage('No matches found for “{term}”'); - } + setTitleId(type === TabTypes.FILES ? t('mobile.no_results_with_term.files') : t('mobile.no_results_with_term.messages')); + setDefaultMessage(type === TabTypes.FILES ? 'No files matching “{term}”' : 'No matches found for “{term}”'); }, [type]); return ( - {type === 'files' ? : } + {type === TabTypes.FILES ? : } void; - publicLinkEnabled: boolean; - theme: Theme; - wrapperWidth?: number; - updateFileForGallery: (idx: number, file: FileInfo) => void; -}; - -const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { - return { - fileWrapper: { - flex: 1, - flexDirection: 'row', - marginTop: 10, - borderWidth: 1, - borderColor: changeOpacity(theme.centerChannelColor, 0.4), - borderRadius: 5, - }, - iconWrapper: { - marginTop: 7.8, - marginRight: 6, - marginBottom: 8.2, - marginLeft: 8, - }, - }; -}); - -const File = ({ - 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 handlePreviewPress = useCallback(() => { - if (document.current) { - document.current.handlePreviewPress(); - } else { - onPress(index); - } - }, [index]); - - const {styles, onGestureEvent, ref} = useGalleryItem(galleryIdentifier, index, handlePreviewPress); - - if (isVideo(file) && publicLinkEnabled) { - return ( - - - - {Boolean(nonVisibleImagesCount) && - - } - - - ); - } - - if (isImage(file)) { - return ( - - - - {Boolean(nonVisibleImagesCount) && - - } - - - ); - } - - if (isDocument(file)) { - return ( - - - - - - - ); - } - - return ( - - - - - - - - - ); -}; - -export default File; diff --git a/app/components/post_list/post/body/files/file_info.tsx b/app/components/post_list/post/body/files/file_info.tsx deleted file mode 100644 index 10ce698650..0000000000 --- a/app/components/post_list/post/body/files/file_info.tsx +++ /dev/null @@ -1,68 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; -import {Text, TouchableOpacity, View} from 'react-native'; - -import {getFormattedFileSize} from '@utils/file'; -import {makeStyleSheetFromTheme} from '@utils/theme'; - -type FileInfoProps = { - file: FileInfo; - onPress: () => void; - theme: Theme; -} - -const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { - return { - attachmentContainer: { - flex: 1, - justifyContent: 'center', - }, - fileDownloadContainer: { - flexDirection: 'row', - marginTop: 3, - }, - fileInfo: { - fontSize: 14, - color: theme.centerChannelColor, - }, - fileName: { - flexDirection: 'column', - flexWrap: 'wrap', - fontSize: 14, - fontFamily: 'OpenSans-SemiBold', - color: theme.centerChannelColor, - paddingRight: 10, - }, - }; -}); - -const FileInfo = ({file, onPress, theme}: FileInfoProps) => { - const style = getStyleSheet(theme); - - return ( - - - - {file.name.trim()} - - - - {`${getFormattedFileSize(file.size)}`} - - - - - ); -}; - -export default FileInfo; diff --git a/app/components/post_list/post/body/index.tsx b/app/components/post_list/post/body/index.tsx index 6300cb4be7..360c18542b 100644 --- a/app/components/post_list/post/body/index.tsx +++ b/app/components/post_list/post/body/index.tsx @@ -4,6 +4,7 @@ import React, {useCallback, useState} from 'react'; import {LayoutChangeEvent, StyleProp, View, ViewStyle} from 'react-native'; +import Files from '@components/files'; import FormattedText from '@components/formatted_text'; import JumboEmoji from '@components/jumbo_emoji'; import {Screens} from '@constants'; @@ -14,7 +15,6 @@ import {makeStyleSheetFromTheme} from '@utils/theme'; import AddMembers from './add_members'; import Content from './content'; import Failed from './failed'; -import Files from './files'; import Message from './message'; import Reactions from './reactions'; diff --git a/app/components/post_with_channel_info/channel_info/channel_info.tsx b/app/components/post_with_channel_info/channel_info/channel_info.tsx index 5b3bf254c2..ee945d8f61 100644 --- a/app/components/post_with_channel_info/channel_info/channel_info.tsx +++ b/app/components/post_with_channel_info/channel_info/channel_info.tsx @@ -9,7 +9,6 @@ import {makeStyleSheetFromTheme} from '@utils/theme'; import {typography} from '@utils/typography'; import type ChannelModel from '@typings/database/models/servers/channel'; -import type PostModel from '@typings/database/models/servers/post'; import type TeamModel from '@typings/database/models/servers/team'; const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ @@ -37,7 +36,6 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ type Props = { channelName: ChannelModel['displayName']; - post: PostModel; teamName: TeamModel['displayName']; testID?: string; } diff --git a/app/hooks/files.ts b/app/hooks/files.ts new file mode 100644 index 0000000000..52ef662305 --- /dev/null +++ b/app/hooks/files.ts @@ -0,0 +1,36 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useMemo} from 'react'; + +import {buildFilePreviewUrl, buildFileUrl} from '@actions/remote/file'; +import {useServerUrl} from '@context/server'; +import {isGif, isImage, isVideo} from '@utils/file'; + +export const useImageAttachments = (filesInfo: FileInfo[], publicLinkEnabled: boolean) => { + const serverUrl = useServerUrl(); + return 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) || videoFile) ? buildFileUrl(serverUrl, file.id!) : buildFilePreviewUrl(serverUrl, file.id!); + } + images.push({...file, uri}); + } else { + if (videoFile) { + // fallback if public links are not enabled + file.uri = buildFileUrl(serverUrl, file.id!); + } + + nonImages.push(file); + } + return {images, nonImages}; + }, {images: [], nonImages: []}); + }, [filesInfo, publicLinkEnabled]); +}; + diff --git a/app/screens/gallery/document_renderer/document_renderer.tsx b/app/screens/gallery/document_renderer/document_renderer.tsx index fb8118c26a..72504e2a45 100644 --- a/app/screens/gallery/document_renderer/document_renderer.tsx +++ b/app/screens/gallery/document_renderer/document_renderer.tsx @@ -7,7 +7,7 @@ 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 FileIcon from '@components/files/file_icon'; import {Events, Preferences} from '@constants'; import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles'; import {isDocument} from '@utils/file'; diff --git a/app/screens/home/search/results/fileCard.tsx b/app/screens/home/search/results/fileCard.tsx deleted file mode 100644 index 3e7ceab76c..0000000000 --- a/app/screens/home/search/results/fileCard.tsx +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; -import {Text, View} from 'react-native'; - -import FileIcon from '@components/post_list/post/body/files/file_icon'; -import {useTheme} from '@context/theme'; -import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; -import {typography} from '@utils/typography'; - -type Props = { - fileInfo: FileInfo; - layoutWidth?: number; - location?: string; - metadata?: PostMetadata; - postId?: string; - theme?: Theme; -} - -const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { - return { - container: { - borderBottomColor: changeOpacity(theme.centerChannelColor, 0.15), - borderRightColor: changeOpacity(theme.centerChannelColor, 0.15), - borderTopColor: changeOpacity(theme.centerChannelColor, 0.15), - borderBottomWidth: 1, - borderRightWidth: 1, - borderTopWidth: 1, - marginTop: 5, - padding: 12, - borderLeftColor: changeOpacity(theme.linkColor, 0.6), - borderLeftWidth: 3, - }, - message: { - color: theme.centerChannelColor, - ...typography('Body', 100, 'Regular'), - }, - }; -}); - -export default function FileCard({fileInfo}: Props) { - const theme = useTheme(); - const style = getStyleSheet(theme); - return ( - - {'To be implemented'} - {`Name: ${fileInfo.name}`} - {`Size: ${fileInfo.size}`} - - - ); -} diff --git a/app/screens/home/search/results/header.tsx b/app/screens/home/search/results/header.tsx index 11e2a803c8..711e6ee320 100644 --- a/app/screens/home/search/results/header.tsx +++ b/app/screens/home/search/results/header.tsx @@ -13,19 +13,18 @@ import {useIsTablet} from '@hooks/device'; import {SEPARATOR_MARGIN, SEPARATOR_MARGIN_TABLET, TITLE_HEIGHT} from '@screens/bottom_sheet/content'; import {bottomSheet} from '@screens/navigation'; import {FileFilter, FileFilters} from '@utils/file'; +import {TabTypes, TabType} from '@utils/search'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import Filter, {DIVIDERS_HEIGHT, FILTER_ITEM_HEIGHT, NUMBER_FILTER_ITEMS} from './filter'; import SelectButton from './header_button'; -export type SelectTab = 'files' | 'messages' - -export const HEADER_HEIGHT = 64; +const HEADER_HEIGHT = 64; type Props = { - onTabSelect: (tab: SelectTab) => void; + onTabSelect: (tab: TabType) => void; onFilterChanged: (filter: FileFilter) => void; - selectedTab: SelectTab; + selectedTab: TabType; selectedFilter: FileFilter; numberMessages: number; numberFiles: number; @@ -73,15 +72,15 @@ const Header = ({ const messagesText = intl.formatMessage({id: 'screen.search.header.messages', defaultMessage: 'Messages'}); const filesText = intl.formatMessage({id: 'screen.search.header.files', defaultMessage: 'Files'}); - const showFilterIcon = selectedTab === 'files'; + const showFilterIcon = selectedTab === TabTypes.FILES; const hasFilters = selectedFilter !== FileFilters.ALL; const handleMessagesPress = useCallback(() => { - onTabSelect('messages'); + onTabSelect(TabTypes.MESSAGES); }, [onTabSelect]); const handleFilesPress = useCallback(() => { - onTabSelect('files'); + onTabSelect(TabTypes.FILES); }, [onTabSelect]); const snapPoints = useMemo(() => { @@ -116,12 +115,12 @@ const Header = ({ <> diff --git a/app/screens/home/search/results/index.tsx b/app/screens/home/search/results/index.tsx index 413f8595e6..d725956e2d 100644 --- a/app/screens/home/search/results/index.tsx +++ b/app/screens/home/search/results/index.tsx @@ -4,29 +4,51 @@ import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; import withObservables from '@nozbe/with-observables'; import compose from 'lodash/fp/compose'; -import {of as of$} from 'rxjs'; -import {switchMap} from 'rxjs/operators'; +import {combineLatest, of as of$} from 'rxjs'; +import {map, switchMap} from 'rxjs/operators'; +import {queryChannelsById} from '@queries/servers/channel'; import {queryPostsById} from '@queries/servers/post'; -import {observeConfigBooleanValue} from '@queries/servers/system'; +import {observeLicense, observeConfigBooleanValue} from '@queries/servers/system'; import {observeCurrentUser} from '@queries/servers/user'; import {getTimezone} from '@utils/user'; import Results from './results'; import type {WithDatabaseArgs} from '@typings/database/database'; +import type PostModel from '@typings/database/models/servers/post'; type enhancedProps = WithDatabaseArgs & { postIds: string[]; + fileChannelIds: string[]; } -const enhance = withObservables(['postIds'], ({database, postIds}: enhancedProps) => { - const posts = queryPostsById(database, postIds).observe(); +const sortPosts = (a: PostModel, b: PostModel) => a.createAt - b.createAt; + +const enhance = withObservables(['postIds', 'fileChannelIds'], ({database, postIds, fileChannelIds}: enhancedProps) => { + const posts = queryPostsById(database, postIds).observeWithColumns(['type', 'createAt']).pipe( + switchMap((pp) => of$(pp.sort(sortPosts))), + ); + const fileChannels = queryChannelsById(database, fileChannelIds).observeWithColumns(['displayName']); const currentUser = observeCurrentUser(database); + + const enableMobileFileDownload = observeConfigBooleanValue(database, 'EnableMobileFileDownload'); + + const complianceDisabled = observeLicense(database).pipe( + switchMap((lcs) => of$(lcs?.IsLicensed === 'false' || lcs?.Compliance === 'false')), + ); + + const canDownloadFiles = combineLatest([enableMobileFileDownload, complianceDisabled]).pipe( + map(([download, compliance]) => compliance || download), + ); + return { - currentTimezone: currentUser.pipe((switchMap((user) => of$(getTimezone(user?.timezone || null))))), + currentTimezone: currentUser.pipe((switchMap((user) => of$(getTimezone(user?.timezone))))), isTimezoneEnabled: observeConfigBooleanValue(database, 'ExperimentalTimezone'), posts, + fileChannels, + canDownloadFiles, + publicLinkEnabled: observeConfigBooleanValue(database, 'EnablePublicLink'), }; }); @@ -34,4 +56,3 @@ export default compose( withDatabase, enhance, )(Results); - diff --git a/app/screens/home/search/results/results.tsx b/app/screens/home/search/results/results.tsx index cbd5dd14e6..9da4ddde36 100644 --- a/app/screens/home/search/results/results.tsx +++ b/app/screens/home/search/results/results.tsx @@ -2,65 +2,118 @@ // See LICENSE.txt for license information. import React, {useCallback, useMemo} from 'react'; -import {FlatList, ListRenderItemInfo, NativeScrollEvent, NativeSyntheticEvent, Text, View} from 'react-native'; +import {StyleSheet, FlatList, ListRenderItemInfo, NativeScrollEvent, NativeSyntheticEvent, StyleProp, View, ViewStyle} from 'react-native'; import Animated from 'react-native-reanimated'; +import File from '@components/files/file'; import NoResultsWithTerm from '@components/no_results_with_term'; import DateSeparator from '@components/post_list/date_separator'; import PostWithChannelInfo from '@components/post_with_channel_info'; import {Screens} from '@constants'; import {useTheme} from '@context/theme'; -import {PostModel} from '@database/models/server'; +import {useIsTablet} from '@hooks/device'; +import {useImageAttachments} from '@hooks/files'; +import {isImage, isVideo} from '@utils/file'; +import {fileToGalleryItem, openGalleryAtIndex} from '@utils/gallery'; +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 FileCard from './fileCard'; import Loader from './loader'; -const notImplementedComponent = ( - - {'Not Implemented'} - -); +import type ChannelModel from '@typings/database/models/servers/channel'; +import type PostModel from '@typings/database/models/servers/post'; + +const styles = StyleSheet.create({ + container: { + flex: 1, + marginHorizontal: 20, + }, +}); const AnimatedFlatList = Animated.createAnimatedComponent(FlatList); type Props = { - searchValue: string; - selectedTab: 'messages' | 'files'; + canDownloadFiles: boolean; currentTimezone: string; - isTimezoneEnabled: boolean; - posts: PostModel[]; + fileChannels: ChannelModel[]; fileInfos: FileInfo[]; - scrollRef: React.RefObject; - onScroll: (event: NativeSyntheticEvent) => void; - scrollPaddingTop: number; + isTimezoneEnabled: boolean; loading: boolean; + onScroll: (event: NativeSyntheticEvent) => void; + posts: PostModel[]; + publicLinkEnabled: boolean; + scrollPaddingTop: number; + scrollRef: React.RefObject; + searchValue: string; + selectedTab: TabType; } const emptyList: FileInfo[] | Array = []; +const galleryIdentifier = 'search-files-location'; -const SearchResults = ({ +const Results = ({ + canDownloadFiles, currentTimezone, + fileChannels, fileInfos, isTimezoneEnabled, + loading, + onScroll, posts, + publicLinkEnabled, + scrollPaddingTop, + scrollRef, searchValue, selectedTab, - scrollRef, - onScroll, - scrollPaddingTop, - loading, }: Props) => { const theme = useTheme(); + const isTablet = useIsTablet(); 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); + const channelNames = useMemo(() => fileChannels.reduce<{[id: string]: string | undefined}>((acc, v) => { + acc[v.id] = v.displayName; + return acc; + }, {}), [fileChannels]); + + const containerStyle = useMemo(() => { + let padding = 0; + if (selectedTab === TabTypes.MESSAGES) { + padding = posts.length ? 4 : 8; + } else { + padding = fileInfos.length ? 8 : 0; + } + return {top: padding}; + }, [selectedTab, posts, fileInfos]); + + const filesForGallery = useMemo(() => imageAttachments.concat(nonImageAttachments), + [imageAttachments, nonImageAttachments]); + + const orderedFilesForGallery = useMemo(() => ( + filesForGallery.sort((a: FileInfo, b: FileInfo) => { + return (b.create_at || 0) - (a.create_at || 0); + }) + ), [filesForGallery]); + + const filesForGalleryIndexes = useMemo(() => orderedFilesForGallery.reduce<{[id: string]: number | undefined}>((acc, v, idx) => { + if (v.id) { + acc[v.id] = idx; + } + return acc; + }, {}), [orderedFilesForGallery]); + + const handlePreviewPress = useCallback(preventDoubleTap((idx: number) => { + const items = orderedFilesForGallery.map((f) => fileToGalleryItem(f, f.user_id)); + 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 renderItem = useCallback(({item}: ListRenderItemInfo) => { if (typeof item === 'string') { @@ -86,37 +139,73 @@ const SearchResults = ({ ); } + const updateFileForGallery = (idx: number, file: FileInfo) => { + 'worklet'; + orderedFilesForGallery[idx] = file; + }; + + const container: StyleProp = fileInfos.length > 1 ? styles.container : undefined; + const isSingleImage = orderedFilesForGallery.length === 1 && (isImage(orderedFilesForGallery[0]) || isVideo(orderedFilesForGallery[0])); + const isReplyPost = false; + return ( - + > + + ); - }, [theme]); + }, [ + theme, + (orderedFilesForGallery.length === 1) && orderedFilesForGallery[0].mime_type, + handleOptionsPress, + channelNames, + filesForGalleryIndexes, + canDownloadFiles, + handlePreviewPress, + publicLinkEnabled, + isTablet, + fileInfos.length > 1, + ]); const noResults = useMemo(() => { - if (searchValue) { - if (loading) { - return (); - } - return ( - - ); + if (loading) { + return (); } - - return notImplementedComponent; + return ( + + ); }, [searchValue, loading, selectedTab]); let data; if (loading || !searchValue) { data = emptyList; - } else if (selectedTab === 'messages') { + } else if (selectedTab === TabTypes.MESSAGES) { data = orderedPosts; } else { - data = fileInfos; + data = orderedFilesForGallery; } return ( @@ -134,9 +223,10 @@ const SearchResults = ({ onScroll={onScroll} removeClippedSubviews={true} ref={scrollRef} + style={containerStyle} testID='search_results.post_list.flat_list' /> ); }; -export default SearchResults; +export default Results; diff --git a/app/screens/home/search/search.tsx b/app/screens/home/search/search.tsx index 48f763d2a4..74a3c45b0b 100644 --- a/app/screens/home/search/search.tsx +++ b/app/screens/home/search/search.tsx @@ -16,15 +16,17 @@ import RoundedHeaderContext from '@components/rounded_header_context'; import {useServerUrl} from '@context/server'; import {useCollapsibleHeader} from '@hooks/header'; import {FileFilter, FileFilters, filterFileExtensions} from '@utils/file'; +import {TabTypes, TabType} from '@utils/search'; import Modifiers from './modifiers'; import Results from './results'; -import Header, {SelectTab} from './results/header'; +import Header from './results/header'; const EDGES: Edge[] = ['bottom', 'left', 'right']; const emptyFileResults: FileInfo[] = []; const emptyPostResults: string[] = []; +const emptyChannelIds: string[] = []; type Props = { teamId: string; @@ -46,7 +48,7 @@ const SearchScreen = ({teamId}: Props) => { const {searchTerm} = nav.getState().routes[stateIndex].params; const [searchValue, setSearchValue] = useState(searchTerm); - const [selectedTab, setSelectedTab] = useState('messages'); + const [selectedTab, setSelectedTab] = useState(TabTypes.MESSAGES); const [filter, setFilter] = useState(FileFilters.ALL); const [showResults, setShowResults] = useState(false); @@ -55,6 +57,7 @@ const SearchScreen = ({teamId}: Props) => { const [postIds, setPostIds] = useState(emptyPostResults); const [fileInfos, setFileInfos] = useState(emptyFileResults); + const [fileChannelIds, setFileChannelIds] = useState([]); const getSearchParams = useCallback((filterValue?: FileFilter) => { const terms = filterValue ? lastSearchedValue : searchValue; @@ -72,21 +75,24 @@ const SearchScreen = ({teamId}: Props) => { // - add recent if doesn't exist // - updated recent createdAt if exists?? + const searchParams = getSearchParams(); + if (!searchParams.terms) { + handleClearSearch(); + return; + } setLoading(true); + setShowResults(true); setFilter(FileFilters.ALL); setLastSearchedValue(searchValue); - const searchParams = getSearchParams(); - const [postResults, fileResults] = await Promise.all([ + const [postResults, {files, channels}] = await Promise.all([ searchPosts(serverUrl, searchParams), searchFiles(serverUrl, teamId, searchParams), ]); - const fileInfosResult = fileResults?.file_infos && Object.values(fileResults?.file_infos); - setFileInfos(fileInfosResult?.length ? fileInfosResult : emptyFileResults); + setFileInfos(files?.length ? files : emptyFileResults); setPostIds(postResults?.order?.length ? postResults.order : emptyPostResults); - + setFileChannelIds(channels?.length ? channels : emptyChannelIds); setLoading(false); - setShowResults(true); })), [searchValue]); const onSnap = (offset: number) => { @@ -97,9 +103,9 @@ const SearchScreen = ({teamId}: Props) => { setLoading(true); setFilter(filterValue); const searchParams = getSearchParams(filterValue); - const fileResults = await searchFiles(serverUrl, teamId, searchParams); - const fileInfosResult = fileResults?.file_infos && Object.values(fileResults?.file_infos); - setFileInfos(fileInfosResult?.length ? fileInfosResult : emptyFileResults); + const {files, channels} = await searchFiles(serverUrl, teamId, searchParams); + setFileInfos(files?.length ? files : emptyFileResults); + setFileChannelIds(channels?.length ? channels : emptyChannelIds); setLoading(false); }, [lastSearchedValue]); @@ -191,6 +197,7 @@ const SearchScreen = ({teamId}: Props) => { selectedTab={selectedTab} searchValue={lastSearchedValue} postIds={postIds} + fileChannelIds={fileChannelIds} fileInfos={fileInfos} scrollRef={scrollRef} onScroll={onScroll} diff --git a/app/utils/search/index.ts b/app/utils/search/index.ts new file mode 100644 index 0000000000..895376d099 --- /dev/null +++ b/app/utils/search/index.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import keyMirror from '@utils/key_mirror'; +export const TabTypes = keyMirror({ + MESSAGES: null, + FILES: null, +}); + +export type TabType = keyof typeof TabTypes; diff --git a/types/api/files.d.ts b/types/api/files.d.ts index 2eefa36486..e2cade6442 100644 --- a/types/api/files.d.ts +++ b/types/api/files.d.ts @@ -4,6 +4,7 @@ type FileInfo = { id?: string; bytesRead?: number; + channel_id?: string; clientId?: string; create_at?: number; delete_at?: number; diff --git a/types/api/search.d.ts b/types/api/search.d.ts new file mode 100644 index 0000000000..c92a2a2b25 --- /dev/null +++ b/types/api/search.d.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +type FileSearchRequest = { + error?: unknown; + file_infos?: {[id: string]: FileInfo}; + next_file_info_id?: string; + order?: string[]; + prev_file_info_id?: string; +} + +type PostSearchRequest = { + error?: unknown; + order?: string[]; + posts?: Post[]; +}