diff --git a/app/actions/local/team.ts b/app/actions/local/team.ts index c33bb8a4d8..8327359448 100644 --- a/app/actions/local/team.ts +++ b/app/actions/local/team.ts @@ -2,9 +2,11 @@ // See LICENSE.txt for license information. import DatabaseManager from '@database/manager'; -import {prepareDeleteTeam, getMyTeamById, removeTeamFromTeamHistory} from '@queries/servers/team'; +import {prepareDeleteTeam, getMyTeamById, queryTeamSearchHistoryByTeamId, removeTeamFromTeamHistory, getTeamSearchHistoryById} from '@queries/servers/team'; import {logError} from '@utils/log'; +import type Model from '@nozbe/watermelondb/Model'; + export async function removeUserFromTeam(serverUrl: string, teamId: string) { try { const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); @@ -30,3 +32,56 @@ export async function removeUserFromTeam(serverUrl: string, teamId: string) { return {error}; } } + +export async function addSearchToTeamSearchHistory(serverUrl: string, teamId: string, terms: string) { + const MAX_TEAM_SEARCHES = 15; + try { + const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const newSearch: TeamSearchHistory = { + created_at: Date.now(), + display_term: terms, + term: terms, + team_id: teamId, + }; + + const models: Model[] = []; + const searchModels = await operator.handleTeamSearchHistory({teamSearchHistories: [newSearch], prepareRecordsOnly: true}); + const searchModel = searchModels[0]; + + models.push(searchModel); + + // determine if need to delete the oldest entry + if (searchModel._raw._changed !== 'created_at') { + const teamSearchHistory = await queryTeamSearchHistoryByTeamId(database, teamId).fetch(); + if (teamSearchHistory.length > MAX_TEAM_SEARCHES) { + const lastSearches = teamSearchHistory.slice(MAX_TEAM_SEARCHES); + for (const lastSearch of lastSearches) { + models.push(lastSearch.prepareDestroyPermanently()); + } + } + } + + await operator.batchRecords(models); + return {searchModel}; + } catch (error) { + logError('Failed addSearchToTeamSearchHistory', error); + return {error}; + } +} + +export async function removeSearchFromTeamSearchHistory(serverUrl: string, id: string) { + try { + const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const teamSearch = await getTeamSearchHistoryById(database, id); + if (teamSearch) { + await database.write(async () => { + await teamSearch.destroyPermanently(); + }); + } + return {teamSearch}; + } catch (error) { + logError('Failed removeSearchFromTeamSearchHistory', error); + return {error}; + } +} + diff --git a/app/components/loading/index.tsx b/app/components/loading/index.tsx index c728be1120..0cfc399e08 100644 --- a/app/components/loading/index.tsx +++ b/app/components/loading/index.tsx @@ -2,12 +2,12 @@ // See LICENSE.txt for license information. import React from 'react'; -import {ActivityIndicator, View, ViewStyle} from 'react-native'; +import {ActivityIndicator, StyleProp, View, ViewStyle} from 'react-native'; import {useTheme} from '@context/theme'; type LoadingProps = { - containerStyle?: ViewStyle; + containerStyle?: StyleProp; size?: number | 'small' | 'large'; color?: string; themeColor?: keyof Theme; 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 ee945d8f61..a0b9794fab 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 @@ -22,10 +22,12 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ ...typography('Body', 75, 'SemiBold'), color: theme.centerChannelColor, marginRight: 5, + flexShrink: 1, }, teamContainer: { borderColor: theme.centerChannelColor, borderLeftWidth: StyleSheet.hairlineWidth, + flexShrink: 1, }, team: { ...typography('Body', 75, 'Light'), @@ -52,6 +54,7 @@ function ChannelInfo({channelName, teamName, testID}: Props) { {channelName} @@ -60,6 +63,7 @@ function ChannelInfo({channelName, teamName, testID}: Props) { {teamName} diff --git a/app/queries/servers/team.ts b/app/queries/servers/team.ts index a6db7a42fe..2e57c3917a 100644 --- a/app/queries/servers/team.ts +++ b/app/queries/servers/team.ts @@ -22,12 +22,14 @@ import type ServerDataOperator from '@database/operator/server_data_operator'; import type MyTeamModel from '@typings/database/models/servers/my_team'; import type TeamModel from '@typings/database/models/servers/team'; import type TeamChannelHistoryModel from '@typings/database/models/servers/team_channel_history'; +import type TeamSearchHistoryModel from '@typings/database/models/servers/team_search_history'; const { MY_CHANNEL, MY_TEAM, TEAM, TEAM_CHANNEL_HISTORY, + TEAM_SEARCH_HISTORY, } = DatabaseConstants.MM_TABLES.SERVER; export const getCurrentTeam = async (database: Database) => { @@ -322,6 +324,15 @@ export const getTeamById = async (database: Database, teamId: string) => { } }; +export const getTeamSearchHistoryById = async (database: Database, id: string) => { + try { + const teamSearchHistory = await database.get(TEAM_SEARCH_HISTORY).find(id); + return teamSearchHistory; + } catch { + return undefined; + } +}; + export const observeTeam = (database: Database, teamId: string) => { return database.get(TEAM).query(Q.where('id', teamId), Q.take(1)).observe().pipe( switchMap((result) => (result.length ? result[0].observe() : of$(undefined))), @@ -356,6 +367,12 @@ export const getTeamByName = async (database: Database, teamName: string) => { return undefined; }; +export const queryTeamSearchHistoryByTeamId = (database: Database, teamId: string) => { + return database.get(TEAM_SEARCH_HISTORY).query( + Q.where('team_id', teamId), + Q.sortBy('created_at', Q.desc)); +}; + export const queryMyTeams = (database: Database) => { return database.get(MY_TEAM).query(); }; diff --git a/app/screens/home/search/modifiers/index.tsx b/app/screens/home/search/modifiers/index.tsx index 6bdaea776e..d9897c4835 100644 --- a/app/screens/home/search/modifiers/index.tsx +++ b/app/screens/home/search/modifiers/index.tsx @@ -3,7 +3,6 @@ import React, {useCallback, useMemo, useState} from 'react'; import {IntlShape, useIntl} from 'react-intl'; -import {View} from 'react-native'; import Animated, {useSharedValue, useAnimatedStyle, withTiming} from 'react-native-reanimated'; import FormattedText from '@components/formatted_text'; @@ -97,14 +96,12 @@ const getModifiersSectionsData = (intl: IntlShape): ModifierItem[] => { }; type Props = { - scrollPaddingTop: number; setSearchValue: (value: string) => void; searchValue?: string; } -const SearchModifiers = ({scrollPaddingTop, searchValue, setSearchValue}: Props) => { +const SearchModifiers = ({searchValue, setSearchValue}: Props) => { const theme = useTheme(); const intl = useIntl(); - const paddingTop = useMemo(() => ({paddingTop: scrollPaddingTop, flexGrow: 1}), [scrollPaddingTop]); const [showMore, setShowMore] = useState(false); const show = useSharedValue(3 * MODIFIER_LABEL_HEIGHT); @@ -137,7 +134,7 @@ const SearchModifiers = ({scrollPaddingTop, searchValue, setSearchValue}: Props) }; return ( - + <> - + ); }; diff --git a/app/screens/home/search/recent_searches/index.tsx b/app/screens/home/search/recent_searches/index.tsx new file mode 100644 index 0000000000..4417069a24 --- /dev/null +++ b/app/screens/home/search/recent_searches/index.tsx @@ -0,0 +1,27 @@ +// 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 compose from 'lodash/fp/compose'; + +import {queryTeamSearchHistoryByTeamId} from '@queries/servers/team'; + +import RecentSearches from './recent_searches'; + +import type {WithDatabaseArgs} from '@typings/database/database'; + +type EnhanceProps = WithDatabaseArgs & { + teamId: string; +} + +const enhance = withObservables(['teamId'], ({database, teamId}: EnhanceProps) => { + return { + recentSearches: queryTeamSearchHistoryByTeamId(database, teamId).observe(), + }; +}); + +export default compose( + withDatabase, + enhance, +)(RecentSearches); diff --git a/app/screens/home/search/recent_searches/recent_item.tsx b/app/screens/home/search/recent_searches/recent_item.tsx new file mode 100644 index 0000000000..d85102898d --- /dev/null +++ b/app/screens/home/search/recent_searches/recent_item.tsx @@ -0,0 +1,97 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import {Text, TouchableOpacity, View} from 'react-native'; + +import {removeSearchFromTeamSearchHistory} from '@actions/local/team'; +import CompassIcon from '@components/compass_icon'; +import MenuItem from '@components/menu_item'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +import type TeamSearchHistoryModel from '@typings/database/models/servers/team_search_history'; + +const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { + return { + container: { + marginVertical: -16, + paddingLeft: 20, + paddingRight: 6, + alignItems: 'center', + height: 48, + flexDirection: 'row', + }, + remove: { + height: 40, + width: 40, + alignItems: 'center', + justifyContent: 'center', + }, + term: { + flex: 1, + marginLeft: 16, + color: theme.centerChannelColor, + ...typography('Body', 200, 'Regular'), + }, + }; +}); + +export type RecentItemType = { + terms: string; + isOrSearch: boolean; +} + +type Props = { + setRecentValue: (value: string) => void; + item: TeamSearchHistoryModel; +} + +const RecentItem = ({item, setRecentValue}: Props) => { + const theme = useTheme(); + const style = getStyleFromTheme(theme); + const testID = 'search.recent_item'; + const serverUrl = useServerUrl(); + + const handlePress = useCallback(() => { + setRecentValue(item.term); + }, [item, setRecentValue]); + + const handleRemove = useCallback(async () => { + await removeSearchFromTeamSearchHistory(serverUrl, item.id); + }, [item.id]); + + return ( + + + {item.term} + + + + + } + separator={false} + theme={theme} + /> + ); +}; + +export default RecentItem; diff --git a/app/screens/home/search/recent_searches/recent_searches.tsx b/app/screens/home/search/recent_searches/recent_searches.tsx new file mode 100644 index 0000000000..d3ce6b4009 --- /dev/null +++ b/app/screens/home/search/recent_searches/recent_searches.tsx @@ -0,0 +1,80 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import {useIntl} from 'react-intl'; +import {FlatList, View} from 'react-native'; +import Animated from 'react-native-reanimated'; + +import FormattedText from '@components/formatted_text'; +import {useTheme} from '@context/theme'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +import RecentItem from './recent_item'; + +import type TeamSearchHistoryModel from '@typings/database/models/servers/team_search_history'; + +const AnimatedFlatList = Animated.createAnimatedComponent(FlatList); + +const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { + return { + divider: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.08), + height: 1, + marginVertical: 15, + marginHorizontal: 20, + }, + title: { + paddingHorizontal: 20, + paddingVertical: 12, + color: theme.centerChannelColor, + ...typography('Heading', 300, 'SemiBold'), + }, + }; +}); + +type Props = { + setRecentValue: (value: string) => void; + recentSearches: TeamSearchHistoryModel[]; +} + +const RecentSearches = ({setRecentValue, recentSearches}: Props) => { + const theme = useTheme(); + const {formatMessage} = useIntl(); + const styles = getStyleFromTheme(theme); + + const renderRecentItem = useCallback(({item}) => { + return ( + + ); + }, [setRecentValue]); + + const header = ( + <> + + + + ); + + return ( + + ); +}; + +export default RecentSearches; diff --git a/app/screens/home/search/results/results.tsx b/app/screens/home/search/results/results.tsx index 9da4ddde36..28c73862ed 100644 --- a/app/screens/home/search/results/results.tsx +++ b/app/screens/home/search/results/results.tsx @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import React, {useCallback, useMemo} from 'react'; -import {StyleSheet, FlatList, ListRenderItemInfo, NativeScrollEvent, NativeSyntheticEvent, StyleProp, View, ViewStyle} from 'react-native'; +import {StyleSheet, FlatList, ListRenderItemInfo, StyleProp, View, ViewStyle} from 'react-native'; import Animated from 'react-native-reanimated'; import File from '@components/files/file'; @@ -20,8 +20,6 @@ import {getDateForDateLine, isDateLine, selectOrderedPosts} from '@utils/post_li import {TabTypes, TabType} from '@utils/search'; import {preventDoubleTap} from '@utils/tap'; -import Loader from './loader'; - import type ChannelModel from '@typings/database/models/servers/channel'; import type PostModel from '@typings/database/models/servers/post'; @@ -40,31 +38,24 @@ type Props = { fileChannels: ChannelModel[]; fileInfos: FileInfo[]; 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 Results = ({ +const SearchResults = ({ canDownloadFiles, currentTimezone, fileChannels, fileInfos, isTimezoneEnabled, - loading, - onScroll, posts, publicLinkEnabled, scrollPaddingTop, - scrollRef, searchValue, selectedTab, }: Props) => { @@ -188,21 +179,16 @@ const Results = ({ ]); const noResults = useMemo(() => { - if (loading) { - return (); - } return ( ); - }, [searchValue, loading, selectedTab]); + }, [searchValue, selectedTab]); let data; - if (loading || !searchValue) { - data = emptyList; - } else if (selectedTab === TabTypes.MESSAGES) { + if (selectedTab === TabTypes.MESSAGES) { data = orderedPosts; } else { data = orderedFilesForGallery; @@ -220,13 +206,11 @@ const Results = ({ renderItem={renderItem} contentContainerStyle={paddingTop} nestedScrollEnabled={true} - onScroll={onScroll} removeClippedSubviews={true} - ref={scrollRef} style={containerStyle} testID='search_results.post_list.flat_list' /> ); }; -export default Results; +export default SearchResults; diff --git a/app/screens/home/search/search.tsx b/app/screens/home/search/search.tsx index 74a3c45b0b..9d2e863e61 100644 --- a/app/screens/home/search/search.tsx +++ b/app/screens/home/search/search.tsx @@ -2,32 +2,38 @@ // See LICENSE.txt for license information. import {useIsFocused, useNavigation} from '@react-navigation/native'; -import {debounce} from 'lodash'; -import React, {useCallback, useState, useEffect} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {useIntl} from 'react-intl'; import {FlatList, StyleSheet} from 'react-native'; import Animated, {useAnimatedStyle, withTiming} from 'react-native-reanimated'; import {Edge, SafeAreaView} from 'react-native-safe-area-context'; +import {addSearchToTeamSearchHistory} from '@actions/local/team'; import {searchPosts, searchFiles} from '@actions/remote/search'; import FreezeScreen from '@components/freeze_screen'; +import Loading from '@components/loading'; import NavigationHeader from '@components/navigation_header'; import RoundedHeaderContext from '@components/rounded_header_context'; import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; import {useCollapsibleHeader} from '@hooks/header'; import {FileFilter, FileFilters, filterFileExtensions} from '@utils/file'; import {TabTypes, TabType} from '@utils/search'; import Modifiers from './modifiers'; +import RecentSearches from './recent_searches'; import Results from './results'; import Header from './results/header'; const EDGES: Edge[] = ['bottom', 'left', 'right']; +const AnimatedFlatList = Animated.createAnimatedComponent(FlatList); const emptyFileResults: FileInfo[] = []; const emptyPostResults: string[] = []; const emptyChannelIds: string[] = []; +const dummyData = [1]; + type Props = { teamId: string; } @@ -36,16 +42,31 @@ const styles = StyleSheet.create({ flex: { flex: 1, }, + loading: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, }); +const getSearchParams = (terms: string, filterValue?: FileFilter) => { + const fileExtensions = filterFileExtensions(filterValue); + const extensionTerms = fileExtensions ? ' ' + fileExtensions : ''; + return { + terms: terms + extensionTerms, + is_or_search: true, + }; +}; + const SearchScreen = ({teamId}: Props) => { const nav = useNavigation(); const isFocused = useIsFocused(); const intl = useIntl(); + const theme = useTheme(); const searchScreenIndex = 1; const stateIndex = nav.getState().index; const serverUrl = useServerUrl(); - const {searchTerm} = nav.getState().routes[stateIndex].params; + const searchTerm = (nav.getState().routes[stateIndex].params as any)?.searchTerm; const [searchValue, setSearchValue] = useState(searchTerm); const [selectedTab, setSelectedTab] = useState(TabTypes.MESSAGES); @@ -59,62 +80,117 @@ const SearchScreen = ({teamId}: Props) => { const [fileInfos, setFileInfos] = useState(emptyFileResults); const [fileChannelIds, setFileChannelIds] = useState([]); - const getSearchParams = useCallback((filterValue?: FileFilter) => { - const terms = filterValue ? lastSearchedValue : searchValue; - const fileExtensions = filterFileExtensions(filterValue || filter); - const extensionTerms = fileExtensions ? ' ' + fileExtensions : ''; - return { - terms: terms + extensionTerms, - is_or_search: true, - }; - }, [filter, lastSearchedValue, searchValue]); - - const handleSearch = useCallback((debounce(async () => { - // execute the search for the text in the navigation text box - // handle recent searches - // - 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 [postResults, {files, channels}] = await Promise.all([ - searchPosts(serverUrl, searchParams), - searchFiles(serverUrl, teamId, searchParams), - ]); - - setFileInfos(files?.length ? files : emptyFileResults); - setPostIds(postResults?.order?.length ? postResults.order : emptyPostResults); - setFileChannelIds(channels?.length ? channels : emptyChannelIds); - setLoading(false); - })), [searchValue]); + const handleSearch = useRef<(term: string) => void>(); const onSnap = (offset: number) => { scrollRef.current?.scrollToOffset({offset, animated: true}); }; + const {scrollPaddingTop, scrollRef, scrollValue, onScroll, headerHeight, hideHeader} = useCollapsibleHeader(true, onSnap); + + const onSubmit = useCallback(() => { + handleSearch.current?.(searchValue); + }, [searchValue]); + + const handleClearSearch = useCallback(() => { + setSearchValue(''); + setLastSearchedValue(''); + setFilter(FileFilters.ALL); + }, []); + + const handleCancelSearch = useCallback(() => { + handleClearSearch(); + setShowResults(false); + }, [handleClearSearch, showResults]); + + useEffect(() => { + handleSearch.current = async (term: string) => { + const searchParams = getSearchParams(term); + if (!searchParams.terms) { + handleClearSearch(); + return; + } + setLoading(true); + setFilter(FileFilters.ALL); + setLastSearchedValue(term); + addSearchToTeamSearchHistory(serverUrl, teamId, term); + const [postResults, {files, channels}] = await Promise.all([ + searchPosts(serverUrl, searchParams), + searchFiles(serverUrl, teamId, searchParams), + ]); + + setFileInfos(files?.length ? files : emptyFileResults); + setPostIds(postResults?.order?.length ? postResults.order : emptyPostResults); + setFileChannelIds(channels?.length ? channels : emptyChannelIds); + + setShowResults(true); + setLoading(false); + }; + }, [teamId]); + + const handleRecentSearch = useCallback((text: string) => { + setSearchValue(text); + handleSearch.current?.(text); + }, []); + const handleFilterChange = useCallback(async (filterValue: FileFilter) => { setLoading(true); setFilter(filterValue); - const searchParams = getSearchParams(filterValue); + const searchParams = getSearchParams(lastSearchedValue, filterValue); const {files, channels} = await searchFiles(serverUrl, teamId, searchParams); setFileInfos(files?.length ? files : emptyFileResults); setFileChannelIds(channels?.length ? channels : emptyChannelIds); setLoading(false); - }, [lastSearchedValue]); + }, [getSearchParams, lastSearchedValue, searchFiles]); - useEffect(() => { - setSearchValue(searchTerm); - }, [searchTerm]); + const loadingComponent = useMemo(() => ( + + ), [theme, scrollPaddingTop]); - const {scrollPaddingTop, scrollRef, scrollValue, onScroll, headerHeight, hideHeader} = useCollapsibleHeader(true, onSnap); + const modifiersComponent = useMemo(() => ( + <> + + + + ), [searchValue, teamId, handleRecentSearch]); + + const resultsComponent = useMemo(() => ( + + ), [selectedTab, lastSearchedValue, postIds, fileInfos, scrollPaddingTop]); + + const renderItem = useCallback(() => { + if (loading) { + return loadingComponent; + } + if (!showResults) { + return modifiersComponent; + } + return resultsComponent; + }, [ + loading && loadingComponent, + !loading && !showResults && modifiersComponent, + !loading && showResults && resultsComponent, + ]); + + const paddingTop = useMemo(() => ({paddingTop: scrollPaddingTop, flexGrow: 1}), [scrollPaddingTop]); const animated = useAnimatedStyle(() => { if (isFocused) { @@ -139,15 +215,8 @@ const SearchScreen = ({teamId}: Props) => { }; }, [headerHeight, lastSearchedValue]); - const handleClearSearch = useCallback(() => { - setSearchValue(''); - setLastSearchedValue(''); - setFilter(FileFilters.ALL); - setShowResults(false); - }, []); - let header = null; - if (lastSearchedValue) { + if (lastSearchedValue && !loading) { header = (
{ scrollValue={scrollValue} hideHeader={hideHeader} onChangeText={setSearchValue} - onSubmitEditing={handleSearch} + onSubmitEditing={onSubmit} blurOnSubmit={true} placeholder={intl.formatMessage({id: 'screen.search.placeholder', defaultMessage: 'Search messages & files'})} onClear={handleClearSearch} + onCancel={handleCancelSearch} defaultValue={searchValue} /> { {header} - {!showResults && - - } - {showResults && - - } + diff --git a/app/utils/file/index.ts b/app/utils/file/index.ts index 516a49908a..eb8b4d1dc7 100644 --- a/app/utils/file/index.ts +++ b/app/utils/file/index.ts @@ -74,7 +74,7 @@ const SUPPORTED_VIDEO_FORMAT = Platform.select({ const types: Record = {}; const extensions: Record = {}; -export function filterFileExtensions(filter: FileFilter): string { +export function filterFileExtensions(filter?: FileFilter): string { let searchTerms: string[] = []; switch (filter) { case FileFilters.ALL: