mattermost-mobile/app/screens/home/search/search.tsx
Daniel Espino García 9d1030a445
Push react native to 0.73.6 (#7863)
* Bump react native to 0.73.6

* iOS changes

* Fix gallery

* Fix test

* Add missing patch

* Unify react native navigation patch

* Update the rest of libraries

* iOS updates

* Update mattermost libraries

* Fix tests and final bumps

* iOS podfile update

* Update android locks

* Revert webrtc update because it was messing with the tests

* Update podfile for webrtc
2024-04-22 12:44:39 +02:00

437 lines
16 KiB
TypeScript

// 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, useEffect, useMemo, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {FlatList, type LayoutChangeEvent, Platform, StyleSheet, type ViewStyle, KeyboardAvoidingView, Keyboard} from 'react-native';
import HWKeyboardEvent from 'react-native-hw-keyboard-event';
import Animated, {useAnimatedStyle, useDerivedValue, withTiming, type AnimatedStyle} from 'react-native-reanimated';
import {type Edge, SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context';
import {getPosts} from '@actions/local/post';
import {addSearchToTeamSearchHistory} from '@actions/local/team';
import {searchPosts, searchFiles} from '@actions/remote/search';
import Autocomplete from '@components/autocomplete';
import FreezeScreen from '@components/freeze_screen';
import Loading from '@components/loading';
import NavigationHeader from '@components/navigation_header';
import RoundedHeaderContext from '@components/rounded_header_context';
import {Screens} from '@constants';
import {BOTTOM_TAB_HEIGHT} from '@constants/view';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {useKeyboardHeight} from '@hooks/device';
import useDidUpdate from '@hooks/did_update';
import {useCollapsibleHeader} from '@hooks/header';
import NavigationStore from '@store/navigation_store';
import {type FileFilter, FileFilters, filterFileExtensions} from '@utils/file';
import {TabTypes, type TabType} from '@utils/search';
import Initial from './initial';
import Results from './results';
import Header from './results/header';
import type {SearchRef} from '@components/search';
import type PostModel from '@typings/database/models/servers/post';
import type TeamModel from '@typings/database/models/servers/team';
const EDGES: Edge[] = ['bottom', 'left', 'right'];
const AnimatedFlatList = Animated.createAnimatedComponent(FlatList);
const emptyFileResults: FileInfo[] = [];
const emptyPosts: PostModel[] = [];
const emptyChannelIds: string[] = [];
const dummyData = [1];
const AutocompletePaddingTop = 4;
type Props = {
teamId: string;
teams: TeamModel[];
}
const styles = StyleSheet.create({
flex: {
flex: 1,
},
loading: {
flex: 1,
justifyContent: 'center',
},
autocompleteContainer: {
zIndex: 11,
},
});
const getSearchParams = (terms: string, filterValue?: FileFilter) => {
const fileExtensions = filterFileExtensions(filterValue);
const extensionTerms = fileExtensions ? ' ' + fileExtensions : '';
return {
terms: terms.replace(/[\u201C\u201D]/g, '"') + extensionTerms,
is_or_search: false,
include_deleted_channels: true,
};
};
const searchScreenIndex = 1;
const SearchScreen = ({teamId, teams}: Props) => {
const nav = useNavigation();
const isFocused = useIsFocused();
const intl = useIntl();
const theme = useTheme();
const insets = useSafeAreaInsets();
const keyboardHeight = useKeyboardHeight();
const stateIndex = nav.getState()?.index;
const serverUrl = useServerUrl();
const searchTerm: string = stateIndex === undefined ? '' : (nav.getState()?.routes[stateIndex]?.params as any)?.searchTerm || '';
const clearRef = useRef<boolean>(false);
const cancelRef = useRef<boolean>(false);
const searchRef = useRef<SearchRef>(null);
const [cursorPosition, setCursorPosition] = useState(searchTerm?.length || 0);
const [searchValue, setSearchValue] = useState<string>(searchTerm || '');
const [searchTeamId, setSearchTeamId] = useState<string>(teamId);
const [selectedTab, setSelectedTab] = useState<TabType>(TabTypes.MESSAGES);
const [filter, setFilter] = useState<FileFilter>(FileFilters.ALL);
const [showResults, setShowResults] = useState(false);
const [containerHeight, setContainerHeight] = useState(0);
const [searchIsFocused, setSearchIsFocused] = useState(false);
const [loading, setLoading] = useState(false);
const [resultsLoading, setResultsLoading] = useState(false);
const [lastSearchedValue, setLastSearchedValue] = useState('');
const [posts, setPosts] = useState<PostModel[]>(emptyPosts);
const [matches, setMatches] = useState<SearchMatches|undefined>();
const [fileInfos, setFileInfos] = useState<FileInfo[]>(emptyFileResults);
const [fileChannelIds, setFileChannelIds] = useState<string[]>([]);
useEffect(() => {
setSearchTeamId(teamId);
}, [teamId]);
useEffect(() => {
if (searchTerm) {
resetToInitial();
setSearchValue(searchTerm);
handleSearch(searchTeamId, searchTerm);
}
}, [searchTerm]);
const onSnap = (offset: number, animated = true) => {
scrollRef.current?.scrollToOffset({offset, animated});
};
const {
headerHeight,
headerOffset,
hideHeader,
lockValue,
onScroll,
scrollEnabled,
scrollPaddingTop,
scrollRef,
scrollValue,
setAutoScroll,
unlock,
} = useCollapsibleHeader<FlatList>(true, onSnap);
const resetToInitial = useCallback(() => {
setShowResults(false);
setSearchValue('');
setLastSearchedValue('');
setFilter(FileFilters.ALL);
}, []);
const handleClearSearch = useCallback(() => {
clearRef.current = true;
Keyboard.dismiss();
resetToInitial();
}, [resetToInitial]);
const handleCancelSearch = useCallback(() => {
cancelRef.current = true;
resetToInitial();
onSnap(0);
}, [resetToInitial]);
const handleTextChange = useCallback((newValue: string) => {
setSearchValue(newValue);
setCursorPosition(newValue.length);
}, []);
const handleModifierTextChange = useCallback((newValue: string) => {
setSearchIsFocused(true);
requestAnimationFrame(() => {
searchRef.current?.focus?.();
handleTextChange(newValue);
});
}, [handleTextChange]);
const handleLoading = useCallback((show: boolean) => {
(showResults ? setResultsLoading : setLoading)(show);
}, [showResults]);
const handleSearch = useCallback(async (newSearchTeamId: string, term: string) => {
const searchParams = getSearchParams(term, filter);
if (!searchParams.terms) {
handleClearSearch();
return;
}
hideHeader(true);
handleLoading(true);
setLastSearchedValue(term);
addSearchToTeamSearchHistory(serverUrl, newSearchTeamId, term);
const [postResults, {files, channels}] = await Promise.all([
searchPosts(serverUrl, newSearchTeamId, searchParams),
searchFiles(serverUrl, newSearchTeamId, searchParams),
]);
setFileInfos(files?.length ? files : emptyFileResults);
if (postResults.order) {
const postModels = await getPosts(serverUrl, postResults.order, 'asc');
setPosts(postModels.length ? postModels : emptyPosts);
setMatches(postResults.matches);
}
setFileChannelIds(channels?.length ? channels : emptyChannelIds);
handleLoading(false);
setShowResults(true);
}, [filter, handleClearSearch, handleLoading]);
const onBlur = useCallback(() => {
setSearchIsFocused(false);
}, []);
const onFocus = useCallback(() => {
setSearchIsFocused(true);
}, []);
const onSubmit = useCallback(() => {
handleSearch(searchTeamId, searchValue);
}, [handleSearch, searchTeamId, searchValue]);
const handleRecentSearch = useCallback((text: string) => {
handleTextChange(text);
handleSearch(searchTeamId, text);
}, [handleSearch, handleTextChange, searchTeamId]);
const handleFilterChange = useCallback(async (filterValue: FileFilter) => {
setResultsLoading(true);
setFilter(filterValue);
const searchParams = getSearchParams(lastSearchedValue, filterValue);
const {files, channels} = await searchFiles(serverUrl, searchTeamId, searchParams);
setFileInfos(files?.length ? files : emptyFileResults);
setFileChannelIds(channels?.length ? channels : emptyChannelIds);
setResultsLoading(false);
}, [lastSearchedValue, searchTeamId]);
const handleResultsTeamChange = useCallback((newTeamId: string) => {
setSearchTeamId(newTeamId);
handleSearch(newTeamId, lastSearchedValue);
}, [lastSearchedValue, handleSearch]);
const initialContainerStyle: AnimatedStyle<ViewStyle> = useMemo(() => {
return {
paddingTop: scrollPaddingTop,
flexGrow: 1,
justifyContent: (resultsLoading || loading) ? 'center' : 'flex-start',
};
}, [loading, resultsLoading, scrollPaddingTop]);
const renderInitialOrLoadingItem = useCallback(() => {
return loading ? (
<Loading
containerStyle={[styles.loading, {paddingTop: scrollPaddingTop}]}
color={theme.buttonBg}
size='large'
/>
) : (
<Initial
scrollEnabled={scrollEnabled}
searchValue={searchValue}
setRecentValue={handleRecentSearch}
searchRef={searchRef}
setSearchValue={handleModifierTextChange}
setTeamId={setSearchTeamId}
teamId={searchTeamId}
teams={teams}
/>
);
}, [handleRecentSearch, handleTextChange, loading,
scrollPaddingTop, searchTeamId, searchValue, theme]);
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}),
flex: 1,
transform: [{translateX: withTiming((stateIndex || 0) < searchScreenIndex ? 25 : -25, {duration: 150})}],
};
}, [isFocused, stateIndex]);
const headerTopStyle = useAnimatedStyle(() => ({
top: lockValue.value ? lockValue.value : headerHeight.value,
zIndex: lastSearchedValue ? 10 : 0,
}), [headerHeight, lastSearchedValue, lockValue]);
const onLayout = useCallback((e: LayoutChangeEvent) => {
setContainerHeight(e.nativeEvent.layout.height);
}, []);
const autocompleteMaxHeight = useDerivedValue(() => {
const iosAdjust = keyboardHeight ? keyboardHeight - BOTTOM_TAB_HEIGHT : insets.bottom;
const autocompleteRemoveFromHeight = headerHeight.value + (Platform.OS === 'ios' ? iosAdjust : 0);
return containerHeight - autocompleteRemoveFromHeight;
}, [keyboardHeight, insets.bottom, containerHeight]);
const autocompletePosition = useDerivedValue(() => {
return headerHeight.value - AutocompletePaddingTop;
}, [headerHeight]);
// when clearing the input from the search results, scroll the initial view
// back to the top so the header is in the collapsed state
const onFlatLayout = useCallback(() => {
if (clearRef.current || cancelRef.current) {
unlock();
}
if (clearRef.current) {
onSnap(headerOffset, false);
clearRef.current = false;
} else if (cancelRef.current) {
onSnap(0);
cancelRef.current = false;
}
}, [headerOffset, scrollRef]);
useDidUpdate(() => {
if (isFocused) {
setTimeout(() => {
setAutoScroll(true);
}, 300);
} else {
setAutoScroll(false);
}
}, [isFocused]);
useEffect(() => {
const listener = HWKeyboardEvent.onHWKeyPressed((keyEvent: {pressedKey: string}) => {
const topScreen = NavigationStore.getVisibleScreen();
if (topScreen === Screens.HOME && isFocused && keyEvent.pressedKey === 'enter') {
searchRef.current?.blur();
onSubmit();
}
});
return () => {
listener.remove();
};
}, [onSubmit]);
return (
<FreezeScreen freeze={!isFocused}>
<NavigationHeader
isLargeTitle={true}
showBackButton={false}
title={intl.formatMessage({id: 'screen.search.title', defaultMessage: 'Search'})}
hasSearch={true}
scrollValue={scrollValue}
lockValue={lockValue}
hideHeader={hideHeader}
onChangeText={handleTextChange}
onSubmitEditing={onSubmit}
blurOnSubmit={true}
placeholder={intl.formatMessage({id: 'screen.search.placeholder', defaultMessage: 'Search messages & files'})}
onBlur={onBlur}
onClear={handleClearSearch}
onCancel={handleCancelSearch}
onFocus={onFocus}
defaultValue={searchValue}
ref={searchRef}
/>
<SafeAreaView
style={styles.flex}
edges={EDGES}
onLayout={onLayout}
testID='search_messages.screen'
>
<KeyboardAvoidingView
style={styles.flex}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<Animated.View style={animated}>
<Animated.View style={headerTopStyle}>
<RoundedHeaderContext/>
{lastSearchedValue && !loading &&
<Header
teamId={searchTeamId}
setTeamId={handleResultsTeamChange}
onTabSelect={setSelectedTab}
onFilterChanged={handleFilterChange}
selectedTab={selectedTab}
selectedFilter={filter}
teams={teams}
/>
}
</Animated.View>
{!showResults &&
<AnimatedFlatList
onLayout={onFlatLayout}
data={dummyData}
contentContainerStyle={initialContainerStyle}
keyboardShouldPersistTaps='handled'
keyboardDismissMode={'interactive'}
nestedScrollEnabled={true}
indicatorStyle='black'
onScroll={onScroll}
scrollEventThrottle={16}
removeClippedSubviews={false}
scrollToOverflowEnabled={true}
overScrollMode='always'
ref={scrollRef}
renderItem={renderInitialOrLoadingItem}
/>
}
{showResults && !loading &&
<Results
loading={resultsLoading}
selectedTab={selectedTab}
searchValue={lastSearchedValue.replace(/[\u201C\u201D]/g, '"')}
posts={posts}
matches={matches}
fileInfos={fileInfos}
scrollPaddingTop={lockValue.value}
fileChannelIds={fileChannelIds}
/>
}
</Animated.View>
</KeyboardAvoidingView>
</SafeAreaView>
{searchIsFocused &&
<Autocomplete
updateValue={handleTextChange}
cursorPosition={cursorPosition}
value={searchValue}
isSearch={true}
hasFilesAttached={false}
availableSpace={autocompleteMaxHeight}
position={autocompletePosition}
growDown={true}
containerStyle={styles.autocompleteContainer}
teamId={searchTeamId}
/>
}
</FreezeScreen>
);
};
export default SearchScreen;