[Gekidou MM-47652] Add Create DM Add Bottom Panel (#6699)

This commit is contained in:
Jason Frerich 2022-12-02 14:44:38 -06:00 committed by GitHub
parent 25fcb066a7
commit e4c38bd5a0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 443 additions and 282 deletions

View file

@ -0,0 +1,265 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {LayoutChangeEvent, Platform, ScrollView, useWindowDimensions, View} from 'react-native';
import Animated, {useAnimatedStyle, useDerivedValue, useSharedValue, withTiming} from 'react-native-reanimated';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import Toast from '@components/toast';
import {General} from '@constants';
import {useTheme} from '@context/theme';
import {useIsTablet, useKeyboardHeightWithDuration} from '@hooks/device';
import Button from '@screens/bottom_sheet/button';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import SelectedUser, {USER_CHIP_BOTTOM_MARGIN, USER_CHIP_HEIGHT} from './selected_user';
type Props = {
/**
* Name of the button Icon
*/
buttonIcon: string;
/*
* Text displayed on the action button
*/
buttonText: string;
/**
* the height of the parent container
*/
containerHeight?: number;
/**
* the Y position of the first view in the parent container
*/
modalPosition?: number;
/**
* A handler function that will select or deselect a user when clicked on.
*/
onPress: (selectedId?: {[id: string]: boolean}) => void;
/**
* A handler function that will deselect a user when clicked on.
*/
onRemove: (id: string) => void;
/**
* An object mapping user ids to a falsey value indicating whether or not they have been selected.
*/
selectedIds: {[id: string]: UserProfile};
/**
* callback to set the value of showToast
*/
setShowToast: (show: boolean) => void;
/**
* show the toast
*/
showToast: boolean;
/**
* How to display the names of users.
*/
teammateNameDisplay: string;
/**
* toast Icon
*/
toastIcon?: string;
/**
* toast Message
*/
toastMessage: string;
}
const BUTTON_HEIGHT = 48;
const CHIP_HEIGHT_WITH_MARGIN = USER_CHIP_HEIGHT + USER_CHIP_BOTTOM_MARGIN;
const EXPOSED_CHIP_HEIGHT = 0.33 * USER_CHIP_HEIGHT;
const MAX_CHIP_ROWS = 2;
const SCROLL_PADDING_TOP = 20;
const PANEL_MAX_HEIGHT = SCROLL_PADDING_TOP + (CHIP_HEIGHT_WITH_MARGIN * MAX_CHIP_ROWS) + EXPOSED_CHIP_HEIGHT;
const TABLET_MARGIN_BOTTOM = 20;
const TOAST_BOTTOM_MARGIN = 24;
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return {
container: {
borderBottomWidth: 0,
borderColor: changeOpacity(theme.centerChannelColor, 0.16),
borderTopLeftRadius: 12,
borderTopRightRadius: 12,
borderWidth: 1,
maxHeight: PANEL_MAX_HEIGHT + BUTTON_HEIGHT,
overflow: 'hidden',
paddingHorizontal: 20,
shadowColor: theme.centerChannelColor,
shadowOffset: {
width: 0,
height: 8,
},
shadowOpacity: 0.16,
shadowRadius: 24,
},
toast: {
backgroundColor: theme.centerChannelColor,
},
users: {
paddingTop: SCROLL_PADDING_TOP,
paddingBottom: 12,
flexDirection: 'row',
flexGrow: 1,
flexWrap: 'wrap',
},
message: {
color: changeOpacity(theme.centerChannelColor, 0.6),
fontSize: 12,
marginRight: 5,
marginTop: 10,
marginBottom: 2,
},
};
});
export default function SelectedUsers({
buttonIcon, buttonText, containerHeight = 0,
modalPosition = 0, onPress, onRemove,
selectedIds, setShowToast, showToast = false,
teammateNameDisplay, toastIcon, toastMessage,
}: Props) {
const theme = useTheme();
const style = getStyleFromTheme(theme);
const isTablet = useIsTablet();
const keyboard = useKeyboardHeightWithDuration();
const insets = useSafeAreaInsets();
const dimensions = useWindowDimensions();
const panelHeight = useSharedValue(0);
const [isVisible, setIsVisible] = useState(false);
const numberSelectedIds = Object.keys(selectedIds).length;
const bottomSpace = (dimensions.height - containerHeight - modalPosition);
const users = useMemo(() => {
const u = [];
for (const id of Object.keys(selectedIds)) {
if (!selectedIds[id]) {
continue;
}
u.push(
<SelectedUser
key={id}
user={selectedIds[id]}
teammateNameDisplay={teammateNameDisplay}
onRemove={onRemove}
testID='create_direct_message.selected_user'
/>,
);
}
return u;
}, [selectedIds, teammateNameDisplay, onRemove]);
const totalPanelHeight = useDerivedValue(() => (
isVisible ? panelHeight.value + BUTTON_HEIGHT : 0
), [isVisible, isTablet]);
const marginBottom = useMemo(() => {
let margin = keyboard.height && Platform.OS === 'ios' ? keyboard.height - insets.bottom : 0;
if (isTablet) {
margin = keyboard.height ? (keyboard.height - bottomSpace - insets.bottom) : 0;
}
return margin;
}, [keyboard, isTablet, insets.bottom, bottomSpace]);
const handlePress = useCallback(() => {
onPress();
}, [onPress]);
const onLayout = useCallback((e: LayoutChangeEvent) => {
panelHeight.value = Math.min(PANEL_MAX_HEIGHT, e.nativeEvent.layout.height);
}, []);
const androidMaxHeight = Platform.select({
android: {
maxHeight: isVisible ? undefined : 0,
},
});
const animatedContainerStyle = useAnimatedStyle(() => ({
marginBottom: withTiming(marginBottom, {duration: keyboard.duration}),
paddingBottom: (isVisible || Platform.OS === 'android') ? TABLET_MARGIN_BOTTOM + insets.bottom : 0,
backgroundColor: isVisible ? theme.centerChannelBg : 'transparent',
...androidMaxHeight,
}), [marginBottom, keyboard.duration, isVisible, theme.centerChannelBg]);
const animatedToastStyle = useAnimatedStyle(() => {
return {
bottom: TOAST_BOTTOM_MARGIN + totalPanelHeight.value,
opacity: withTiming(showToast ? 1 : 0, {duration: 250}),
position: 'absolute',
};
}, [showToast, keyboard]);
const animatedViewStyle = useAnimatedStyle(() => ({
height: withTiming(totalPanelHeight.value, {duration: 250}),
borderWidth: isVisible ? 1 : 0,
maxHeight: isVisible ? PANEL_MAX_HEIGHT + BUTTON_HEIGHT : 0,
}), [totalPanelHeight.value, isVisible]);
const animatedButtonStyle = useAnimatedStyle(() => ({
opacity: withTiming(isVisible ? 1 : 0, {duration: isVisible ? 500 : 100}),
}), [isVisible]);
useEffect(() => {
setIsVisible(numberSelectedIds > 0);
}, [numberSelectedIds > 0]);
// This effect hides the toast after 4 seconds
useEffect(() => {
let timer: NodeJS.Timeout;
if (showToast) {
timer = setTimeout(() => {
setShowToast(false);
}, 4000);
}
return () => clearTimeout(timer);
}, [showToast]);
return (
<Animated.View style={animatedContainerStyle}>
{showToast &&
<Toast
animatedStyle={animatedToastStyle}
iconName={toastIcon}
style={style.toast}
message={toastMessage}
/>
}
<Animated.View style={[style.container, animatedViewStyle]}>
<ScrollView>
<View
style={style.users}
onLayout={onLayout}
>
{users}
</View>
</ScrollView>
<Animated.View style={animatedButtonStyle}>
<Button
onPress={handlePress}
icon={buttonIcon}
text={buttonText}
disabled={numberSelectedIds > General.MAX_USERS_IN_GM}
/>
</Animated.View>
</Animated.View>
</Animated.View>
);
}

