diff --git a/app/actions/local/post.ts b/app/actions/local/post.ts index 3deff24fa8..8378ab932f 100644 --- a/app/actions/local/post.ts +++ b/app/actions/local/post.ts @@ -3,7 +3,7 @@ import {ActionType, Post} from '@constants'; import DatabaseManager from '@database/manager'; -import {getPostById, prepareDeletePost} from '@queries/servers/post'; +import {getPostById, prepareDeletePost, queryPostsById} from '@queries/servers/post'; import {getCurrentUserId} from '@queries/servers/system'; import {getIsCRTEnabled, prepareThreadsFromReceivedPosts} from '@queries/servers/thread'; import {generateId} from '@utils/general'; @@ -233,3 +233,12 @@ export async function storePostsForChannel( return {error}; } } + +export async function getPosts(serverUrl: string, ids: string[]) { + try { + const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + return queryPostsById(database, ids).fetch(); + } catch (error) { + return []; + } +} diff --git a/app/components/files/files.tsx b/app/components/files/files.tsx index 9d7fab2fce..5f45f878ec 100644 --- a/app/components/files/files.tsx +++ b/app/components/files/files.tsx @@ -66,7 +66,6 @@ const Files = ({canDownloadFiles, failed, filesInfo, isReplyPost, layoutWidth, l const updateFileForGallery = (idx: number, file: FileInfo) => { 'worklet'; - filesForGallery.value[idx] = file; }; diff --git a/app/screens/home/search/results/file_results.tsx b/app/screens/home/search/results/file_results.tsx new file mode 100644 index 0000000000..ec01da4b2d --- /dev/null +++ b/app/screens/home/search/results/file_results.tsx @@ -0,0 +1,196 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import {StyleSheet, FlatList, ListRenderItemInfo, StyleProp, View, ViewStyle} from 'react-native'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; + +import File from '@components/files/file'; +import NoResultsWithTerm from '@components/no_results_with_term'; +import {ITEM_HEIGHT} from '@components/option_item'; +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 {TabTypes} 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'; + +const styles = StyleSheet.create({ + container: { + flex: 1, + marginHorizontal: 20, + }, +}); + +type Props = { + canDownloadFiles: boolean; + fileChannels: ChannelModel[]; + fileInfos: FileInfo[]; + publicLinkEnabled: boolean; + paddingTop: StyleProp; + searchValue: string; +} + +const galleryIdentifier = 'search-files-location'; + +const FileResults = ({ + canDownloadFiles, + fileChannels, + fileInfos, + publicLinkEnabled, + paddingTop, + searchValue, +}: Props) => { + const theme = useTheme(); + const isTablet = useIsTablet(); + const insets = useSafeAreaInsets(); + const [lastViewedIndex, setLastViewedIndex] = useState(undefined); + const containerStyle = useMemo(() => ({top: fileInfos.length ? 8 : 0}), [fileInfos]); + + 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 orderedFilesForGallery = useMemo(() => { + const filesForGallery = imageAttachments.concat(nonImageAttachments); + return filesForGallery.sort((a: FileInfo, b: FileInfo) => { + return (b.create_at || 0) - (a.create_at || 0); + }); + }, [imageAttachments, nonImageAttachments]); + + 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((item: number) => { + setLastViewedIndex(item); + let numberOptions = 1; + numberOptions += canDownloadFiles ? 1 : 0; + numberOptions += publicLinkEnabled ? 1 : 0; + const renderContent = () => ( + + ); + bottomSheet({ + closeButtonId: 'close-search-file-options', + renderContent, + snapPoints: [bottomSheetSnapPoint(numberOptions, ITEM_HEIGHT, insets.bottom) + HEADER_HEIGHT, 10], + theme, + title: '', + }); + }, [canDownloadFiles, publicLinkEnabled, orderedFilesForGallery, 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() === Screens.BOTTOM_SHEET) { + dismissBottomSheet().then(() => { + handleOptionsPress(lastViewedIndex); + }); + } + }, [canDownloadFiles, publicLinkEnabled]); + + const updateFileForGallery = (idx: number, file: FileInfo) => { + 'worklet'; + orderedFilesForGallery[idx] = file; + }; + + const renderItem = useCallback(({item}: ListRenderItemInfo) => { + const container: StyleProp = fileInfos.length > 1 ? styles.container : undefined; + const isSingleImage = orderedFilesForGallery.length === 1 && (isImage(orderedFilesForGallery[0]) || isVideo(orderedFilesForGallery[0])); + const isReplyPost = false; + + return ( + + + + ); + }, [ + (orderedFilesForGallery.length === 1) && orderedFilesForGallery[0].mime_type, + canDownloadFiles, + channelNames, + fileInfos.length > 1, + filesForGalleryIndexes, + handleOptionsPress, + handlePreviewPress, + isTablet, + publicLinkEnabled, + theme, + ]); + + const noResults = useMemo(() => ( + + ), [searchValue]); + + return ( + + ); +}; + +export default FileResults; diff --git a/app/screens/home/search/results/index.tsx b/app/screens/home/search/results/index.tsx index d725956e2d..9605a13f6a 100644 --- a/app/screens/home/search/results/index.tsx +++ b/app/screens/home/search/results/index.tsx @@ -8,7 +8,6 @@ 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 {observeLicense, observeConfigBooleanValue} from '@queries/servers/system'; import {observeCurrentUser} from '@queries/servers/user'; import {getTimezone} from '@utils/user'; @@ -16,19 +15,12 @@ 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 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 enhance = withObservables(['fileChannelIds'], ({database, fileChannelIds}: enhancedProps) => { const fileChannels = queryChannelsById(database, fileChannelIds).observeWithColumns(['displayName']); const currentUser = observeCurrentUser(database); @@ -45,7 +37,6 @@ const enhance = withObservables(['postIds', 'fileChannelIds'], ({database, postI return { currentTimezone: currentUser.pipe((switchMap((user) => of$(getTimezone(user?.timezone))))), isTimezoneEnabled: observeConfigBooleanValue(database, 'ExperimentalTimezone'), - posts, fileChannels, canDownloadFiles, publicLinkEnabled: observeConfigBooleanValue(database, 'EnablePublicLink'), diff --git a/app/screens/home/search/results/post_results.tsx b/app/screens/home/search/results/post_results.tsx new file mode 100644 index 0000000000..8e6b457a55 --- /dev/null +++ b/app/screens/home/search/results/post_results.tsx @@ -0,0 +1,88 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useMemo} from 'react'; +import {FlatList, ListRenderItemInfo, StyleProp, ViewStyle} from 'react-native'; + +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 {getDateForDateLine, isDateLine, selectOrderedPosts} from '@utils/post_list'; +import {TabTypes} from '@utils/search'; + +import type PostModel from '@typings/database/models/servers/post'; + +type Props = { + currentTimezone: string; + isTimezoneEnabled: boolean; + posts: PostModel[]; + paddingTop: StyleProp; + searchValue: string; +} + +const PostResults = ({ + currentTimezone, + isTimezoneEnabled, + posts, + paddingTop, + searchValue, +}: Props) => { + const orderedPosts = useMemo(() => selectOrderedPosts(posts, 0, false, '', '', false, isTimezoneEnabled, currentTimezone, false).reverse(), [posts]); + const containerStyle = useMemo(() => ({top: posts.length ? 4 : 8}), [posts]); + + const renderItem = useCallback(({item}: ListRenderItemInfo) => { + if (typeof item === 'string') { + if (isDateLine(item)) { + return ( + + ); + } + return null; + } + + if ('message' in item) { + return ( + + ); + } + return null; + }, []); + + const noResults = useMemo(() => ( + + ), [searchValue]); + + return ( + + ); +}; + +export default PostResults; diff --git a/app/screens/home/search/results/results.tsx b/app/screens/home/search/results/results.tsx index d563f3195b..6bf6fbba82 100644 --- a/app/screens/home/search/results/results.tsx +++ b/app/screens/home/search/results/results.tsx @@ -1,45 +1,41 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -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 React, {useMemo} from 'react'; +import {ScaledSize, StyleSheet, useWindowDimensions, View} from 'react-native'; +import Animated, {useAnimatedStyle, withTiming} from 'react-native-reanimated'; -import File from '@components/files/file'; -import Loading from '@components/loading'; -import NoResultsWithTerm from '@components/no_results_with_term'; -import {ITEM_HEIGHT} from '@components/option_item'; -import DateSeparator from '@components/post_list/date_separator'; -import PostWithChannelInfo from '@components/post_with_channel_info'; -import {Screens} from '@constants'; +import Loading from '@app/components/loading'; 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 FileResults from './file_results'; +import PostResults from './post_results'; 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 duration = 250; -const AnimatedFlatList = Animated.createAnimatedComponent(FlatList); +const getStyles = (dimensions: ScaledSize) => { + return StyleSheet.create({ + container: { + flex: 1, + flexDirection: 'row', + width: dimensions.width * 2, + }, + result: { + flex: 1, + width: dimensions.width, + }, + loading: { + justifyContent: 'center', + flex: 1, + width: dimensions.width, + }, + + }); +}; type Props = { canDownloadFiles: boolean; @@ -50,13 +46,12 @@ type Props = { loading: boolean; posts: PostModel[]; publicLinkEnabled: boolean; + scrollPaddingTop: number; searchValue: string; selectedTab: TabType; } -const galleryIdentifier = 'search-files-location'; - -const SearchResults = ({ +const Results = ({ canDownloadFiles, currentTimezone, fileChannels, @@ -65,207 +60,61 @@ const SearchResults = ({ loading, posts, publicLinkEnabled, + scrollPaddingTop, searchValue, selectedTab, }: Props) => { + const dimensions = useWindowDimensions(); const theme = useTheme(); - const isTablet = useIsTablet(); - const insets = useSafeAreaInsets(); - const [lastViewedIndex, setLastViewedIndex] = useState(undefined); + const styles = useMemo(() => getStyles(dimensions), [dimensions]); - 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 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 ( - - ); + const transform = useAnimatedStyle(() => { + const translateX = selectedTab === TabTypes.MESSAGES ? 0 : -dimensions.width; + return { + transform: [ + {translateX: withTiming(translateX, {duration})}, + ], }; - bottomSheet({ - closeButtonId: 'close-search-file-options', - renderContent, - snapPoints, - theme, - title: '', - }); - }, [orderedFilesForGallery, snapPoints, theme]); + }, [selectedTab, dimensions.width]); - // 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 paddingTop = useMemo(() => ( + {paddingTop: scrollPaddingTop, flexGrow: 1} + ), [scrollPaddingTop]); - const renderItem = useCallback(({item}: ListRenderItemInfo) => { - if (item === 'loading') { - return ( + return ( + <> + {loading && - ); - } - - if (typeof item === 'string') { - if (isDateLine(item)) { - return ( - - ); } - return null; - } - - if ('message' in item) { - return ( - - ); - } - - 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, - (orderedFilesForGallery.length === 1) && orderedFilesForGallery[0].mime_type, - handleOptionsPress, - channelNames, - filesForGalleryIndexes, - canDownloadFiles, - handlePreviewPress, - publicLinkEnabled, - isTablet, - fileInfos.length > 1, - ]); - - const noResults = useMemo(() => { - return ( - - ); - }, [searchValue, selectedTab]); - - let data; - if (loading) { - data = ['loading']; - } else { - data = selectedTab === TabTypes.MESSAGES ? orderedPosts : orderedFilesForGallery; - } - - return ( - + {!loading && + + + + + + + + + } + ); }; -export default SearchResults; +export default Results; diff --git a/app/screens/home/search/search.tsx b/app/screens/home/search/search.tsx index 6e0f31e958..b8fbb1b6ef 100644 --- a/app/screens/home/search/search.tsx +++ b/app/screens/home/search/search.tsx @@ -8,6 +8,7 @@ import {FlatList, LayoutChangeEvent, Platform, StyleSheet, ViewProps} from 'reac import Animated, {useAnimatedStyle, withTiming} from 'react-native-reanimated'; import {Edge, SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context'; +import {getPosts} from '@actions/local/post'; import {addSearchToTeamSearchHistory} from '@actions/local/team'; import {searchPosts, searchFiles} from '@actions/remote/search'; import Autocomplete from '@components/autocomplete'; @@ -27,11 +28,13 @@ import Initial from './initial'; import Results from './results'; import Header from './results/header'; +import type PostModel from '@typings/database/models/servers/post'; + const EDGES: Edge[] = ['bottom', 'left', 'right']; const AnimatedFlatList = Animated.createAnimatedComponent(FlatList); const emptyFileResults: FileInfo[] = []; -const emptyPostResults: string[] = []; +const emptyPosts: PostModel[] = []; const emptyChannelIds: string[] = []; const dummyData = [1]; @@ -87,8 +90,7 @@ const SearchScreen = ({teamId}: Props) => { const [loading, setLoading] = useState(false); const [resultsLoading, setResultsLoading] = useState(false); const [lastSearchedValue, setLastSearchedValue] = useState(''); - - const [postIds, setPostIds] = useState(emptyPostResults); + const [posts, setPosts] = useState(emptyPosts); const [fileInfos, setFileInfos] = useState(emptyFileResults); const [fileChannelIds, setFileChannelIds] = useState([]); @@ -130,9 +132,11 @@ const SearchScreen = ({teamId}: Props) => { ]); setFileInfos(files?.length ? files : emptyFileResults); - setPostIds(postResults?.order?.length ? postResults.order : emptyPostResults); + if (postResults.order) { + const postModels = await getPosts(serverUrl, postResults.order); + setPosts(postModels.length ? postModels : emptyPosts); + } setFileChannelIds(channels?.length ? channels : emptyChannelIds); - handleLoading(false); setShowResults(true); }, [handleCancelAndClearSearch, handleLoading, showResults]); @@ -184,29 +188,14 @@ const SearchScreen = ({teamId}: Props) => { /> ), [searchValue, searchTeamId, handleRecentSearch, handleTextChange]); - const resultsComponent = useMemo(() => ( - - ), [selectedTab, lastSearchedValue, postIds, fileInfos, fileChannelIds, resultsLoading]); - const renderItem = useCallback(() => { if (loading) { return loadingComponent; } - if (!showResults) { - return initialComponent; - } - return resultsComponent; + return initialComponent; }, [ loading && loadingComponent, - !loading && !showResults && initialComponent, - !loading && showResults && resultsComponent, + initialComponent, ]); const animated = useAnimatedStyle(() => { @@ -243,7 +232,7 @@ const SearchScreen = ({teamId}: Props) => { setTeamId={handleResultsTeamChange} onTabSelect={setSelectedTab} onFilterChanged={handleFilterChange} - numberMessages={postIds.length} + numberMessages={posts.length} selectedTab={selectedTab} numberFiles={fileInfos.length} selectedFilter={filter} @@ -300,21 +289,34 @@ const SearchScreen = ({teamId}: Props) => { {header} - + {!showResults && + + } + {showResults && !loading && + + }