forked from Ivasoft/mattermost-mobile
[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:
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
53
app/screens/home/search/results/fileCard.tsx
Normal file
53
app/screens/home/search/results/fileCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
156
app/screens/home/search/results/filter.tsx
Normal file
156
app/screens/home/search/results/filter.tsx
Normal 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;
|
||||
@@ -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}/>
|
||||
</>
|
||||
|
||||
37
app/screens/home/search/results/index.tsx
Normal file
37
app/screens/home/search/results/index.tsx
Normal 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);
|
||||
|
||||
30
app/screens/home/search/results/loader.tsx
Normal file
30
app/screens/home/search/results/loader.tsx
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
179
app/screens/home/search/search.tsx
Normal file
179
app/screens/home/search/search.tsx
Normal 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;
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
5
types/api/files.d.ts
vendored
5
types/api/files.d.ts
vendored
@@ -33,3 +33,8 @@ type FileUploadResponse = {
|
||||
file_infos: FileInfo[];
|
||||
client_ids: string[];
|
||||
};
|
||||
|
||||
type FileSearchParams = {
|
||||
terms: string;
|
||||
is_or_search: boolean;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user