View file

@ -8,8 +8,10 @@ import {
TouchableOpacity,
View,
} from 'react-native';
import Animated, {FadeIn, FadeOut} from 'react-native-reanimated';
import CompassIcon from '@components/compass_icon';
import ProfilePicture from '@components/profile_picture';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
@ -38,6 +40,10 @@ type Props = {
testID?: string;
}
export const USER_CHIP_HEIGHT = 32;
export const USER_CHIP_BOTTOM_MARGIN = 8;
const FADE_DURATION = 100;
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return {
container: {
@ -45,16 +51,22 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
justifyContent: 'center',
flexDirection: 'row',
borderRadius: 16,
height: USER_CHIP_HEIGHT,
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
marginBottom: 8,
marginRight: 10,
paddingLeft: 12,
paddingVertical: 8,
paddingRight: 7,
marginBottom: USER_CHIP_BOTTOM_MARGIN,
marginRight: 8,
paddingHorizontal: 7,
},
remove: {
justifyContent: 'center',
marginLeft: 7,
},
profileContainer: {
flexDirection: 'row',
alignItems: 'center',
marginRight: 8,
color: theme.centerChannelColor,
},
text: {
color: theme.centerChannelColor,
...typography('Body', 100, 'SemiBold'),
@ -76,11 +88,22 @@ export default function SelectedUser({
onRemove(user.id);
}, [onRemove, user.id]);
const userItemTestID = `${testID}.${user.id}`;
return (
<View
<Animated.View
entering={FadeIn.duration(FADE_DURATION)}
exiting={FadeOut.duration(FADE_DURATION)}
style={style.container}
testID={`${testID}.${user.id}`}
>
<View style={style.profileContainer}>
<ProfilePicture
author={user}
size={20}
iconSize={20}
testID={`${userItemTestID}.profile_picture`}
/>
</View>
<Text
style={style.text}
testID={`${testID}.${user.id}.display_name`}
@ -94,10 +117,10 @@ export default function SelectedUser({
>
<CompassIcon
name='close-circle'
size={17}
size={18}
color={changeOpacity(theme.centerChannelColor, 0.32)}
/>
</TouchableOpacity>
</View>
</Animated.View>
);
}

View file

@ -40,11 +40,11 @@ exports[`components/channel_list_row should show no results 1`] = `
"username": "johndoe",
},
],
"first": true,
"id": "J",
},
]
}
extraData={false}
getItem={[Function]}
getItemCount={[Function]}
initialNumToRender={15}
@ -237,7 +237,7 @@ exports[`components/channel_list_row should show no results 1`] = `
}
>
<Icon
color="rgba(63,67,80,0.1024)"
color="rgba(63,67,80,0.32)"
name="circle-outline"
size={28}
/>
@ -312,11 +312,11 @@ exports[`components/channel_list_row should show results and tutorial 1`] = `
"username": "johndoe",
},
],
"first": true,
"id": "J",
},
]
}
extraData={false}
getItem={[Function]}
getItemCount={[Function]}
initialNumToRender={15}
@ -509,7 +509,7 @@ exports[`components/channel_list_row should show results and tutorial 1`] = `
}
>
<Icon
color="rgba(63,67,80,0.1024)"
color="rgba(63,67,80,0.32)"
name="circle-outline"
size={28}
/>
@ -584,11 +584,11 @@ exports[`components/channel_list_row should show results no tutorial 1`] = `
"username": "johndoe",
},
],
"first": true,
"id": "J",
},
]
}
extraData={false}
getItem={[Function]}
getItemCount={[Function]}
initialNumToRender={15}
@ -781,7 +781,7 @@ exports[`components/channel_list_row should show results no tutorial 1`] = `
}
>
<Icon
color="rgba(63,67,80,0.1024)"
color="rgba(63,67,80,0.32)"
name="circle-outline"
size={28}
/>

