[Gekidou - MM-44645] Search Screen - show results from server (#6314)

Co-authored-by: Daniel Espino García <larkox@gmail.com>
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
This commit is contained in:
Jason Frerich
2022-06-15 08:29:32 -05:00
committed by GitHub
parent 9370a9c54e
commit 196f922b6a
25 changed files with 784 additions and 279 deletions

View File

@@ -7,7 +7,6 @@ import NetworkManager from '@managers/network_manager';
import {prepareMissingChannelsForAllTeams} from '@queries/servers/channel';
import {getIsCRTEnabled, prepareThreadsFromReceivedPosts} from '@queries/servers/thread';
import {getCurrentUser} from '@queries/servers/user';
import {processPostsFetched} from '@utils/post';
import {fetchPostAuthors, fetchMissingChannelsFromPosts} from './post';
import {forceLogoutIfNecessary} from './session';
@@ -15,6 +14,14 @@ 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[];
@@ -22,25 +29,9 @@ type PostSearchRequest = {
}
export async function fetchRecentMentions(serverUrl: string): Promise<PostSearchRequest> {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
let posts: Record<string, Post> = {};
let postsArray: Post[] = [];
let order: string[] = [];
try {
const currentUser = await getCurrentUser(operator.database);
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const currentUser = await getCurrentUser(database);
if (!currentUser) {
return {
posts: [],
@@ -48,17 +39,15 @@ export async function fetchRecentMentions(serverUrl: string): Promise<PostSearch
};
}
const terms = currentUser.userMentionKeys.map(({key}) => key).join(' ').trim() + ' ';
const data = await client.searchPosts('', terms, true);
posts = data.posts || {};
order = data.order || [];
const results = await searchPosts(serverUrl, {terms, is_or_search: true});
if (results.error) {
throw results.error;
}
const promises: Array<Promise<Model[]>> = [];
postsArray = order.map((id) => posts[id]);
const mentions: IdValue = {
id: SYSTEM_IDENTIFIERS.RECENT_MENTIONS,
value: JSON.stringify(order),
value: JSON.stringify(results.order),
};
promises.push(operator.handleSystem({
@@ -66,6 +55,25 @@ export async function fetchRecentMentions(serverUrl: string): Promise<PostSearch
prepareRecordsOnly: true,
}));
return results;
} catch (error) {
return {error};
}
}
export const searchPosts = async (serverUrl: string, params: PostSearchParams): Promise<PostSearchRequest> => {
try {
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const client = NetworkManager.getClient(serverUrl);
let postsArray: Post[] = [];
const data = await client.searchPosts('', params.terms, params.is_or_search);
const posts = data.posts || {};
const order = data.order || [];
const promises: Array<Promise<Model[]>> = [];
postsArray = order.map((id) => posts[id]);
if (postsArray.length) {
const isCRTEnabled = await getIsCRTEnabled(operator.database);
if (isCRTEnabled) {
@@ -111,18 +119,19 @@ export async function fetchRecentMentions(serverUrl: string): Promise<PostSearch
});
await operator.batchRecords(models);
return {
order,
posts: postsArray,
};
} catch (error) {
// eslint-disable-next-line no-console
console.log('Failed: searchPosts', error);
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
};
return {
order,
posts: postsArray,
};
}
export const searchPosts = async (serverUrl: string, params: PostSearchParams): Promise<PostSearchRequest> => {
export const searchFiles = async (serverUrl: string, teamId: string, params: FileSearchParams): Promise<FileSearchRequest> => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
@@ -138,17 +147,11 @@ export const searchPosts = async (serverUrl: string, params: PostSearchParams):
let data;
try {
data = await client.searchPosts('', params.terms, params.is_or_search);
data = await client.searchFiles(teamId, params.terms);
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
const result = processPostsFetched(data);
await operator.handlePosts({
...result,
actionType: '',
});
return result;
return data;
};

View File

@@ -16,6 +16,8 @@ export interface ClientFilesMix {
onError: (response: ClientResponseError) => void,
skipBytes?: number,
) => () => void;
searchFiles: (teamId: string, terms: string) => Promise<any>;
searchFilesWithParams: (teamId: string, FileSearchParams: string) => Promise<any>;
}
const ClientFiles = (superclass: any) => class extends superclass {
@@ -75,6 +77,16 @@ const ClientFiles = (superclass: any) => class extends superclass {
promise.progress!(onProgress).then(onComplete).catch(onError);
return promise.cancel!;
};
searchFilesWithParams = async (teamId: string, params: FileSearchParams) => {
this.analytics.trackAPI('api_files_search');
const endpoint = teamId ? `${this.getTeamRoute(teamId)}/files/search` : `${this.getFilesRoute()}/search`;
return this.doFetch(endpoint, {method: 'post', body: params});
};
searchFiles = async (teamId: string, terms: string, isOrSearch: boolean) => {
return this.searchFilesWithParams(teamId, {terms, is_or_search: isOrSearch});
};
};
export default ClientFiles;

View File

@@ -26,7 +26,7 @@ export interface ClientPostsMix {
removeReaction: (userId: string, postId: string, emojiName: string) => Promise<any>;
getReactionsForPost: (postId: string) => Promise<any>;
searchPostsWithParams: (teamId: string, params: PostSearchParams) => Promise<any>;
searchPosts: (teamId: string, terms: string, isOrSearch: boolean) => Promise<any>;
searchPosts: (teamId: string, terms: string, isOrSearch: boolean) => Promise<PostResponse>;
doPostAction: (postId: string, actionId: string, selectedOption?: string) => Promise<any>;
doPostActionWithCookie: (postId: string, actionId: string, actionCookie: string, selectedOption?: string) => Promise<any>;
}

View File

@@ -8,11 +8,11 @@ import {
} from 'react-native';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import {typography} from '@app/utils/typography';
import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
import {typography} from '@utils/typography';
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return {

View File

@@ -5,7 +5,6 @@ import React from 'react';
import {IntlShape, useIntl} from 'react-intl';
import {StyleProp, Text, View, ViewStyle} from 'react-native';
import {typography} from '@app/utils/typography';
import ChannelIcon from '@components/channel_icon';
import CustomStatusEmoji from '@components/custom_status/custom_status_emoji';
import FormattedText from '@components/formatted_text';
@@ -14,6 +13,7 @@ import {BotTag, GuestTag} from '@components/tag';
import {General} from '@constants';
import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
import {typography} from '@utils/typography';
import {getUserCustomStatus, isBot, isGuest, isShared} from '@utils/user';
import type UserModel from '@typings/database/models/servers/user';

View File

@@ -30,17 +30,18 @@ export const VALID_IMAGE_MIME_TYPES = [
const Files: Record<string, string[]> = {
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', 'tex', 'vbnet', 'vb', 'bas', 'vbs', 'v', 'veo', 'xml', 'html', 'xhtml', 'rss', 'atom', 'xsl', 'plist', 'yaml'],
IMAGE_TYPES: ['jpg', 'gif', 'bmp', 'png', 'jpeg', 'tiff', 'tif'],
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'],
PATCH_TYPES: ['patch'],
PDF_TYPES: ['pdf'],
PRESENTATION_TYPES: ['ppt', 'pptx'],
SPREADSHEET_TYPES: ['xlsx', 'csv'],
PRESENTATION_TYPES: ['ppt', 'pptx', 'odp'],
SPREADSHEET_TYPES: ['xls, xlsx', 'csv', 'ods'],
TEXT_TYPES: ['txt', 'rtf'],
VIDEO_TYPES: ['mp4', 'avi', 'webm', 'mkv', 'wmv', 'mpg', 'mov', 'flv'],
WORD_TYPES: ['doc', 'docx'],
VIDEO_TYPES: ['mp4', 'avi', 'webm', 'mkv', 'wmv', 'mpg', 'mov', 'flv', 'ogm', 'mpeg'],
WORD_TYPES: ['doc', 'docx', 'odt'],
ZIP_TYPES: ['zip'],
};
Files.DOCUMENT_TYPES = Files.WORD_TYPES.concat(Files.PDF_TYPES, Files.TEXT_TYPES);
export const PROGRESS_TIME_TO_STORE = 60000; // 60 * 1000 (60s)

View File

@@ -2,56 +2,48 @@
// See LICENSE.txt for license information.
import React from 'react';
import {GestureResponderEvent, Text, View} from 'react-native';
import {GestureResponderEvent, StyleSheet, Text, View} from 'react-native';
import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles';
import {changeOpacity} from '@utils/theme';
type Props = {
disabled?: boolean;
onPress?: (e: GestureResponderEvent) => void;
icon?: string;
testID?: string;
text?: string;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
button: {
backgroundColor: theme.buttonBg,
display: 'flex',
flexDirection: 'row',
paddingVertical: 14,
paddingHorizontal: 24,
borderRadius: 4,
alignItems: 'center',
justifyContent: 'center',
height: 48,
},
text: {
color: theme.buttonColor,
paddingHorizontal: 8,
...typography('Body', 200, 'SemiBold'),
},
icon_container: {
width: 24,
height: 24,
marginTop: 2,
},
};
const styles = StyleSheet.create({
button: {
display: 'flex',
flexDirection: 'row',
},
icon_container: {
width: 24,
height: 24,
marginTop: 2,
},
});
export default function BottomSheetButton({onPress, icon, testID, text}: Props) {
export default function BottomSheetButton({disabled = false, onPress, icon, testID, text}: Props) {
const theme = useTheme();
const styles = getStyleSheet(theme);
const buttonType = disabled ? 'disabled' : 'default';
const styleButtonText = buttonTextStyle(theme, 'lg', 'primary', buttonType);
const styleButtonBackground = buttonBackgroundStyle(theme, 'lg', 'primary', buttonType);
const iconColor = disabled ? changeOpacity(theme.centerChannelColor, 0.32) : theme.buttonColor;
return (
<TouchableWithFeedback
onPress={onPress}
type='opacity'
style={styles.button}
style={[styles.button, styleButtonBackground]}
testID={testID}
>
{icon && (
@@ -59,13 +51,13 @@ export default function BottomSheetButton({onPress, icon, testID, text}: Props)
<CompassIcon
size={24}
name={icon}
color={theme.buttonColor}
color={iconColor}
/>
</View>
)}
{text && (
<Text
style={styles.text}
style={styleButtonText}
>{text}</Text>
)}

View File

@@ -14,11 +14,13 @@ type Props = {
buttonIcon?: string;
buttonText?: string;
children: React.ReactNode;
disableButton?: boolean;
onPress?: (e: GestureResponderEvent) => void;
showButton: boolean;
showTitle: boolean;
testID?: string;
title?: string;
titleSeparator?: boolean;
}
export const TITLE_HEIGHT = 38;
@@ -45,7 +47,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
const BottomSheetContent = ({buttonText, buttonIcon, children, onPress, showButton, showTitle, testID, title}: Props) => {
const BottomSheetContent = ({buttonText, buttonIcon, children, disableButton, onPress, showButton, showTitle, testID, title, titleSeparator}: Props) => {
const dimensions = useWindowDimensions();
const theme = useTheme();
const isTablet = useIsTablet();
@@ -68,6 +70,9 @@ const BottomSheetContent = ({buttonText, buttonIcon, children, onPress, showButt
</Text>
</View>
}
{titleSeparator &&
<View style={[styles.separator, {width: separatorWidth, marginBottom: (isTablet ? 20 : 12)}]}/>
}
<>
{children}
</>
@@ -75,6 +80,7 @@ const BottomSheetContent = ({buttonText, buttonIcon, children, onPress, showButt
<>
<View style={[styles.separator, {width: separatorWidth, marginBottom: (isTablet ? 20 : 12)}]}/>
<Button
disabled={disableButton}
onPress={onPress}
icon={buttonIcon}
testID={buttonTestId}

View File

@@ -5,9 +5,9 @@ import React from 'react';
import {useIntl} from 'react-intl';
import {Platform} from 'react-native';
import {goToScreen} from '@app/screens/navigation';
import OptionItem from '@components/option_item';
import {Screens} from '@constants';
import {goToScreen} from '@screens/navigation';
import {preventDoubleTap} from '@utils/tap';
type Props = {

View File

@@ -5,10 +5,10 @@ import React from 'react';
import {useIntl} from 'react-intl';
import {Platform} from 'react-native';
import {t} from '@app/i18n';
import {goToScreen} from '@app/screens/navigation';
import OptionItem from '@components/option_item';
import {NotificationLevel, Screens} from '@constants';
import {t} from '@i18n';
import {goToScreen} from '@screens/navigation';
import {preventDoubleTap} from '@utils/tap';
type Props = {

View File

@@ -5,10 +5,10 @@ import React from 'react';
import {useIntl} from 'react-intl';
import {Platform} from 'react-native';
import {goToScreen} from '@app/screens/navigation';
import OptionItem from '@components/option_item';
import {Screens} from '@constants';
import {useTheme} from '@context/theme';
import {goToScreen} from '@screens/navigation';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity} from '@utils/theme';

View File

@@ -4,8 +4,8 @@
import React from 'react';
import {Text, View} from 'react-native';
import {BotTag, GuestTag} from '@app/components/tag';
import ProfilePicture from '@components/profile_picture';
import {BotTag, GuestTag} from '@components/tag';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';

View File

@@ -6,9 +6,9 @@ import {FlatList} from 'react-native';
import Animated, {Easing, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
import {fetchDirectChannelsInfo} from '@actions/remote/channel';
import {useServerUrl} from '@app/context/server';
import ChannelItem from '@components/channel_item';
import {DMS_CATEGORY} from '@constants/categories';
import {useServerUrl} from '@context/server';
import {isDMorGM} from '@utils/channel';
import type CategoryModel from '@typings/database/models/servers/category';

View File

@@ -20,10 +20,9 @@ import Account from './account';
import ChannelList from './channel_list';
import RecentMentions from './recent_mentions';
import SavedMessages from './saved_messages';
import Search from './search';
import TabBar from './tab_bar';
// import Search from './search';
import type {LaunchProps} from '@typings/launch';
if (Platform.OS === 'ios') {
@@ -118,11 +117,11 @@ export default function HomeScreen(props: HomeProps) {
>
{() => <ChannelList {...props}/>}
</Tab.Screen>
{/* <Tab.Screen
<Tab.Screen
name={Screens.SEARCH}
component={Search}
options={{unmountOnBlur: false, lazy: true, tabBarTestID: 'tab_bar.search.tab'}}
/> */}
/>
<Tab.Screen
name={Screens.MENTIONS}
component={RecentMentions}

View File

@@ -1,151 +1,24 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useIsFocused, useNavigation} from '@react-navigation/native';
import React, {useCallback, useState, useEffect, useMemo} from 'react';
import {useIntl} from 'react-intl';
import {ScrollView} from 'react-native';
import Animated, {useAnimatedStyle, withTiming} from 'react-native-reanimated';
import {Edge, SafeAreaView} from 'react-native-safe-area-context';
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import compose from 'lodash/fp/compose';
import FreezeScreen from '@components/freeze_screen';
import NavigationHeader from '@components/navigation_header';
import RoundedHeaderContext from '@components/rounded_header_context';
import {useCollapsibleHeader} from '@hooks/header';
import {observeCurrentTeamId} from '@queries/servers/system';
// import RecentSearches from './recent_searches/recent_searches';
// import SearchModifiers from './search_modifiers/search_modifiers';
// import Filter from './results/filter';
import Header, {SelectTab} from './results/header';
import Results from './results/results';
import SearchScreen from './search';
const AnimatedScrollView = Animated.createAnimatedComponent(ScrollView);
import type {WithDatabaseArgs} from '@typings/database/database';
const EDGES: Edge[] = ['bottom', 'left', 'right'];
const SearchScreen = () => {
const nav = useNavigation();
const isFocused = useIsFocused();
const intl = useIntl();
const searchScreenIndex = 1;
const stateIndex = nav.getState().index;
const {searchTerm} = nav.getState().routes[stateIndex].params;
const [searchValue, setSearchValue] = useState<string>(searchTerm);
const [selectedTab, setSelectedTab] = useState<string>('messages');
const handleSearch = () => {
// 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??
// console.log('execute the search for : ', searchValue);
const enhance = withObservables([], ({database}: WithDatabaseArgs) => {
const currentTeamId = observeCurrentTeamId(database);
return {
teamId: currentTeamId,
};
});
const onSnap = (y: number) => {
scrollRef.current?.scrollTo({y, animated: true});
};
useEffect(() => {
setSearchValue(searchTerm);
}, [searchTerm]);
const {scrollPaddingTop, scrollRef, scrollValue, onScroll, headerHeight, hideHeader} = useCollapsibleHeader<ScrollView>(true, onSnap);
const paddingTop = useMemo(() => ({paddingTop: scrollPaddingTop, flexGrow: 1}), [scrollPaddingTop]);
const animated = useAnimatedStyle(() => {
if (isFocused) {
return {
opacity: withTiming(1, {duration: 150}),
flex: 1,
transform: [
{translateX: withTiming(0, {duration: 150})},
],
};
}
return {
opacity: withTiming(0, {duration: 150}),
transform: [{translateX: withTiming(stateIndex < searchScreenIndex ? 25 : -25, {duration: 150})}],
};
}, [isFocused, stateIndex, scrollPaddingTop]);
const top = useAnimatedStyle(() => {
return {
top: headerHeight.value,
zIndex: searchValue ? 10 : 0,
};
}, [searchValue]);
const onHeaderTabSelect = useCallback((tab: SelectTab) => {
setSelectedTab(tab);
}, [setSelectedTab]);
return (
<FreezeScreen freeze={!isFocused}>
<NavigationHeader
isLargeTitle={true}
onBackPress={() => {
// eslint-disable-next-line no-console
console.log('BACK');
}}
showBackButton={false}
title={intl.formatMessage({id: 'screen.search.title', defaultMessage: 'Search'})}
hasSearch={true}
scrollValue={scrollValue}
hideHeader={hideHeader}
onChangeText={setSearchValue}
onSubmitEditing={handleSearch}
blurOnSubmit={true}
placeholder={intl.formatMessage({id: 'screen.search.placeholder', defaultMessage: 'Search messages & files'})}
defaultValue={searchValue}
/>
<SafeAreaView
style={{flex: 1}}
edges={EDGES}
>
<Animated.View style={animated}>
<Animated.View style={top}>
<RoundedHeaderContext/>
{Boolean(searchValue) &&
<Header
onTabSelect={onHeaderTabSelect}
numberFiles={0}
numberMessages={0}
/>
}
</Animated.View>
<AnimatedScrollView
contentContainerStyle={paddingTop}
nestedScrollEnabled={true}
scrollToOverflowEnabled={true}
showsVerticalScrollIndicator={false}
indicatorStyle='black'
onScroll={onScroll}
scrollEventThrottle={16}
removeClippedSubviews={true}
ref={scrollRef}
>
{/* <SearchModifiers */}
{/* setSearchValue={setSearchValue} */}
{/* searchValue={searchValue} */}
{/* /> */}
{/* <RecentSearches */}
{/* setSearchValue={setSearchValue} */}
{/* /> */}
<Results
selectedTab={selectedTab}
searchValue={searchValue}
/>
{/* <Filter/> */}
</AnimatedScrollView>
</Animated.View>
</SafeAreaView>
</FreezeScreen>
);
};
export default SearchScreen;
export default compose(
withDatabase,
enhance,
)(SearchScreen);

View File

@@ -0,0 +1,53 @@
// 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 (
<View style={[style.container, style.border]}>
<Text style={style.message}>{'To be implemented'}</Text>
<Text style={style.message}>{`Name: ${fileInfo.name}`}</Text>
<Text style={style.message}>{`Size: ${fileInfo.size}`}</Text>
<FileIcon/>
</View>
);
}

View File

@@ -0,0 +1,156 @@
// 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} from 'react-native';
import {FlatList} from 'react-native-gesture-handler';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import MenuItem from '@components/menu_item';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {t} from '@i18n';
import BottomSheetContent from '@screens/bottom_sheet/content';
import {dismissBottomSheet} from '@screens/navigation';
import {FileFilter} from '@utils/file';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
labelContainer: {
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'space-between',
},
menuText: {
color: theme.centerChannelColor,
...typography('Body', 200, 'Regular'),
},
};
});
type FilterItem = {
id: string;
defaultMessage: string;
filterType: FileFilter;
separator?: boolean;
}
const data: FilterItem[] = [
{
id: t('screen.search.results.filter.all_file_types'),
defaultMessage: 'All file types',
filterType: 'all',
}, {
id: t('screen.search.results.filter.documents'),
defaultMessage: 'Documents',
filterType: 'documents',
}, {
id: t('screen.search.results.filter.spreadsheets'),
defaultMessage: 'Spreadsheets',
filterType: 'spreadsheets',
}, {
id: t('screen.search.results.filter.presentations'),
defaultMessage: 'Presentations',
filterType: 'presentations',
}, {
id: t('screen.search.results.filter.code'),
defaultMessage: 'Code',
filterType: 'code',
}, {
id: t('screen.search.results.filter.images'),
defaultMessage: 'Images',
filterType: 'images',
}, {
id: t('screen.search.results.filter.audio'),
defaultMessage: 'Audio',
filterType: 'audio',
}, {
id: t('screen.search.results.filter.videos'),
defaultMessage: 'Videos',
filterType: 'videos',
separator: false,
},
];
type FilterProps = {
initialFilter: FileFilter;
setFilter: (filter: FileFilter) => void;
}
const Filter = ({initialFilter, setFilter}: FilterProps) => {
const intl = useIntl();
const theme = useTheme();
const style = getStyleSheet(theme);
const isTablet = useIsTablet();
const [selectedFilter, setSelectedFilter] = useState<FileFilter>(initialFilter);
const disableButton = selectedFilter === initialFilter;
const renderLabelComponent = useCallback((item: FilterItem) => {
return (
<View style={style.labelContainer}>
<FormattedText
style={style.menuText}
id={item.id}
defaultMessage={item.defaultMessage}
/>
{(selectedFilter === item.filterType) && (
<CompassIcon
style={style.selected}
name={'check'}
size={24}
/>
)}
</View>
);
}, [selectedFilter, style]);
const renderFilterItem = useCallback(({item}: {item: FilterItem}) => {
return (
<MenuItem
labelComponent={renderLabelComponent(item)}
onPress={() => {
setSelectedFilter(item.filterType);
}}
separator={item.separator}
testID={item.id}
theme={theme}
/>
);
}, [renderLabelComponent, theme]);
const handleShowResults = useCallback(() => {
setFilter(selectedFilter);
dismissBottomSheet();
}, [selectedFilter, setFilter]);
const buttonText = intl.formatMessage({id: 'screen.search.results.filter.show_button', defaultMessage: 'Show results'});
const buttonTitle = intl.formatMessage({id: 'screen.search.results.filter.title', defaultMessage: 'Filter by file type'});
return (
<BottomSheetContent
buttonText={buttonText}
onPress={handleShowResults}
disableButton={disableButton}
showButton={true}
showTitle={!isTablet}
testID='search.filters'
title={buttonTitle}
titleSeparator={true}
>
<View style={style.container}>
<FlatList
data={data}
renderItem={renderFilterItem}
contentContainerStyle={style.contentContainer}
/>
</View>
</BottomSheetContent>
);
};
export default Filter;

View File

@@ -1,26 +1,37 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useState} from 'react';
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {View} from 'react-native';
import Badge from '@components/badge';
import CompassIcon from '@components/compass_icon';
import {useTheme} from '@context/theme';
import {bottomSheet} from '@screens/navigation';
import {FileFilter} from '@utils/file';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import Filter from './filter';
import SelectButton from './header_button';
export type SelectTab = 'files' | 'messages'
type Props = {
onTabSelect: (tab: SelectTab) => void;
numberFiles: number;
onFilterChanged: (filter: FileFilter) => void;
selectedTab: SelectTab;
selectedFilter: FileFilter;
numberMessages: number;
numberFiles: number;
}
export const HEADER_HEIGHT = 64;
const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
return {
flex: {
flex: 1,
},
container: {
backgroundColor: theme.centerChannelBg,
marginHorizontal: 12,
@@ -28,6 +39,11 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
paddingVertical: 12,
flexGrow: 0,
height: HEADER_HEIGHT,
alignItems: 'center',
},
filter: {
marginRight: 12,
marginLeft: 'auto',
},
divider: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
@@ -36,7 +52,14 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
};
});
const Header = ({onTabSelect, numberFiles, numberMessages}: Props) => {
const Header = ({
onTabSelect,
onFilterChanged,
numberMessages,
numberFiles,
selectedTab,
selectedFilter,
}: Props) => {
const theme = useTheme();
const styles = getStyleFromTheme(theme);
const intl = useIntl();
@@ -44,31 +67,69 @@ const Header = ({onTabSelect, numberFiles, numberMessages}: Props) => {
const messagesText = intl.formatMessage({id: 'screen.search.header.messages', defaultMessage: 'Messages'});
const filesText = intl.formatMessage({id: 'screen.search.header.files', defaultMessage: 'Files'});
const [tab, setTab] = useState(0);
const showFilterIcon = selectedTab === 'files';
const hasFilters = selectedFilter !== 'all';
const handleMessagesPress = useCallback(() => {
onTabSelect('messages');
setTab(0);
}, [onTabSelect]);
const handleFilesPress = useCallback(() => {
onTabSelect('files');
setTab(1);
}, [onTabSelect]);
const handleFilterPress = useCallback(() => {
const renderContent = () => {
return (
<Filter
initialFilter={selectedFilter}
setFilter={onFilterChanged}
/>
);
};
bottomSheet({
closeButtonId: 'close-search-filters',
renderContent,
snapPoints: [700, 10],
theme,
title: intl.formatMessage({id: 'mobile.add_team.join_team', defaultMessage: 'Join Another Team'}),
});
}, [selectedFilter]);
return (
<>
<View style={styles.container}>
<SelectButton
selected={tab === 0}
selected={selectedTab === 'messages'}
onPress={handleMessagesPress}
text={`${messagesText} (${numberMessages})`}
/>
<SelectButton
selected={tab === 1}
selected={selectedTab === 'files'}
onPress={handleFilesPress}
text={`${filesText} (${numberFiles})`}
/>
<View
style={styles.filter}
>
{showFilterIcon &&
<>
<CompassIcon
name={'filter-variant'}
size={24}
color={changeOpacity(theme.centerChannelColor, 0.56)}
onPress={handleFilterPress}
/>
<Badge
borderColor={theme.buttonBg}
backgroundColor={theme.buttonBg}
visible={hasFilters}
testID={'search.filters.badge'}
value={-1}
/>
</>
}
</View>
</View>
<View style={styles.divider}/>
</>

View File

@@ -0,0 +1,37 @@
// 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 {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {queryPostsById} from '@queries/servers/post';
import {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';
type enhancedProps = WithDatabaseArgs & {
postIds: string[];
}
const enhance = withObservables(['postIds'], ({database, postIds}: enhancedProps) => {
const posts = queryPostsById(database, postIds).observe();
const currentUser = observeCurrentUser(database);
return {
currentTimezone: currentUser.pipe((switchMap((user) => of$(getTimezone(user?.timezone || null))))),
isTimezoneEnabled: observeConfigBooleanValue(database, 'ExperimentalTimezone'),
posts,
};
});
export default compose(
withDatabase,
enhance,
)(Results);

View File

@@ -0,0 +1,30 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {StyleSheet} from 'react-native';
import Loading from '@components/loading';
import {useTheme} from '@context/theme';
const styles = StyleSheet.create({
loading: {
position: 'absolute',
margin: -18,
top: '50%',
left: '50%',
},
});
const Loader = () => {
const theme = useTheme();
return (
<Loading
containerStyle={styles.loading}
color={theme.buttonBg}
size='large'
/>
);
};
export default Loader;

View File

@@ -1,18 +1,20 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState} from 'react';
import {Text, View} from 'react-native';
import React, {useCallback, useMemo} from 'react';
import {FlatList, ListRenderItemInfo, NativeScrollEvent, NativeSyntheticEvent, Text, View} from 'react-native';
import Animated from 'react-native-reanimated';
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 {getDateForDateLine, isDateLine, selectOrderedPosts} from '@utils/post_list';
type Props = {
searchValue: string;
selectedTab: string;
}
const emptyPostResults: Post[] = [];
const emptyFilesResults: FileInfo[] = [];
import FileCard from './fileCard';
import Loader from './loader';
const notImplementedComponent = (
<View
@@ -26,45 +28,113 @@ const notImplementedComponent = (
</View>
);
const AnimatedFlatList = Animated.createAnimatedComponent(FlatList);
type Props = {
searchValue: string;
selectedTab: 'messages' | 'files';
currentTimezone: string;
isTimezoneEnabled: boolean;
posts: PostModel[];
fileInfos: FileInfo[];
scrollRef: React.RefObject<FlatList>;
onScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
scrollPaddingTop: number;
loading: boolean;
}
const emptyList: FileInfo[] | Array<string | PostModel> = [];
const SearchResults = ({
currentTimezone,
fileInfos,
isTimezoneEnabled,
posts,
searchValue,
selectedTab,
scrollRef,
onScroll,
scrollPaddingTop,
loading,
}: Props) => {
const [postResults] = useState<Post[]>(emptyPostResults);
const [fileResults] = useState<FileInfo[]>(emptyFilesResults);
const [loading] = useState(false);
const theme = useTheme();
const paddingTop = useMemo(() => ({paddingTop: scrollPaddingTop, flexGrow: 1}), [scrollPaddingTop]);
let content;
if (loading) {
content = notImplementedComponent;
} else if (!searchValue) {
content = notImplementedComponent;
} else if (
(selectedTab === 'messages' && postResults.length === 0) ||
(selectedTab === 'files' && fileResults.length === 0)
) {
content = (
<View
style={{
height: 800,
flexGrow: 1,
alignItems: 'center',
}}
>
const orderedPosts = useMemo(() => selectOrderedPosts(posts, 0, false, '', '', false, isTimezoneEnabled, currentTimezone, false).reverse(), [posts]);
const renderItem = useCallback(({item}: ListRenderItemInfo<string|FileInfo | Post>) => {
if (typeof item === 'string') {
if (isDateLine(item)) {
return (
<DateSeparator
date={getDateForDateLine(item)}
theme={theme}
timezone={isTimezoneEnabled ? currentTimezone : null}
/>
);
}
return null;
}
if ('message' in item) {
return (
<PostWithChannelInfo
location={Screens.SEARCH}
post={item}
/>
);
}
return (
<FileCard
fileInfo={item}
key={item.id}
/>
);
}, [theme]);
const noResults = useMemo(() => {
if (searchValue) {
if (loading) {
return (<Loader/>);
}
return (
<NoResultsWithTerm
term={searchValue}
type={selectedTab}
/>
</View>
);
);
}
return notImplementedComponent;
}, [searchValue, loading, selectedTab]);
let data;
if (loading || !searchValue) {
data = emptyList;
} else if (selectedTab === 'messages') {
data = orderedPosts;
} else {
content = notImplementedComponent;
data = fileInfos;
}
return (<>
{content}
</>);
return (
<AnimatedFlatList
ListEmptyComponent={noResults}
data={data}
scrollToOverflowEnabled={true}
showsVerticalScrollIndicator={true}
scrollEventThrottle={16}
indicatorStyle='black'
refreshing={false}
renderItem={renderItem}
contentContainerStyle={paddingTop}
nestedScrollEnabled={true}
onScroll={onScroll}
removeClippedSubviews={true}
ref={scrollRef}
/>
);
};
export default SearchResults;

View File

@@ -0,0 +1,179 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// 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 {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 {searchPosts, searchFiles} from '@actions/remote/search';
import FreezeScreen from '@components/freeze_screen';
import NavigationHeader from '@components/navigation_header';
import RoundedHeaderContext from '@components/rounded_header_context';
import {useServerUrl} from '@context/server';
import {useCollapsibleHeader} from '@hooks/header';
import {FileFilter, filterFiles} from '@utils/file';
import Results from './results';
import Header, {SelectTab} from './results/header';
const EDGES: Edge[] = ['bottom', 'left', 'right'];
const emptyFileResults: FileInfo[] = [];
const emptyPostResults: string[] = [];
type Props = {
teamId: string;
}
const styles = StyleSheet.create({
flex: {
flex: 1,
},
});
const SearchScreen = ({teamId}: Props) => {
const nav = useNavigation();
const isFocused = useIsFocused();
const intl = useIntl();
const searchScreenIndex = 1;
const stateIndex = nav.getState().index;
const serverUrl = useServerUrl();
const {searchTerm} = nav.getState().routes[stateIndex].params;
const [searchValue, setSearchValue] = useState<string>(searchTerm);
const [selectedTab, setSelectedTab] = useState<SelectTab>('messages');
const [filter, setFilter] = useState<FileFilter>('all');
const [loading, setLoading] = useState(false);
const [lastSearchedValue, setLastSearchedValue] = useState('');
const [postIds, setPostIds] = useState<string[]>(emptyPostResults);
const [fileInfos, setFileInfos] = useState<FileInfo[]>(emptyFileResults);
const [filteredFileInfos, setFilteredFileInfos] = useState<FileInfo[]>(emptyFileResults);
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??
setLoading(true);
setLastSearchedValue(searchValue);
const searchParams: PostSearchParams | FileSearchParams = {
terms: searchValue,
is_or_search: true,
};
const [postResults, fileResults] = 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);
setPostIds(postResults?.order?.length ? postResults.order : emptyPostResults);
setLoading(false);
})), [searchValue]);
const onSnap = (offset: number) => {
scrollRef.current?.scrollToOffset({offset, animated: true});
};
useEffect(() => {
setSearchValue(searchTerm);
}, [searchTerm]);
useEffect(() => {
setFilteredFileInfos(filterFiles(fileInfos, filter));
}, [filter, fileInfos]);
const {scrollPaddingTop, scrollRef, scrollValue, onScroll, headerHeight, hideHeader} = useCollapsibleHeader<FlatList>(true, onSnap);
const animated = useAnimatedStyle(() => {
if (isFocused) {
return {
opacity: withTiming(1, {duration: 150}),
flex: 1,
transform: [{translateX: withTiming(0, {duration: 150})}],
};
}
return {
opacity: withTiming(0, {duration: 150}),
transform: [{translateX: withTiming(stateIndex < searchScreenIndex ? 25 : -25, {duration: 150})}],
};
}, [isFocused, stateIndex]);
const top = useAnimatedStyle(() => {
return {
top: headerHeight.value,
zIndex: lastSearchedValue ? 10 : 0,
};
}, [headerHeight, lastSearchedValue]);
let header = null;
if (lastSearchedValue) {
header = (
<Header
onTabSelect={setSelectedTab}
onFilterChanged={setFilter}
numberMessages={postIds.length}
selectedTab={selectedTab}
numberFiles={Object.keys(filteredFileInfos).length}
selectedFilter={filter}
/>
);
}
return (
<FreezeScreen freeze={!isFocused}>
<NavigationHeader
isLargeTitle={true}
onBackPress={() => {
// eslint-disable-next-line no-console
console.log('BACK');
}}
showBackButton={false}
title={intl.formatMessage({id: 'screen.search.title', defaultMessage: 'Search'})}
hasSearch={true}
scrollValue={scrollValue}
hideHeader={hideHeader}
onChangeText={setSearchValue}
onSubmitEditing={handleSearch}
blurOnSubmit={true}
placeholder={intl.formatMessage({id: 'screen.search.placeholder', defaultMessage: 'Search messages & files'})}
defaultValue={searchValue}
/>
<SafeAreaView
style={styles.flex}
edges={EDGES}
>
<Animated.View style={animated}>
<Animated.View style={top}>
<RoundedHeaderContext/>
{header}
</Animated.View>
<Results
selectedTab={selectedTab}
searchValue={lastSearchedValue}
postIds={postIds}
fileInfos={filteredFileInfos}
scrollRef={scrollRef}
onScroll={onScroll}
scrollPaddingTop={scrollPaddingTop}
loading={loading}
/>
</Animated.View>
</SafeAreaView>
</FreezeScreen>
);
};
export default SearchScreen;

View File

@@ -24,6 +24,8 @@ const EXTRACT_TYPE_REGEXP = /^\s*([^;\s]*)(?:;|\s|$)/;
const CONTENT_DISPOSITION_REGEXP = /inline;filename=".*\.([a-z]+)";/i;
const DEFAULT_SERVER_MAX_FILE_SIZE = 50 * 1024 * 1024;// 50 Mb
export type FileFilter = 'all' | 'documents' | 'spreadsheets'| 'presentations' | 'code' | 'images' | 'audio' | 'videos'
export const GENERAL_SUPPORTED_DOCS_FORMAT = [
'application/json',
'application/msword',
@@ -58,6 +60,22 @@ const SUPPORTED_VIDEO_FORMAT = Platform.select({
const types: Record<string, string> = {};
const extensions: Record<string, readonly string[]> = {};
export function filterFiles<T extends FileModel | FileInfo>(files: T[], filter: FileFilter) {
switch (filter) {
case 'all':
return files;
case 'videos':
return files.filter((f) => isVideo(f));
case 'documents':
return files.filter((f) => isDocument(f));
case 'images':
return files.filter((f) => isImage(f));
default:
// TODO create the rest of the filters
return files.filter((f) => !isVideo(f) && !isDocument(f) && !isImage(f));
}
}
/**
* Populate the extensions and types maps.
* @private

View File

@@ -637,6 +637,16 @@
"screen.search.header.messages": "Messages",
"screen.search.placeholder": "Search messages & files",
"screen.search.title": "Search",
"screen.search.results.filter.all_file_types": "All file types",
"screen.search.results.filter.audio": "Audio",
"screen.search.results.filter.code": "Code",
"screen.search.results.filter.documents": "Documents",
"screen.search.results.filter.images": "Images",
"screen.search.results.filter.presentations": "Presentations",
"screen.search.results.filter.spreadsheets": "Spreadsheets",
"screen.search.results.filter.videos": "Videos",
"screen.search.results.filter.show_button": "Show results",
"screen.search.results.filter.title": "Filter by file type",
"screens.channel_edit": "Edit Channel",
"screens.channel_edit_header": "Edit Channel Header",
"screens.channel_info": "Channel Info",

View File

@@ -33,3 +33,8 @@ type FileUploadResponse = {
file_infos: FileInfo[];
client_ids: string[];
};
type FileSearchParams = {
terms: string;
is_or_search: boolean;
};