[Gekidou MM-44943] Add team picker to the search screen and results screens (#6455)

* initial check in

* add search value to memoized dependencies in modifier component

* ignore the back press

* UI adjustments from PR feedback

* initial commit

* recent search are getting rendered from WDB

* search terms from the search bar are getting added

* can delete recent searches from WDB from recent searches Options

* will now add new ters to the table and recreate existing terms with new
timestamp

* push for scrollview

* use flatlist instead of scrolview

* s/deleteRecentTeamSearchById/removeSearchFromTeamSearchHistory/

* s/addRecentTeamSearch/addSearchToTeamSearchHistory/

* Fix search to use a flatlist and remove douplicate reference

* fix eslint

* Fix android autoscroll search field to the top

* limit the number of saved searches to 20 for a team.
return the results a team Search History sorted by createdAt

* set display to term for now

* clean up

* clean up

* initial commit

* - From the search screen, you cna now set the team for the search
- Recent searches are saved for the specified team

* fix styling fo icon in modifiers

* - move team picker to its own component and call from modifiers
- will use for results header also

* - show team image if available
- pass optional size to TeamPickerIcon
- add TeamPickerIcon to Resuls Header
- styling fixes

* add team name to recent searches title

* parameter renaming

* fix lint

* fix callback bug that was calling itself

* when changing a team while showing search results:
  - update the search results with new selected team and current search
    term.
  - save the recent search the new selected team TeamSearchHistory for
    that team

* move to bottom for reduction of PR review lines and comparison to
changes in theh component logic

* - add dependencies
- rename function

* fix PR feedback

* - created helper function for bottom sheet with TeamList
  BottomSheetContent
- share the bottomScreen calls from AddTeam and TeamPickerIcon

* Add title back to renderContent of bottomSheet call.  This is needed for
tablets

* remove unnecessary check of selectTeamId.  it will be undefined and fail
the equality check

* - now all team_icons are all radius of 8. Includes the following icons:
  - team picker icon in in search screen
    - each team icon in the search screen bottom sheet team list
  - team picker icon in in search results screen
  - each team icon in the home screen team side bar list
    - each team icon in the join new team bottom sheet team list (from home screen)

* use padding in the width of the header so the margins are extended full
width, and dateline separator of post list does not creep into the
header region

* add smallText prop to team_icon.
- allows using 200 instead of default 400 value.
- 200 is used for the TeamPickerIcon used in the searcha nd results
  headers

* - add dependency back to handle renderItem and allow selecting files or
  messages view
- when handling the search, save the term to the correct team

* adjust styling so the rounded edges appear for the header. Use the
header container height to set the height of the header to 40 and then
set top and bottom margins for the rounded top edge and the bottom
margin to the divider

* use typography

* add title dependency

* update dependencies

* use bottomSheetSnapPoint to get the height of the total items

* rename variable

* Always use Metropolis-SemiBold for the team icon fallback

* use a fragment because there is not styling

* move title inside onPress function and can remove the title as a
dependency

* just use strings for testID

* calculate the observable inside the return object

* - remove const and use string
- correct the name of the testID

* - use more specific dependency value team.id
- add onPress dependency

* move to a constant

* move to a constant

* replace with logical AND

* add all logic for the style to textStyle

* remove the separate divider view and just use the outside container with
bottomBorder with and color

* extend the image vertically in the results

* Fix size of bottom sheet

* Refactor unneeded change

* Minor tweaks

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
Co-authored-by: Daniel Espino García <larkox@gmail.com>
This commit is contained in:
Jason Frerich
2022-07-15 07:05:58 -05:00
committed by GitHub
parent 4a842f0129
commit 33d9e6257e
23 changed files with 498 additions and 240 deletions

View File

@@ -25,7 +25,7 @@ export async function fetchRecentMentions(serverUrl: string): Promise<PostSearch
};
}
const terms = currentUser.userMentionKeys.map(({key}) => key).join(' ').trim() + ' ';
const results = await searchPosts(serverUrl, {terms, is_or_search: true});
const results = await searchPosts(serverUrl, '', {terms, is_or_search: true});
if (results.error) {
throw results.error;
}
@@ -46,13 +46,13 @@ export async function fetchRecentMentions(serverUrl: string): Promise<PostSearch
}
}
export const searchPosts = async (serverUrl: string, params: PostSearchParams): Promise<PostSearchRequest> => {
export const searchPosts = async (serverUrl: string, teamId: 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 data = await client.searchPosts(teamId, params.terms, params.is_or_search);
const posts = data.posts || {};
const order = data.order || [];

View File

@@ -10,7 +10,7 @@ import DatabaseManager from '@database/manager';
import NetworkManager from '@managers/network_manager';
import {prepareCategories, prepareCategoryChannels} from '@queries/servers/categories';
import {prepareMyChannelsForTeam, getDefaultChannelForTeam} from '@queries/servers/channel';
import {prepareCommonSystemValues, getCurrentTeamId} from '@queries/servers/system';
import {prepareCommonSystemValues, getCurrentTeamId, getCurrentUserId} from '@queries/servers/system';
import {addTeamToTeamHistory, prepareDeleteTeam, prepareMyTeams, getNthLastChannelFromTeam, queryTeamsById, syncTeamTable} from '@queries/servers/team';
import EphemeralStore from '@store/ephemeral_store';
import {isTablet} from '@utils/helpers';
@@ -29,6 +29,22 @@ export type MyTeamsRequest = {
error?: unknown;
}
export async function addCurrentUserToTeam(serverUrl: string, teamId: string, fetchOnly = false) {
let database;
try {
database = DatabaseManager.getServerDatabaseAndOperator(serverUrl).database;
} catch (error) {
return {error};
}
const currentUserId = await getCurrentUserId(database);
if (!currentUserId) {
return {error: 'no current user'};
}
return addUserToTeam(serverUrl, teamId, currentUserId, fetchOnly);
}
export async function addUserToTeam(serverUrl: string, teamId: string, userId: string, fetchOnly = false) {
let client;
try {

View File

@@ -10,10 +10,10 @@ import {GestureResponderEvent, Keyboard, StyleProp, StyleSheet, Text, TextStyle,
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import {fetchUserOrGroupsByMentionsInBatch} from '@actions/remote/user';
import {useServerUrl} from '@app/context/server';
import SlideUpPanelItem, {ITEM_HEIGHT} from '@components/slide_up_panel_item';
import {Screens} from '@constants';
import {MM_TABLES} from '@constants/database';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import GroupModel from '@database/models/server/group';
import UserModel from '@database/models/server/user';

View File

@@ -23,6 +23,7 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
flexGrow: 1,
height: '100%',
alignItems: 'center' as const,
justifyContent: 'center' as const,
},

View File

@@ -3,35 +3,69 @@
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {View} from 'react-native';
import {handleTeamChange} from '@actions/remote/team';
import {addCurrentUserToTeam, handleTeamChange} from '@actions/remote/team';
import FormattedText from '@components/formatted_text';
import Empty from '@components/illustrations/no_team';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import BottomSheetContent from '@screens/bottom_sheet/content';
import {dismissBottomSheet} from '@screens/navigation';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import TeamList from './team_list';
import type TeamModel from '@typings/database/models/servers/team';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
empty: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
title: {
color: theme.centerChannelColor,
lineHeight: 28,
marginTop: 16,
...typography('Heading', 400, 'Regular'),
},
description: {
color: theme.centerChannelColor,
marginTop: 8,
maxWidth: 334,
...typography('Body', 200, 'Regular'),
},
}));
type Props = {
otherTeams: TeamModel[];
title: string;
showTitle?: boolean;
}
export default function AddTeamSlideUp({otherTeams, showTitle = true}: Props) {
export default function AddTeamSlideUp({otherTeams, title, showTitle = true}: Props) {
const intl = useIntl();
const serverUrl = useServerUrl();
const theme = useTheme();
const styles = getStyleSheet(theme);
const onPressCreate = useCallback(() => {
//TODO Create team screen https://mattermost.atlassian.net/browse/MM-43622
dismissBottomSheet();
}, []);
const onTeamAdded = useCallback(async (teamId: string) => {
await dismissBottomSheet();
handleTeamChange(serverUrl, teamId);
const onPress = useCallback(async (teamId: string) => {
const {error} = await addCurrentUserToTeam(serverUrl, teamId);
if (!error) {
await dismissBottomSheet();
handleTeamChange(serverUrl, teamId);
}
}, [serverUrl]);
const hasOtherTeams = otherTeams.length;
return (
<BottomSheetContent
buttonIcon='plus'
@@ -39,14 +73,33 @@ export default function AddTeamSlideUp({otherTeams, showTitle = true}: Props) {
onPress={onPressCreate}
showButton={false}
showTitle={showTitle}
testID='team_sidebar.add_team_slide_up'
title={intl.formatMessage({id: 'mobile.add_team.join_team', defaultMessage: 'Join Another Team'})}
testID={'team_sidebar.add_team_slide_up'}
title={title}
>
<TeamList
teams={otherTeams}
onTeamAdded={onTeamAdded}
testID='team_sidebar.add_team_slide_up.team_list'
/>
{hasOtherTeams &&
<TeamList
teams={otherTeams}
onPress={onPress}
testID='team_sidebar.add_team_slide_up.team_list'
/>
}
{!hasOtherTeams &&
<View style={styles.empty}>
<Empty theme={theme}/>
<FormattedText
id='team_list.no_other_teams.title'
defaultMessage='No additional teams to join'
style={styles.title}
testID={'team_sidebar.add_team_slide_up.no_other_teams.title'}
/>
<FormattedText
id='team_list.no_other_teams.description'
defaultMessage='To join another team, ask a Team Admin for an invitation, or create your own team.'
style={styles.description}
testID={'team_sidebar.add_team_slide_up.no_other_teams.description'}
/>
</View>
}
</BottomSheetContent>
);
}

View File

@@ -9,7 +9,7 @@ import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {bottomSheet} from '@screens/navigation';
import {bottomSheetWithTeamList} from '@screens/navigation';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
@@ -21,12 +21,6 @@ type Props = {
otherTeams: TeamModel[];
}
const ITEM_HEIGHT = 72;
const HEADER_HEIGHT = 66;
const CONTAINER_HEIGHT = 392;
//const CREATE_HEIGHT = 97;
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
@@ -55,31 +49,27 @@ export default function AddTeam({otherTeams}: Props) {
const dimensions = useWindowDimensions();
const intl = useIntl();
const isTablet = useIsTablet();
const maxHeight = Math.round((dimensions.height * 0.9));
const onPress = useCallback(preventDoubleTap(() => {
const title = intl.formatMessage({id: 'mobile.add_team.join_team', defaultMessage: 'Join Another Team'});
const renderContent = () => {
return (
<AddTeamSlideUp
otherTeams={otherTeams}
showTitle={!isTablet && Boolean(otherTeams.length)}
title={title}
/>
);
};
let height = CONTAINER_HEIGHT;
if (otherTeams.length) {
height = Math.min(maxHeight, HEADER_HEIGHT + ((otherTeams.length + 1) * ITEM_HEIGHT));
}
bottomSheet({
closeButtonId: 'close-join-team',
bottomSheetWithTeamList({
dimensions,
renderContent,
snapPoints: [height, 10],
theme,
title: intl.formatMessage({id: 'mobile.add_team.join_team', defaultMessage: 'Join Another Team'}),
title,
teams: otherTeams,
});
}), [otherTeams, isTablet, theme]);
}), [otherTeams, intl, isTablet, dimensions, theme]);
return (
<View style={styles.container}>

View File

@@ -2,14 +2,9 @@
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {ListRenderItemInfo, View} from 'react-native';
import {ListRenderItemInfo, StyleSheet, View} from 'react-native';
import {FlatList} from 'react-native-gesture-handler'; // Keep the FlatList from gesture handler so it works well with bottom sheet
import FormattedText from '@components/formatted_text';
import Empty from '@components/illustrations/no_team';
import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme} from '@utils/theme';
import TeamListItem from './team_list_item';
import type TeamModel from '@typings/database/models/servers/team';
@@ -19,85 +14,44 @@ type Props = {
textColor?: string;
iconTextColor?: string;
iconBackgroundColor?: string;
onTeamAdded: (id: string) => void;
onPress: (id: string) => void;
testID?: string;
selectedTeamId?: string;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
const styles = StyleSheet.create({
container: {
flexShrink: 1,
},
contentContainer: {
marginBottom: 4,
},
empty: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
title: {
fontFamily: 'Metropolis',
fontSize: 20,
color: theme.centerChannelColor,
lineHeight: 28,
marginTop: 16,
},
description: {
fontFamily: 'Open Sans',
fontSize: 16,
color: theme.centerChannelColor,
lineHeight: 24,
marginTop: 8,
maxWidth: 334,
},
}));
});
const keyExtractor = (item: TeamModel) => item.id;
export default function TeamList({teams, textColor, iconTextColor, iconBackgroundColor, onTeamAdded, testID}: Props) {
const theme = useTheme();
const styles = getStyleSheet(theme);
export default function TeamList({teams, textColor, iconTextColor, iconBackgroundColor, onPress, testID, selectedTeamId}: Props) {
const renderTeam = useCallback(({item: t}: ListRenderItemInfo<Team|TeamModel>) => {
return (
<TeamListItem
onPress={onPress}
team={t}
textColor={textColor}
iconBackgroundColor={iconBackgroundColor}
iconTextColor={iconTextColor}
onTeamAdded={onTeamAdded}
selectedTeamId={selectedTeamId}
/>
);
}, [textColor, iconTextColor, iconBackgroundColor, onTeamAdded]);
if (teams.length) {
return (
<View style={styles.container}>
<FlatList
data={teams}
renderItem={renderTeam}
keyExtractor={keyExtractor}
contentContainerStyle={styles.contentContainer}
testID={`${testID}.flat_list`}
/>
</View>
);
}
}, [textColor, iconTextColor, iconBackgroundColor, onPress, selectedTeamId]);
return (
<View style={styles.empty}>
<Empty theme={theme}/>
<FormattedText
id='team_list.no_other_teams.title'
defaultMessage='No additional teams to join'
style={styles.title}
testID={`${testID}.no_other_teams.title`}
/>
<FormattedText
id='team_list.no_other_teams.description'
defaultMessage='To join another team, ask a Team Admin for an invitation, or create your own team.'
style={styles.description}
testID={`${testID}.no_other_teams.description`}
<View style={styles.container}>
<FlatList
data={teams}
renderItem={renderTeam}
keyExtractor={keyExtractor}
contentContainerStyle={styles.contentContainer}
testID={`${testID}.flat_list`}
/>
</View>
);

View File

@@ -4,10 +4,9 @@
import React, {useCallback} from 'react';
import {Text, View} from 'react-native';
import {addUserToTeam} from '@actions/remote/team';
import CompassIcon from '@components/compass_icon';
import TeamIcon from '@components/team_sidebar/team_list/team_item/team_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
@@ -16,18 +15,22 @@ import type TeamModel from '@typings/database/models/servers/team';
type Props = {
team: TeamModel | Team;
currentUserId: string;
textColor?: string;
iconTextColor?: string;
iconBackgroundColor?: string;
onTeamAdded: (teamId: string) => void;
selectedTeamId?: string;
onPress: (teamId: string) => void;
}
const CONTAINER_HEIGHT = 40;
const CONTAINER_VERTICAL_MARGIN = 8;
export const ITEM_HEIGHT = CONTAINER_HEIGHT + (CONTAINER_VERTICAL_MARGIN * 2);
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
height: 64,
marginBottom: 2,
height: CONTAINER_HEIGHT,
marginVertical: CONTAINER_VERTICAL_MARGIN,
},
touchable: {
display: 'flex',
@@ -46,28 +49,29 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
width: 40,
height: 40,
},
compassContainer: {
flex: 1,
alignItems: 'flex-end',
},
};
});
export default function TeamListItem({team, currentUserId, textColor, iconTextColor, iconBackgroundColor, onTeamAdded}: Props) {
export default function TeamListItem({team, textColor, iconTextColor, iconBackgroundColor, selectedTeamId, onPress}: Props) {
const theme = useTheme();
const styles = getStyleSheet(theme);
const serverUrl = useServerUrl();
const onPress = useCallback(async () => {
const {error} = await addUserToTeam(serverUrl, team.id, currentUserId);
if (!error) {
onTeamAdded(team.id);
}
}, [onTeamAdded]);
const displayName = 'displayName' in team ? team.displayName : team.display_name;
const lastTeamIconUpdateAt = 'lastTeamIconUpdatedAt' in team ? team.lastTeamIconUpdatedAt : team.last_team_icon_update;
const teamListItemTestId = `team_sidebar.team_list.team_list_item.${team.id}`;
const handlePress = useCallback(() => {
onPress(team.id);
}, [team.id, onPress]);
return (
<View style={styles.container}>
<TouchableWithFeedback
onPress={onPress}
onPress={handlePress}
type='opacity'
style={styles.touchable}
>
@@ -88,6 +92,15 @@ export default function TeamListItem({team, currentUserId, textColor, iconTextCo
>
{displayName}
</Text>
{(team.id === selectedTeamId) &&
<View style={styles.compassContainer}>
<CompassIcon
color={theme.buttonBg}
name='check'
size={24}
/>
</View>
}
</TouchableWithFeedback>
</View>
);

View File

@@ -19,7 +19,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
alignItems: 'center',
justifyContent: 'center',
backgroundColor: theme.sidebarBg,
borderRadius: 10,
borderRadius: 8,
},
containerSelected: {
width: '100%',
@@ -32,10 +32,9 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
text: {
color: theme.sidebarText,
textTransform: 'capitalize',
...typography('Heading', 400, 'SemiBold'),
},
image: {
borderRadius: 6,
borderRadius: 8,
position: 'absolute',
top: 0,
bottom: 0,
@@ -55,6 +54,7 @@ type Props = {
displayName: string;
selected: boolean;
backgroundColor?: string;
smallText?: boolean;
textColor?: string;
testID?: string;
}
@@ -64,6 +64,7 @@ export default function TeamIcon({
lastIconUpdate,
displayName,
selected,
smallText = false,
textColor,
backgroundColor,
testID,
@@ -100,11 +101,20 @@ export default function TeamIcon({
return backgroundColor ? [styles.container, {backgroundColor}] : [styles.container, nameOnly && styles.nameOnly];
}, [styles, backgroundColor, selected, nameOnly]);
const textTypography = typography('Heading', smallText ? 200 : 400, 'SemiBold');
textTypography.fontFamily = 'Metropolis-SemiBold';
let teamIconContent;
if (nameOnly) {
const textStyle = [
styles.text,
textTypography,
textColor && {color: textColor},
];
teamIconContent = (
<Text
style={textColor ? [styles.text, {color: textColor}] : styles.text}
style={textStyle}
testID={`${testID}.display_name_abbreviation`}
>
{displayName.substring(0, 2)}

View File

@@ -3,59 +3,37 @@
import React, {useCallback, useMemo, useState} from 'react';
import {IntlShape, useIntl} from 'react-intl';
import {View} from 'react-native';
import Animated, {useSharedValue, useAnimatedStyle, withTiming} from 'react-native-reanimated';
import FormattedText from '@components/formatted_text';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import TeamPickerIcon from '../team_picker_icon';
import Modifier, {ModifierItem} from './modifier';
import ShowMoreButton from './show_more';
const SECTION_HEIGHT = 20;
const RECENT_SEPARATOR_HEIGHT = 3;
const MODIFIER_LABEL_HEIGHT = 48;
const TEAM_PICKER_ICON_SIZE = 32;
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return {
flex: {
flex: 1,
},
sectionWrapper: {
marginBottom: 12,
height: 48,
backgroundColor: theme.centerChannelBg,
},
sectionContainer: {
justifyContent: 'center',
paddingLeft: 20,
height: SECTION_HEIGHT,
titleContainer: {
alignItems: 'center',
flexDirection: 'row',
marginTop: 20,
marginRight: 18,
},
title: {
marginTop: 20,
paddingVertical: 12,
paddingHorizontal: 20,
flex: 1,
alignItems: 'center',
paddingLeft: 18,
color: theme.centerChannelColor,
...typography('Heading', 300, 'SemiBold'),
},
showMore: {
padding: 0,
color: theme.buttonBg,
...typography('Body', 200, 'SemiBold'),
},
separatorContainer: {
justifyContent: 'center',
flex: 1,
height: RECENT_SEPARATOR_HEIGHT,
},
separator: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.1),
height: 1,
},
sectionList: {
flex: 1,
},
};
});
@@ -98,8 +76,10 @@ const getModifiersSectionsData = (intl: IntlShape): ModifierItem[] => {
type Props = {
setSearchValue: (value: string) => void;
searchValue?: string;
setTeamId: (id: string) => void;
teamId: string;
}
const SearchModifiers = ({searchValue, setSearchValue}: Props) => {
const Modifiers = ({searchValue, setSearchValue, setTeamId, teamId}: Props) => {
const theme = useTheme();
const intl = useIntl();
@@ -135,11 +115,18 @@ const SearchModifiers = ({searchValue, setSearchValue}: Props) => {
return (
<>
<FormattedText
style={styles.title}
id={'screen.search.modifier.header'}
defaultMessage='Search options'
/>
<View style={styles.titleContainer}>
<FormattedText
style={styles.title}
id={'screen.search.modifier.header'}
defaultMessage='Search options'
/>
<TeamPickerIcon
size={TEAM_PICKER_ICON_SIZE}
setTeamId={setTeamId}
teamId={teamId}
/>
</View>
<Animated.View style={animatedStyle}>
{data.map((item) => renderModifier(item))}
</Animated.View>
@@ -151,5 +138,5 @@ const SearchModifiers = ({searchValue, setSearchValue}: Props) => {
);
};
export default SearchModifiers;
export default Modifiers;

View File

@@ -4,8 +4,10 @@
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, distinctUntilChanged} from 'rxjs/operators';
import {queryTeamSearchHistoryByTeamId} from '@queries/servers/team';
import {observeTeam, queryTeamSearchHistoryByTeamId} from '@queries/servers/team';
import RecentSearches from './recent_searches';
@@ -18,6 +20,10 @@ type EnhanceProps = WithDatabaseArgs & {
const enhance = withObservables(['teamId'], ({database, teamId}: EnhanceProps) => {
return {
recentSearches: queryTeamSearchHistoryByTeamId(database, teamId).observe(),
teamName: observeTeam(database, teamId).pipe(
switchMap((t) => of$(t?.displayName || '')),
distinctUntilChanged(),
),
};
});

View File

@@ -3,10 +3,9 @@
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {FlatList, View} from 'react-native';
import {FlatList, Text, View} from 'react-native';
import Animated from 'react-native-reanimated';
import FormattedText from '@components/formatted_text';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
@@ -37,13 +36,21 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
type Props = {
setRecentValue: (value: string) => void;
recentSearches: TeamSearchHistoryModel[];
teamName: string;
}
const RecentSearches = ({setRecentValue, recentSearches}: Props) => {
const RecentSearches = ({setRecentValue, recentSearches, teamName}: Props) => {
const theme = useTheme();
const {formatMessage} = useIntl();
const styles = getStyleFromTheme(theme);
const title = formatMessage({
id: 'smobile.search.recent_title',
defaultMessage: 'Recent searches in {teamName}',
}, {
teamName,
});
const renderRecentItem = useCallback(({item}) => {
return (
<RecentItem
@@ -56,11 +63,12 @@ const RecentSearches = ({setRecentValue, recentSearches}: Props) => {
const header = (
<>
<View style={styles.divider}/>
<FormattedText
<Text
style={styles.title}
id={'screen.search.recent.header'}
defaultMessage={formatMessage({id: 'mobile.search.recent_title', defaultMessage: 'Recent searches'})}
/>
numberOfLines={2}
>
{title}
</Text>
</>
);

View File

@@ -75,16 +75,15 @@ export const DIVIDERS_HEIGHT = data.length - 1;
type FilterProps = {
initialFilter: FileFilter;
setFilter: (filter: FileFilter) => void;
title: string;
}
const Filter = ({initialFilter, setFilter}: FilterProps) => {
const Filter = ({initialFilter, setFilter, title}: FilterProps) => {
const intl = useIntl();
const theme = useTheme();
const style = getStyleSheet(theme);
const isTablet = useIsTablet();
const buttonTitle = intl.formatMessage({id: 'screen.search.results.filter.title', defaultMessage: 'Filter by file type'});
const handleOnPress = useCallback((fileType: FileFilter) => {
if (fileType !== initialFilter) {
setFilter(fileType);
@@ -110,7 +109,7 @@ const Filter = ({initialFilter, setFilter}: FilterProps) => {
showButton={false}
showTitle={!isTablet}
testID='search.filters'
title={buttonTitle}
title={title}
titleSeparator={true}
>
<View style={style.container}>

View File

@@ -5,7 +5,6 @@ import {useIntl} from 'react-intl';
import {View} from 'react-native';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import {bottomSheetSnapPoint} from '@app/utils/helpers';
import Badge from '@components/badge';
import CompassIcon from '@components/compass_icon';
import {useTheme} from '@context/theme';
@@ -13,14 +12,15 @@ import {useIsTablet} from '@hooks/device';
import {SEPARATOR_MARGIN, SEPARATOR_MARGIN_TABLET, TITLE_HEIGHT} from '@screens/bottom_sheet/content';
import {bottomSheet} from '@screens/navigation';
import {FileFilter, FileFilters} from '@utils/file';
import {bottomSheetSnapPoint} from '@utils/helpers';
import {TabTypes, TabType} from '@utils/search';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import TeamPickerIcon from '../team_picker_icon';
import Filter, {DIVIDERS_HEIGHT, FILTER_ITEM_HEIGHT, NUMBER_FILTER_ITEMS} from './filter';
import SelectButton from './header_button';
const HEADER_HEIGHT = 64;
type Props = {
onTabSelect: (tab: TabType) => void;
onFilterChanged: (filter: FileFilter) => void;
@@ -28,34 +28,34 @@ type Props = {
selectedFilter: FileFilter;
numberMessages: number;
numberFiles: number;
setTeamId: (id: string) => void;
teamId: string;
}
const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
return {
flex: {
flex: 1,
},
container: {
marginTop: 10,
backgroundColor: theme.centerChannelBg,
marginHorizontal: 12,
borderBottomWidth: 1,
borderBottomColor: changeOpacity(theme.centerChannelColor, 0.1),
},
buttonsContainer: {
marginBottom: 12,
paddingHorizontal: 12,
flexDirection: 'row',
paddingVertical: 12,
flexGrow: 0,
height: HEADER_HEIGHT,
alignItems: 'center',
},
filter: {
marginRight: 12,
alignItems: 'center',
flexDirection: 'row',
marginLeft: 'auto',
},
divider: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
height: 1,
},
};
});
const Header = ({
teamId,
setTeamId,
onTabSelect,
onFilterChanged,
numberMessages,
@@ -71,6 +71,7 @@ const Header = ({
const messagesText = intl.formatMessage({id: 'screen.search.header.messages', defaultMessage: 'Messages'});
const filesText = intl.formatMessage({id: 'screen.search.header.files', defaultMessage: 'Files'});
const title = intl.formatMessage({id: 'screen.search.results.filter.title', defaultMessage: 'Filter by file type'});
const showFilterIcon = selectedTab === TabTypes.FILES;
const hasFilters = selectedFilter !== FileFilters.ALL;
@@ -99,6 +100,7 @@ const Header = ({
<Filter
initialFilter={selectedFilter}
setFilter={onFilterChanged}
title={title}
/>
);
};
@@ -107,13 +109,13 @@ const Header = ({
renderContent,
snapPoints,
theme,
title: intl.formatMessage({id: 'mobile.add_team.join_team', defaultMessage: 'Join Another Team'}),
title,
});
}, [selectedFilter]);
return (
<>
<View style={styles.container}>
<View style={styles.container}>
<View style={styles.buttonsContainer}>
<SelectButton
selected={selectedTab === TabTypes.MESSAGES}
onPress={handleMessagesPress}
@@ -144,11 +146,15 @@ const Header = ({
/>
</>
}
<TeamPickerIcon
size={32}
divider={true}
setTeamId={setTeamId}
teamId={teamId}
/>
</View>
</View>
<View style={styles.divider}/>
</>
</View>
);
};

View File

@@ -11,13 +11,9 @@ import {typography} from '@utils/typography';
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return {
flex: {
flex: 1,
},
button: {
alignItems: 'center',
borderRadius: 4,
height: 40,
},
text: {
paddingHorizontal: 16,

View File

@@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import {useIsFocused, useNavigation} from '@react-navigation/native';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import React, {useCallback, useMemo, useState} from 'react';
import {useIntl} from 'react-intl';
import {FlatList, StyleSheet} from 'react-native';
import Animated, {useAnimatedStyle, withTiming} from 'react-native-reanimated';
@@ -58,17 +58,20 @@ const getSearchParams = (terms: string, filterValue?: FileFilter) => {
};
};
const searchScreenIndex = 1;
const SearchScreen = ({teamId}: Props) => {
const nav = useNavigation();
const isFocused = useIsFocused();
const intl = useIntl();
const theme = useTheme();
const searchScreenIndex = 1;
const stateIndex = nav.getState().index;
const serverUrl = useServerUrl();
const searchTerm = (nav.getState().routes[stateIndex].params as any)?.searchTerm;
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);
@@ -80,18 +83,12 @@ const SearchScreen = ({teamId}: Props) => {
const [fileInfos, setFileInfos] = useState<FileInfo[]>(emptyFileResults);
const [fileChannelIds, setFileChannelIds] = useState<string[]>([]);
const handleSearch = useRef<(term: string) => void>();
const onSnap = (offset: number) => {
scrollRef.current?.scrollToOffset({offset, animated: true});
};
const {scrollPaddingTop, scrollRef, scrollValue, onScroll, headerHeight, hideHeader} = useCollapsibleHeader<FlatList>(true, onSnap);
const onSubmit = useCallback(() => {
handleSearch.current?.(searchValue);
}, [searchValue]);
const handleClearSearch = useCallback(() => {
setSearchValue('');
setLastSearchedValue('');
@@ -101,48 +98,55 @@ const SearchScreen = ({teamId}: Props) => {
const handleCancelSearch = useCallback(() => {
handleClearSearch();
setShowResults(false);
}, [handleClearSearch, showResults]);
}, [handleClearSearch]);
useEffect(() => {
handleSearch.current = async (term: string) => {
const searchParams = getSearchParams(term);
if (!searchParams.terms) {
handleClearSearch();
return;
}
setLoading(true);
setFilter(FileFilters.ALL);
setLastSearchedValue(term);
addSearchToTeamSearchHistory(serverUrl, teamId, term);
const [postResults, {files, channels}] = await Promise.all([
searchPosts(serverUrl, searchParams),
searchFiles(serverUrl, teamId, searchParams),
]);
const handleSearch = useCallback(async (newSearchTeamId: string, term: string) => {
const searchParams = getSearchParams(term);
if (!searchParams.terms) {
handleClearSearch();
return;
}
setLoading(true);
setFilter(FileFilters.ALL);
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);
setPostIds(postResults?.order?.length ? postResults.order : emptyPostResults);
setFileChannelIds(channels?.length ? channels : emptyChannelIds);
setFileInfos(files?.length ? files : emptyFileResults);
setPostIds(postResults?.order?.length ? postResults.order : emptyPostResults);
setFileChannelIds(channels?.length ? channels : emptyChannelIds);
setShowResults(true);
setLoading(false);
};
}, [teamId]);
setShowResults(true);
setLoading(false);
}, [handleClearSearch]);
const onSubmit = useCallback(() => {
handleSearch(searchTeamId, searchValue);
}, [handleSearch, searchTeamId, searchValue]);
const handleRecentSearch = useCallback((text: string) => {
setSearchValue(text);
handleSearch.current?.(text);
}, []);
handleSearch(searchTeamId, text);
}, [handleSearch, searchTeamId]);
const handleFilterChange = useCallback(async (filterValue: FileFilter) => {
setLoading(true);
setFilter(filterValue);
const searchParams = getSearchParams(lastSearchedValue, filterValue);
const {files, channels} = await searchFiles(serverUrl, teamId, searchParams);
const {files, channels} = await searchFiles(serverUrl, searchTeamId, searchParams);
setFileInfos(files?.length ? files : emptyFileResults);
setFileChannelIds(channels?.length ? channels : emptyChannelIds);
setLoading(false);
}, [getSearchParams, lastSearchedValue, searchFiles]);
}, [lastSearchedValue, searchTeamId]);
const handleResultsTeamChange = useCallback((newTeamId: string) => {
setSearchTeamId(newTeamId);
handleSearch(newTeamId, lastSearchedValue);
}, [lastSearchedValue]);
const loadingComponent = useMemo(() => (
<Loading
@@ -157,13 +161,15 @@ const SearchScreen = ({teamId}: Props) => {
<Modifiers
setSearchValue={setSearchValue}
searchValue={searchValue}
teamId={searchTeamId}
setTeamId={setSearchTeamId}
/>
<RecentSearches
setRecentValue={handleRecentSearch}
teamId={teamId}
teamId={searchTeamId}
/>
</>
), [searchValue, teamId, handleRecentSearch]);
), [searchValue, searchTeamId, handleRecentSearch]);
const resultsComponent = useMemo(() => (
<Results
@@ -174,7 +180,7 @@ const SearchScreen = ({teamId}: Props) => {
scrollPaddingTop={scrollPaddingTop}
fileChannelIds={fileChannelIds}
/>
), [selectedTab, lastSearchedValue, postIds, fileInfos, scrollPaddingTop]);
), [selectedTab, lastSearchedValue, postIds, fileInfos, scrollPaddingTop, fileChannelIds]);
const renderItem = useCallback(() => {
if (loading) {
@@ -204,7 +210,6 @@ const SearchScreen = ({teamId}: Props) => {
return {
opacity: withTiming(0, {duration: 150}),
transform: [{translateX: withTiming(stateIndex < searchScreenIndex ? 25 : -25, {duration: 150})}],
};
}, [isFocused, stateIndex]);
@@ -219,6 +224,8 @@ const SearchScreen = ({teamId}: Props) => {
if (lastSearchedValue && !loading) {
header = (
<Header
teamId={searchTeamId}
setTeamId={handleResultsTeamChange}
onTabSelect={setSelectedTab}
onFilterChanged={handleFilterChange}
numberMessages={postIds.length}

View File

@@ -0,0 +1,20 @@
// 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 {queryJoinedTeams} from '@queries/servers/team';
import TeamPickerIcon from './team_picker_icon';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
const teams = queryJoinedTeams(database).observe();
return {
teams,
};
});
export default withDatabase(enhanced(TeamPickerIcon));

View File

@@ -0,0 +1,44 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import TeamList from '@components/team_sidebar/add_team/team_list';
import {useIsTablet} from '@hooks/device';
import BottomSheetContent from '@screens/bottom_sheet/content';
import {dismissBottomSheet} from '@screens/navigation';
import type TeamModel from '@typings/database/models/servers/team';
type Props = {
teams: TeamModel[];
teamId: string;
setTeamId: (teamId: string) => void;
title: string;
}
export default function SelectTeamSlideUp({teams, title, setTeamId, teamId}: Props) {
const isTablet = useIsTablet();
const showTitle = !isTablet && Boolean(teams.length);
const onPress = useCallback((newTeamId: string) => {
setTeamId(newTeamId);
dismissBottomSheet();
}, [setTeamId]);
return (
<BottomSheetContent
showButton={false}
showTitle={showTitle}
testID={'search.search_team_slide_up'}
title={title}
>
<TeamList
selectedTeamId={teamId}
teams={teams}
onPress={onPress}
testID='search.search_team_slide_up.team_list'
/>
</BottomSheetContent>
);
}

View File

@@ -0,0 +1,114 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {View, useWindowDimensions} from 'react-native';
import CompassIcon from '@components/compass_icon';
import TeamIcon from '@components/team_sidebar/team_list/team_item/team_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {useTheme} from '@context/theme';
import {bottomSheetWithTeamList} from '@screens/navigation';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import SelectTeamSlideUp from './search_team_slideup';
import type TeamModel from '@typings/database/models/servers/team';
const MENU_DOWN_ICON_SIZE = 24;
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return {
teamContainer: {
paddingLeft: 12,
flexDirection: 'row',
alignItems: 'center',
},
border: {
marginLeft: 12,
borderLeftWidth: 1,
borderLeftColor: changeOpacity(theme.centerChannelColor, 0.16),
},
teamIcon: {
flexDirection: 'row',
},
compass: {
alignItems: 'center',
marginLeft: 0,
},
};
});
type Props = {
size?: number;
divider?: boolean;
teams: TeamModel[];
setTeamId: (id: string) => void;
teamId: string;
}
const TeamPickerIcon = ({size = 24, divider = false, setTeamId, teams, teamId}: Props) => {
const intl = useIntl();
const theme = useTheme();
const dimensions = useWindowDimensions();
const styles = getStyleFromTheme(theme);
const selectedTeam = teams.find((t) => t.id === teamId);
const title = intl.formatMessage({id: 'mobile.search.team.select', defaultMessage: 'Select a team to search'});
const handleTeamChange = useCallback(preventDoubleTap(() => {
const renderContent = () => {
return (
<SelectTeamSlideUp
setTeamId={setTeamId}
teams={teams}
teamId={teamId}
title={title}
/>
);
};
bottomSheetWithTeamList({
dimensions,
renderContent,
theme,
title,
teams,
});
}), [theme, setTeamId, teamId, teams]);
return (
<>
{selectedTeam &&
<TouchableWithFeedback
onPress={handleTeamChange}
type='opacity'
testID={selectedTeam.id}
>
<View style={[styles.teamContainer, divider && styles.border]}>
<View style={[styles.teamIcon, {width: size, height: size}]}>
<TeamIcon
displayName={selectedTeam.displayName}
id={selectedTeam.id}
lastIconUpdate={selectedTeam.lastTeamIconUpdatedAt}
textColor={theme.centerChannelColor}
backgroundColor={changeOpacity(theme.centerChannelColor, 0.16)}
selected={false}
testID={`${selectedTeam}.team_icon`}
smallText={true}
/>
</View>
<CompassIcon
color={changeOpacity(theme.centerChannelColor, 0.56)}
style={styles.compass}
name='menu-down'
size={MENU_DOWN_ICON_SIZE}
/>
</View>
</TouchableWithFeedback>
}
</>
);
};
export default TeamPickerIcon;

View File

@@ -4,21 +4,25 @@
/* eslint-disable max-lines */
import merge from 'deepmerge';
import {Appearance, DeviceEventEmitter, NativeModules, StatusBar, Platform, Alert} from 'react-native';
import {Appearance, ScaledSize, DeviceEventEmitter, NativeModules, StatusBar, Platform, Alert} from 'react-native';
import {ImageResource, Navigation, Options, OptionsModalPresentationStyle, OptionsTopBarButton} from 'react-native-navigation';
import tinyColor from 'tinycolor2';
import CompassIcon from '@components/compass_icon';
import {ITEM_HEIGHT} from '@components/team_sidebar/add_team/team_list_item/team_list_item';
import {Device, Events, Screens} from '@constants';
import NavigationConstants from '@constants/navigation';
import {NOT_READY} from '@constants/screens';
import {getDefaultThemeByAppearance} from '@context/theme';
import {TITLE_HEIGHT} from '@screens/bottom_sheet/content';
import EphemeralStore from '@store/ephemeral_store';
import NavigationStore from '@store/navigation_store';
import {LaunchProps, LaunchType} from '@typings/launch';
import {bottomSheetSnapPoint} from '@utils/helpers';
import {appearanceControlledScreens, mergeNavigationOptions} from '@utils/navigation';
import {changeOpacity, setNavigatorStyles} from '@utils/theme';
import type TeamModel from '@typings/database/models/servers/team';
import type {NavButtons} from '@typings/screens/navigation';
const {MattermostManaged} = NativeModules;
@@ -663,6 +667,34 @@ export async function bottomSheet({title, renderContent, snapPoints, initialSnap
}
}
type BottomSheetWithTeamListArgs = {
teams: TeamModel[];
dimensions: ScaledSize;
renderContent: () => JSX.Element;
theme: Theme;
title: string;
}
export async function bottomSheetWithTeamList({title, teams, dimensions, renderContent, theme}: BottomSheetWithTeamListArgs) {
const NO_TEAMS_HEIGHT = 392;
const maxHeight = Math.round((dimensions.height * 0.9));
let height = NO_TEAMS_HEIGHT;
if (teams.length) {
const itemsHeight = bottomSheetSnapPoint(teams.length, ITEM_HEIGHT, 0);
const heightWithHeader = TITLE_HEIGHT + itemsHeight;
height = Math.min(maxHeight, heightWithHeader);
}
bottomSheet({
closeButtonId: 'close-team_list',
renderContent,
snapPoints: [height, 10],
theme,
title,
});
}
export async function dismissBottomSheet(alternativeScreen = Screens.BOTTOM_SHEET) {
DeviceEventEmitter.emit(Events.CLOSE_BOTTOM_SHEET);
await NavigationStore.waitUntilScreensIsRemoved(alternativeScreen);

View File

@@ -75,7 +75,7 @@ function TeamList({
textColor={theme.sidebarText}
iconBackgroundColor={changeOpacity(theme.sidebarText, 0.16)}
iconTextColor={theme.sidebarText}
onTeamAdded={onTeamAdded}
onPress={onTeamAdded}
/>
</View>
);

View File

@@ -5,13 +5,13 @@ import {useIntl} from 'react-intl';
import {Text, TouchableOpacity, View} from 'react-native';
import Animated from 'react-native-reanimated';
import {useServerUrl} from '@app/context/server';
import {useGalleryItem} from '@app/hooks/gallery';
import {openGalleryAtIndex} from '@app/utils/gallery';
import {GalleryInit} from '@context/gallery';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {useGalleryItem} from '@hooks/gallery';
import NetworkManager from '@managers/network_manager';
import {openGalleryAtIndex} from '@utils/gallery';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import {displayUsername} from '@utils/user';

View File

@@ -519,8 +519,10 @@
"mobile.search.modifier.in": "a specific channel",
"mobile.search.modifier.on": "a specific date",
"mobile.search.modifier.phrases": "messages with phrases",
"mobile.search.recent_title": "Recent searches in {teamName}",
"mobile.search.show_less": "Show less",
"mobile.search.show_more": "Show more",
"mobile.search.team.select": "Select a team to search",
"mobile.server_identifier.exists": "You are already connected to this server.",
"mobile.server_link.error.text": "The link could not be found on this server.",
"mobile.server_link.error.title": "Link Error",