View file

@ -57,9 +57,10 @@ export function createProfilesSections(profiles: UserProfile[]) {
sectionKeys.sort();
return sectionKeys.map((sectionKey) => {
return sectionKeys.map((sectionKey, index) => {
return {
id: sectionKey,
first: index === 0,
data: sections[sectionKey],
};
});
@ -70,11 +71,6 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
list: {
backgroundColor: theme.centerChannelBg,
flex: 1,
...Platform.select({
android: {
marginBottom: 20,
},
}),
},
container: {
flexGrow: 1,
@ -174,12 +170,13 @@ export default function UserList({
return (
<UserListRow
key={item.id}
highlight={section?.id === data?.[0].id && index === 0}
highlight={section?.first && index === 0}
id={item.id}
isMyUser={currentUserId === item.id}
onPress={handleSelectProfile}
onLongPress={openUserProfile}
selectable={canAdd}
disabled={!canAdd}
selectable={true}
selected={selected}
testID='create_direct_message.user_list.user_item'
teammateNameDisplay={teammateNameDisplay}
@ -187,7 +184,7 @@ export default function UserList({
user={item}
/>
);
}, [selectedIds, currentUserId, handleSelectProfile, teammateNameDisplay, tutorialWatched, data]);
}, [selectedIds, currentUserId, handleSelectProfile, teammateNameDisplay, tutorialWatched]);
const renderLoading = useCallback(() => {
if (!loading) {
@ -230,7 +227,6 @@ export default function UserList({
<FlatList
contentContainerStyle={style.container}
data={items}
extraData={selectedIds}
keyboardShouldPersistTaps='always'
{...keyboardDismissProp}
keyExtractor={keyExtractor}
@ -251,7 +247,6 @@ export default function UserList({
return (
<SectionList
contentContainerStyle={style.container}
extraData={loading ? false : selectedIds}
keyboardShouldPersistTaps='always'
{...keyboardDismissProp}
keyExtractor={keyExtractor}

View file

@ -95,7 +95,7 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
const DISABLED_OPACITY = 0.32;
const DEFAULT_ICON_OPACITY = 0.32;
export default function UserListRow({
function UserListRow({
id,
isMyUser,
highlight,
@ -124,7 +124,7 @@ export default function UserListRow({
const bounds: TutorialItemBounds = {
startX: x - 20,
startY: y,
endX: x + w + 20,
endX: x + w,
endY: y + h,
};
if (viewRef.current) {
@ -160,7 +160,7 @@ export default function UserListRow({
return null;
}
const iconOpacity = DEFAULT_ICON_OPACITY * (disabled ? 1 : DISABLED_OPACITY);
const iconOpacity = DEFAULT_ICON_OPACITY * (disabled ? DISABLED_OPACITY : 1);
const color = selected ? theme.buttonBg : changeOpacity(theme.centerChannelColor, iconOpacity);
return (
<View style={style.selector}>
@ -267,3 +267,4 @@ export default function UserListRow({
);
}
export default React.memo(UserListRow);

View file

@ -45,21 +45,21 @@ export function useIsTablet() {
return Device.IS_TABLET && !isSplitView;
}
export function useKeyboardHeight(keyboardTracker?: React.RefObject<KeyboardTrackingViewRef>) {
const [keyboardHeight, setKeyboardHeight] = useState(0);
export function useKeyboardHeightWithDuration(keyboardTracker?: React.RefObject<KeyboardTrackingViewRef>) {
const [keyboardHeight, setKeyboardHeight] = useState({height: 0, duration: 0});
const updateTimeout = useRef<NodeJS.Timeout | null>(null);
const insets = useSafeAreaInsets();
// This is a magic number. With tracking view, to properly get the final position, this had to be added.
const KEYBOARD_TRACKINGVIEW_SEPARATION = 4;
const updateValue = (v: number) => {
const updateValue = (height: number, duration: number) => {
if (updateTimeout.current != null) {
clearTimeout(updateTimeout.current);
updateTimeout.current = null;
}
updateTimeout.current = setTimeout(() => {
setKeyboardHeight(v);
setKeyboardHeight({height, duration});
updateTimeout.current = null;
}, 200);
};
@ -69,21 +69,21 @@ export function useKeyboardHeight(keyboardTracker?: React.RefObject<KeyboardTrac
if (keyboardTracker?.current) {
const props = await keyboardTracker.current.getNativeProps();
if (props.keyboardHeight) {
updateValue((props.trackingViewHeight + props.keyboardHeight) - KEYBOARD_TRACKINGVIEW_SEPARATION);
updateValue((props.trackingViewHeight + props.keyboardHeight) - KEYBOARD_TRACKINGVIEW_SEPARATION, event.duration);
} else {
updateValue((props.trackingViewHeight + insets.bottom) - KEYBOARD_TRACKINGVIEW_SEPARATION);
updateValue((props.trackingViewHeight + insets.bottom) - KEYBOARD_TRACKINGVIEW_SEPARATION, event.duration);
}
} else {
updateValue(event.endCoordinates.height);
setKeyboardHeight({height: event.endCoordinates.height, duration: event.duration});
}
});
const hide = Keyboard.addListener(Platform.select({ios: 'keyboardWillHide', default: 'keyboardDidHide'}), () => {
const hide = Keyboard.addListener(Platform.select({ios: 'keyboardWillHide', default: 'keyboardDidHide'}), (event) => {
if (updateTimeout.current != null) {
clearTimeout(updateTimeout.current);
updateTimeout.current = null;
}
setKeyboardHeight(0);
setKeyboardHeight({height: 0, duration: event.duration});
});
return () => {
@ -95,9 +95,15 @@ export function useKeyboardHeight(keyboardTracker?: React.RefObject<KeyboardTrac
return keyboardHeight;
}
export function useKeyboardHeight(keyboardTracker?: React.RefObject<KeyboardTrackingViewRef>) {
const {height} = useKeyboardHeightWithDuration(keyboardTracker);
return height;
}
export function useModalPosition(viewRef: RefObject<View>, deps?: React.DependencyList) {
const [modalPosition, setModalPosition] = useState(0);
const isTablet = useIsTablet();
const height = useKeyboardHeight();
useEffect(() => {
if (Platform.OS === 'ios' && isTablet) {
@ -107,7 +113,7 @@ export function useModalPosition(viewRef: RefObject<View>, deps?: React.Dependen
}
});
}
}, deps);
}, [...(deps || []), isTablet, height]);
return modalPosition;
}

View file

@ -27,6 +27,7 @@ const styles = StyleSheet.create({
width: 24,
height: 24,
marginTop: 2,
marginRight: 8,
},
});

View file

@ -2,8 +2,8 @@
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useMemo, useReducer, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {Keyboard, Platform, View} from 'react-native';
import {defineMessages, useIntl} from 'react-intl';
import {Keyboard, LayoutChangeEvent, Platform, View} from 'react-native';
import {SafeAreaView} from 'react-native-safe-area-context';
import {makeDirectChannel, makeGroupChannel} from '@actions/remote/channel';
@ -11,11 +11,13 @@ import {fetchProfiles, fetchProfilesInTeam, searchProfiles} from '@actions/remot
import CompassIcon from '@components/compass_icon';
import Loading from '@components/loading';
import Search from '@components/search';
import SelectedUsers from '@components/selected_users';
import UserList from '@components/user_list';
import {General} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {debounce} from '@helpers/api/general';
import {useModalPosition} from '@hooks/device';
import useNavButtonPressed from '@hooks/navigation_button_pressed';
import {t} from '@i18n';
import {dismissModal, setButtons} from '@screens/navigation';
@ -24,9 +26,25 @@ import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme}
import {typography} from '@utils/typography';
import {displayUsername, filterProfilesMatchingTerm} from '@utils/user';
import SelectedUsers from './selected_users';
const messages = defineMessages({
dm: {
id: t('mobile.open_dm.error'),
defaultMessage: "We couldn't open a direct message with {displayName}. Please check your connection and try again.",
},
gm: {
id: t('mobile.open_gm.error'),
defaultMessage: "We couldn't open a group message with those users. Please check your connection and try again.",
},
buttonText: {
id: t('mobile.create_direct_message.start'),
defaultMessage: 'Start Conversation',
},
toastMessage: {
id: t('mobile.create_direct_message.max_limit_reached'),
defaultMessage: 'Group messages are limited to {maxCount} members',
},
});
const START_BUTTON = 'start-conversation';
const CLOSE_BUTTON = 'close-dms';
type Props = {
@ -43,7 +61,7 @@ const close = () => {
dismissModal();
};
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
flex: 1,
@ -82,6 +100,13 @@ function reduceProfiles(state: UserProfile[], action: {type: 'add'; values?: Use
return state;
}
function removeProfileFromList(list: {[id: string]: UserProfile}, id: string) {
const newSelectedIds = Object.assign({}, list);
Reflect.deleteProperty(newSelectedIds, id);
return newSelectedIds;
}
export default function CreateDirectMessage({
componentId,
currentTeamId,
@ -100,6 +125,8 @@ export default function CreateDirectMessage({
const next = useRef(true);
const page = useRef(-1);
const mounted = useRef(false);
const mainView = useRef<View>(null);
const modalPosition = useModalPosition(mainView);
const [profiles, dispatchProfiles] = useReducer(reduceProfiles, []);
const [searchResults, setSearchResults] = useState<UserProfile[]>([]);
@ -107,6 +134,8 @@ export default function CreateDirectMessage({
const [term, setTerm] = useState('');
const [startingConversation, setStartingConversation] = useState(false);
const [selectedIds, setSelectedIds] = useState<{[id: string]: UserProfile}>({});
const [showToast, setShowToast] = useState(false);
const [containerHeight, setContainerHeight] = useState(0);
const selectedCount = Object.keys(selectedIds).length;
const isSearch = Boolean(term);
@ -123,6 +152,28 @@ export default function CreateDirectMessage({
}
};
const data = useMemo(() => {
if (term) {
const exactMatches: UserProfile[] = [];
const filterByTerm = (p: UserProfile) => {
if (selectedCount > 0 && p.id === currentUserId) {
return false;
}
if (p.username === term || p.username.startsWith(term)) {
exactMatches.push(p);
return false;
}
return true;
};
const results = filterProfilesMatchingTerm(searchResults, term).filter(filterByTerm);
return [...exactMatches, ...results];
}
return profiles;
}, [term, isSearch && selectedCount, isSearch && searchResults, profiles]);
const clearSearch = useCallback(() => {
setTerm('');
setSearchResults([]);
@ -140,32 +191,16 @@ export default function CreateDirectMessage({
}, 100), [loading, isSearch, restrictDirectMessage, serverUrl, currentTeamId]);
const handleRemoveProfile = useCallback((id: string) => {
const newSelectedIds = Object.assign({}, selectedIds);
Reflect.deleteProperty(newSelectedIds, id);
setSelectedIds(newSelectedIds);
}, [selectedIds]);
setSelectedIds((current) => removeProfileFromList(current, id));
}, []);
const createDirectChannel = useCallback(async (id: string): Promise<boolean> => {
const user = selectedIds[id];
const displayName = displayUsername(user, intl.locale, teammateNameDisplay);
const result = await makeDirectChannel(serverUrl, id, displayName);
if (result.error) {
alertErrorWithFallback(
intl,
result.error,
{
id: 'mobile.open_dm.error',
defaultMessage: "We couldn't open a direct message with {displayName}. Please check your connection and try again.",
},
{
displayName,
},
);
alertErrorWithFallback(intl, result.error, messages.dm);
}
return !result.error;
@ -175,14 +210,7 @@ export default function CreateDirectMessage({
const result = await makeGroupChannel(serverUrl, ids);
if (result.error) {
alertErrorWithFallback(
intl,
result.error,
{
id: t('mobile.open_gm.error'),
defaultMessage: "We couldn't open a group message with those users. Please check your connection and try again.",
},
);
alertErrorWithFallback(intl, result.error, messages.gm);
}
return !result.error;
@ -213,11 +241,6 @@ export default function CreateDirectMessage({
}, [startingConversation, selectedIds, createGroupChannel, createDirectChannel]);
const handleSelectProfile = useCallback((user: UserProfile) => {
if (selectedIds[user.id]) {
handleRemoveProfile(user.id);
return;
}
if (user.id === currentUserId) {
const selectedId = {
[currentUserId]: true,
@ -225,22 +248,28 @@ export default function CreateDirectMessage({
startConversation(selectedId);
} else {
const wasSelected = selectedIds[user.id];
if (!wasSelected && selectedCount >= General.MAX_USERS_IN_GM) {
return;
}
const newSelectedIds = Object.assign({}, selectedIds);
if (!wasSelected) {
newSelectedIds[user.id] = user;
}
setSelectedIds(newSelectedIds);
clearSearch();
setSelectedIds((current) => {
if (current[user.id]) {
return removeProfileFromList(current, user.id);
}
const wasSelected = current[user.id];
if (!wasSelected && selectedCount >= General.MAX_USERS_IN_GM) {
setShowToast(true);
return current;
}
const newSelectedIds = Object.assign({}, current);
if (!wasSelected) {
newSelectedIds[user.id] = user;
}
return newSelectedIds;
});
}
}, [selectedIds, currentUserId, handleRemoveProfile, startConversation, clearSearch]);
}, [currentUserId, clearSearch]);
const searchUsers = useCallback(async (searchTerm: string) => {
const lowerCasedTerm = searchTerm.toLowerCase();
@ -253,12 +282,12 @@ export default function CreateDirectMessage({
results = await searchProfiles(serverUrl, lowerCasedTerm, {allow_inactive: true});
}
let data: UserProfile[] = [];
let searchData: UserProfile[] = [];
if (results.data) {
data = results.data;
searchData = results.data;
}
setSearchResults(data);
setSearchResults(searchData);
setLoading(false);
}, [restrictDirectMessage, serverUrl, currentTeamId]);
@ -266,6 +295,10 @@ export default function CreateDirectMessage({
searchUsers(term);
}, [searchUsers, term]);
const onLayout = useCallback((e: LayoutChangeEvent) => {
setContainerHeight(e.nativeEvent.layout.height);
}, []);
const onSearch = useCallback((text: string) => {
if (text) {
setTerm(text);
@ -281,7 +314,7 @@ export default function CreateDirectMessage({
}
}, [searchUsers, clearSearch]);
const updateNavigationButtons = useCallback(async (startEnabled: boolean) => {
const updateNavigationButtons = useCallback(async () => {
const closeIcon = await CompassIcon.getImageSource('close', 24, theme.sidebarHeaderTextColor);
setButtons(componentId, {
leftButtons: [{
@ -289,23 +322,14 @@ export default function CreateDirectMessage({
icon: closeIcon,
testID: 'close.create_direct_message.button',
}],
rightButtons: [{
color: theme.sidebarHeaderTextColor,
id: START_BUTTON,
text: formatMessage({id: 'mobile.create_direct_message.start', defaultMessage: 'Start'}),
showAsAction: 'always',
enabled: startEnabled,
testID: 'create_direct_message.start.button',
}],
});
}, [intl.locale, theme]);
useNavButtonPressed(START_BUTTON, componentId, startConversation, [startConversation]);
useNavButtonPressed(CLOSE_BUTTON, componentId, close, [close]);
useEffect(() => {
mounted.current = true;
updateNavigationButtons(false);
updateNavigationButtons();
getProfiles();
return () => {
mounted.current = false;
@ -313,31 +337,8 @@ export default function CreateDirectMessage({
}, []);
useEffect(() => {
const canStart = selectedCount > 0 && !startingConversation;
updateNavigationButtons(canStart);
}, [selectedCount > 0, startingConversation, updateNavigationButtons]);
const data = useMemo(() => {
if (term) {
const exactMatches: UserProfile[] = [];
const filterByTerm = (p: UserProfile) => {
if (selectedCount > 0 && p.id === currentUserId) {
return false;
}
if (p.username === term || p.username.startsWith(term)) {
exactMatches.push(p);
return false;
}
return true;
};
const results = filterProfilesMatchingTerm(searchResults, term).filter(filterByTerm);
return [...exactMatches, ...results];
}
return profiles;
}, [term, isSearch && selectedCount, isSearch && searchResults, profiles]);
setShowToast(selectedCount >= General.MAX_USERS_IN_GM);
}, [selectedCount >= General.MAX_USERS_IN_GM]);
if (startingConversation) {
return (
@ -351,12 +352,15 @@ export default function CreateDirectMessage({
<SafeAreaView
style={style.container}
testID='create_direct_message.screen'
onLayout={onLayout}
ref={mainView}
edges={['top', 'left', 'right']}
>
<View style={style.searchBar}>
<Search
testID='create_direct_message.search_bar'
placeholder={intl.formatMessage({id: 'search_bar.search', defaultMessage: 'Search'})}
cancelButtonTitle={intl.formatMessage({id: 'mobile.post.cancel', defaultMessage: 'Cancel'})}
placeholder={formatMessage({id: 'search_bar.search', defaultMessage: 'Search'})}
cancelButtonTitle={formatMessage({id: 'mobile.post.cancel', defaultMessage: 'Cancel'})}
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.5)}
onChangeText={onSearch}
onSubmitEditing={search}
@ -366,15 +370,6 @@ export default function CreateDirectMessage({
value={term}
/>
</View>
{selectedCount > 0 &&
<SelectedUsers
selectedIds={selectedIds}
warnCount={General.MAX_USERS_IN_GM - 2}
maxCount={General.MAX_USERS_IN_GM}
onRemove={handleRemoveProfile}
teammateNameDisplay={teammateNameDisplay}
/>
}
<UserList
currentUserId={currentUserId}
handleSelectProfile={handleSelectProfile}
@ -388,6 +383,20 @@ export default function CreateDirectMessage({
testID='create_direct_message.user_list'
tutorialWatched={tutorialWatched}
/>
<SelectedUsers
containerHeight={containerHeight}
modalPosition={modalPosition}
showToast={showToast}
setShowToast={setShowToast}
toastIcon={'check'}
toastMessage={formatMessage(messages.toastMessage, {maxCount: General.MAX_USERS_IN_GM})}
selectedIds={selectedIds}
onRemove={handleRemoveProfile}
teammateNameDisplay={teammateNameDisplay}
onPress={startConversation}
buttonIcon={'forum-outline'}
buttonText={formatMessage(messages.buttonText)}
/>
</SafeAreaView>
);
}

View file

@ -1,138 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import {View} from 'react-native';
import FormattedText from '@components/formatted_text';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import SelectedUser from './selected_user';
type Props = {
/*
* An object mapping user ids to a falsey value indicating whether or not they've been selected.
*/
selectedIds: {[id: string]: UserProfile};
/*
* How to display the names of users.
*/
teammateNameDisplay: string;
/*
* The number of users that will be selected when we start to display a message indicating
* the remaining number of users that can be selected.
*/
warnCount: number;
/*
* The maximum number of users that can be selected.
*/
maxCount: number;
/*
* A handler function that will deselect a user when clicked on.
*/
onRemove: (id: string) => void;
}
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return {
container: {
marginHorizontal: 12,
},
users: {
alignItems: 'flex-start',
flexDirection: 'row',
flexWrap: 'wrap',
},
message: {
color: changeOpacity(theme.centerChannelColor, 0.6),
fontSize: 12,
marginRight: 5,
marginTop: 10,
marginBottom: 2,
},
};
});
export default function SelectedUsers({
selectedIds,
teammateNameDisplay,
warnCount,
maxCount,
onRemove,
}: Props) {
const theme = useTheme();
const style = getStyleFromTheme(theme);
const users = useMemo(() => {
const u = [];
for (const id of Object.keys(selectedIds)) {
if (!selectedIds[id]) {
continue;
}
u.push(
<SelectedUser
key={id}
user={selectedIds[id]}
teammateNameDisplay={teammateNameDisplay}
onRemove={onRemove}
testID='create_direct_message.selected_user'
/>,
);
}
return u;
}, [selectedIds, teammateNameDisplay, onRemove]);
const showWarn = users.length >= warnCount && users.length < maxCount;
const message = useMemo(() => {
if (users.length >= maxCount) {
return (
<FormattedText
style={style.message}
id='mobile.create_direct_message.cannot_add_more'
defaultMessage='You cannot add more users'
/>
);
} else if (users.length >= warnCount) {
const remaining = maxCount - users.length;
if (remaining === 1) {
return (
<FormattedText
style={style.message}
id='mobile.create_direct_message.one_more'
defaultMessage='You can add 1 more user'
/>
);
}
return (
<FormattedText
style={style.message}
id='mobile.create_direct_message.add_more'
defaultMessage='You can add {remaining, number} more users'
values={{
remaining,
}}
/>
);
}
return null;
}, [users.length >= maxCount, showWarn && users.length, theme, maxCount]);
return (
<View style={style.container}>
<View style={style.users}>
{users}
</View>
{message}
</View>
);
}

View file

@ -296,6 +296,7 @@
"general_settings.display": "Display",
"general_settings.help": "Help",
"general_settings.notifications": "Notifications",
"general_settings.report_problem": "Report a Problem",
"get_post_link_modal.title": "Copy Link",
"global_threads.allThreads": "All Your Threads",
"global_threads.emptyThreads.message": "Any threads you are mentioned in or have participated in will show here along with any threads you have followed.",
@ -437,10 +438,8 @@
"mobile.components.select_server_view.proceed": "Proceed",
"mobile.create_channel": "Create",
"mobile.create_channel.title": "New channel",
"mobile.create_direct_message.add_more": "You can add {remaining, number} more users",
"mobile.create_direct_message.cannot_add_more": "You cannot add more users",
"mobile.create_direct_message.one_more": "You can add 1 more user",
"mobile.create_direct_message.start": "Start",
"mobile.create_direct_message.max_limit_reached": "Group messages are limited to {maxCount} members",
"mobile.create_direct_message.start": "Start Conversation",
"mobile.create_direct_message.you": "@{username} - you",
"mobile.create_post.read_only": "This channel is read-only.",
"mobile.custom_list.no_results": "No Results",
@ -474,7 +473,6 @@
"mobile.gallery.title": "{index} of {total}",
"mobile.integration_selector.loading_channels": "Loading channels...",
"mobile.integration_selector.loading_options": "Loading options...",
"mobile.integration_selector.loading_users": "Loading users...",
"mobile.ios.photos_permission_denied_description": "Upload photos and videos to your server or save them to your device. Open Settings to grant {applicationName} Read and Write access to your photo and video library.",
"mobile.ios.photos_permission_denied_title": "{applicationName} would like to access your photos",
"mobile.join_channel.error": "We couldn't join the channel {displayName}.",
@ -527,6 +525,7 @@
"mobile.onboarding.next": "Next",
"mobile.onboarding.sign_in": "Sign in",
"mobile.onboarding.sign_in_to_get_started": "Sign in to get started",
"mobile.open_dm.error": "We couldn't open a direct message with {displayName}. Please check your connection and try again.",
"mobile.open_gm.error": "We couldn't open a group message with those users. Please check your connection and try again.",
"mobile.participants.header": "Thread Participants",
"mobile.permission_denied_dismiss": "Don't Allow",