Compare commits

...

13 Commits

Author SHA1 Message Date
Jason Frerich
2c4195d1ed prep for performing the addmembers command 2022-10-26 14:46:15 -05:00
Jason Frerich
e0a279e155 update icon
update button text
2022-10-26 13:24:54 -05:00
Jason Frerich
ae819ec763 hook up add people screen 2022-10-26 13:16:05 -05:00
Jason Frerich
a7ae4cef41 create members_modal screen and reference from create_direct_message and
add_members screens
2022-10-26 13:02:04 -05:00
Jason Frerich
e4d8e61bb2 get db values in membersModal index instead of passing through as props 2022-10-26 12:24:59 -05:00
Jason Frerich
5289f151b4 add props for max and warn users counts 2022-10-26 12:19:42 -05:00
Jason Frerich
6f5861d441 extract MembersModal to its own component so that Add Members, Create
DM/GM, and ManageMembers components will not have to manage the search
and selection of profiles
2022-10-26 11:10:30 -05:00
Jason Frerich
af7558cc6e make onPress required and remove the callback wrapper 2022-10-25 08:57:04 -05:00
Jason Frerich
3715c5cbbe use all caps for constants 2022-10-25 08:50:56 -05:00
Jason Frerich
c7e4dc992d - sort style attributes
- update shadow values
2022-10-24 20:22:34 -05:00
Jason Frerich
fa497f6e3c - remove navigator Search Button and add button to bottom panel
- add margin when bottom sheet button has an icon
- add button with onPress, buttonIcon, and buttonText props
2022-10-24 19:36:08 -05:00
Jason Frerich
42f34727ec formating updates 2022-10-24 16:25:42 -05:00
Jason Frerich
dfb4a53a2d reformat comments and remove apostraphe so syntax highlighting works 2022-10-24 10:27:34 -05:00
13 changed files with 627 additions and 357 deletions

View File

@@ -127,6 +127,7 @@ export const MODAL_SCREENS_WITHOUT_BACK = new Set<string>([
BROWSE_CHANNELS, BROWSE_CHANNELS,
CHANNEL_INFO, CHANNEL_INFO,
CREATE_DIRECT_MESSAGE, CREATE_DIRECT_MESSAGE,
CHANNEL_ADD_PEOPLE,
CREATE_TEAM, CREATE_TEAM,
CUSTOM_STATUS, CUSTOM_STATUS,
EDIT_POST, EDIT_POST,
@@ -156,7 +157,6 @@ export const OVERLAY_SCREENS = new Set<string>([
]); ]);
export const NOT_READY = [ export const NOT_READY = [
CHANNEL_ADD_PEOPLE,
CHANNEL_MENTION, CHANNEL_MENTION,
CREATE_TEAM, CREATE_TEAM,
INTEGRATION_SELECTOR, INTEGRATION_SELECTOR,

View File

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

View File

@@ -0,0 +1,102 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useState} from 'react';
import {defineMessages, useIntl} from 'react-intl';
import {Keyboard} from 'react-native';
import {addMembersToChannel, makeDirectChannel, makeGroupChannel} from '@actions/remote/channel';
import {useServerUrl} from '@context/server';
import {t} from '@i18n';
import MembersModal from '@screens/members_modal';
import {dismissModal} from '@screens/navigation';
import {alertErrorWithFallback} from '@utils/draft';
import {displayUsername} from '@utils/user';
const messages = defineMessages({
dm: {
id: '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.channel_add_people.title'),
defaultMessage: 'Add Members',
},
});
type Props = {
componentId: string;
currentChannelId: string;
teammateNameDisplay: string;
}
const close = () => {
Keyboard.dismiss();
dismissModal();
};
export default function ChannelAddPeople({
componentId,
currentChannelId,
teammateNameDisplay,
}: Props) {
const serverUrl = useServerUrl();
const intl = useIntl();
const [startingConversation, setStartingConversation] = useState(false);
const [selectedIds, setSelectedIds] = useState<{[id: string]: UserProfile}>({});
const addMembers = useCallback(async (ids: string[]): Promise<boolean> => {
// addMembersToChannel(serverUrl: string, channelId: string, userIds: string[], postRootId = '', fetchOnly = false) {
console.log('currentChannelId', currentChannelId);
console.log('ids', ids);
const result = await addMembersToChannel(serverUrl, currentChannelId, ids);
if (result.error) {
alertErrorWithFallback(intl, result.error, messages.dm);
}
return !result.error;
}, [selectedIds, intl.locale, teammateNameDisplay, serverUrl]);
const startConversation = useCallback(async (selectedId?: {[id: string]: boolean}) => {
if (startingConversation) {
return;
}
setStartingConversation(true);
const idsToUse = selectedId ? Object.keys(selectedId) : Object.keys(selectedIds);
let success;
if (idsToUse.length === 0) {
success = false;
} else {
console.log('. IN HERE!');
success = await addMembers(idsToUse);
}
if (success) {
close();
} else {
setStartingConversation(false);
}
}, [startingConversation, selectedIds, addMembers]);
return (
<MembersModal
componentId={componentId}
selectUsersButtonIcon={'account-plus-outline'}
selectUsersButtonText={intl.formatMessage(messages.buttonText)}
selectUsersMax={7}
selectUsersWarn={5}
selectedIds={selectedIds}
setSelectedIds={setSelectedIds}
startConversation={startConversation}
startingConversation={startingConversation}
/>
);
}

View File

@@ -0,0 +1,21 @@
// 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 {observeCurrentChannelId} from '@app/queries/servers/system';
import {observeTeammateNameDisplay} from '@queries/servers/user';
import ChannelAddPeople from './channel_add_people';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
return {
currentChannelId: observeCurrentChannelId(database),
teammateNameDisplay: observeTeammateNameDisplay(database),
};
});
export default withDatabase(enhanced(ChannelAddPeople));

View File

@@ -1,40 +1,36 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import React, {useCallback, useEffect, useMemo, useReducer, useRef, useState} from 'react'; import React, {useCallback, useState} from 'react';
import {useIntl} from 'react-intl'; import {defineMessages, useIntl} from 'react-intl';
import {Keyboard, Platform, View} from 'react-native'; import {Keyboard} from 'react-native';
import {SafeAreaView} from 'react-native-safe-area-context';
import {makeDirectChannel, makeGroupChannel} from '@actions/remote/channel'; import {makeDirectChannel, makeGroupChannel} from '@actions/remote/channel';
import {fetchProfiles, fetchProfilesInTeam, searchProfiles} from '@actions/remote/user';
import CompassIcon from '@components/compass_icon';
import Loading from '@components/loading';
import Search from '@components/search';
import {General} from '@constants';
import {useServerUrl} from '@context/server'; import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {debounce} from '@helpers/api/general';
import useNavButtonPressed from '@hooks/navigation_button_pressed';
import {t} from '@i18n'; import {t} from '@i18n';
import {dismissModal, setButtons} from '@screens/navigation'; import MembersModal from '@screens/members_modal';
import {dismissModal} from '@screens/navigation';
import {alertErrorWithFallback} from '@utils/draft'; import {alertErrorWithFallback} from '@utils/draft';
import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme'; import {displayUsername} from '@utils/user';
import {displayUsername, filterProfilesMatchingTerm} from '@utils/user';
import SelectedUsers from './selected_users'; const messages = defineMessages({
import UserList from './user_list'; dm: {
id: 'mobile.open_dm.error',
const START_BUTTON = 'start-conversation'; defaultMessage: "We couldn't open a direct message with {displayName}. Please check your connection and try again.",
const CLOSE_BUTTON = 'close-dms'; },
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('create_direct_message.start'),
defaultMessage: 'Start Conversation',
},
});
type Props = { type Props = {
componentId: string; componentId: string;
currentTeamId: string;
currentUserId: string;
restrictDirectMessage: boolean;
teammateNameDisplay: string; teammateNameDisplay: string;
tutorialWatched: boolean;
} }
const close = () => { const close = () => {
@@ -42,148 +38,31 @@ const close = () => {
dismissModal(); dismissModal();
}; };
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return {
container: {
flex: 1,
},
searchBar: {
marginLeft: 12,
marginRight: Platform.select({ios: 4, default: 12}),
marginVertical: 12,
},
loadingContainer: {
alignItems: 'center',
backgroundColor: theme.centerChannelBg,
height: 70,
justifyContent: 'center',
},
loadingText: {
color: changeOpacity(theme.centerChannelColor, 0.6),
},
noResultContainer: {
flexGrow: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
noResultText: {
fontSize: 26,
color: changeOpacity(theme.centerChannelColor, 0.5),
},
};
});
function reduceProfiles(state: UserProfile[], action: {type: 'add'; values?: UserProfile[]}) {
if (action.type === 'add' && action.values?.length) {
return [...state, ...action.values];
}
return state;
}
export default function CreateDirectMessage({ export default function CreateDirectMessage({
componentId, componentId,
currentTeamId,
currentUserId,
restrictDirectMessage,
teammateNameDisplay, teammateNameDisplay,
tutorialWatched,
}: Props) { }: Props) {
const serverUrl = useServerUrl(); const serverUrl = useServerUrl();
const theme = useTheme();
const style = getStyleFromTheme(theme);
const intl = useIntl(); const intl = useIntl();
const {formatMessage} = intl;
const searchTimeoutId = useRef<NodeJS.Timeout | null>(null);
const next = useRef(true);
const page = useRef(-1);
const mounted = useRef(false);
const [profiles, dispatchProfiles] = useReducer(reduceProfiles, []);
const [searchResults, setSearchResults] = useState<UserProfile[]>([]);
const [loading, setLoading] = useState(false);
const [term, setTerm] = useState('');
const [startingConversation, setStartingConversation] = useState(false); const [startingConversation, setStartingConversation] = useState(false);
const [selectedIds, setSelectedIds] = useState<{[id: string]: UserProfile}>({}); const [selectedIds, setSelectedIds] = useState<{[id: string]: UserProfile}>({});
const selectedCount = Object.keys(selectedIds).length;
const isSearch = Boolean(term);
const loadedProfiles = ({users}: {users?: UserProfile[]}) => {
if (mounted.current) {
if (users && !users.length) {
next.current = false;
}
page.current += 1;
setLoading(false);
dispatchProfiles({type: 'add', values: users});
}
};
const clearSearch = useCallback(() => {
setTerm('');
setSearchResults([]);
}, []);
const getProfiles = useCallback(debounce(() => {
if (next.current && !loading && !term && mounted.current) {
setLoading(true);
if (restrictDirectMessage) {
fetchProfilesInTeam(serverUrl, currentTeamId, page.current + 1, General.PROFILE_CHUNK_SIZE).then(loadedProfiles);
} else {
fetchProfiles(serverUrl, page.current + 1, General.PROFILE_CHUNK_SIZE).then(loadedProfiles);
}
}
}, 100), [loading, isSearch, restrictDirectMessage, serverUrl, currentTeamId]);
const handleRemoveProfile = useCallback((id: string) => {
const newSelectedIds = Object.assign({}, selectedIds);
Reflect.deleteProperty(newSelectedIds, id);
setSelectedIds(newSelectedIds);
}, [selectedIds]);
const createDirectChannel = useCallback(async (id: string): Promise<boolean> => { const createDirectChannel = useCallback(async (id: string): Promise<boolean> => {
const user = selectedIds[id]; const user = selectedIds[id];
const displayName = displayUsername(user, intl.locale, teammateNameDisplay); const displayName = displayUsername(user, intl.locale, teammateNameDisplay);
const result = await makeDirectChannel(serverUrl, id, displayName); const result = await makeDirectChannel(serverUrl, id, displayName);
if (result.error) { if (result.error) {
alertErrorWithFallback( alertErrorWithFallback(intl, result.error, messages.dm, {displayName});
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,
},
);
} }
return !result.error; return !result.error;
}, [selectedIds, intl.locale, teammateNameDisplay, serverUrl]); }, [selectedIds, intl.locale, teammateNameDisplay, serverUrl]);
const createGroupChannel = useCallback(async (ids: string[]): Promise<boolean> => { const createGroupChannel = useCallback(async (ids: string[]): Promise<boolean> => {
const result = await makeGroupChannel(serverUrl, ids); const result = await makeGroupChannel(serverUrl, ids);
if (result.error) { if (result.error) {
alertErrorWithFallback( alertErrorWithFallback(intl, result.error, messages.gm);
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.",
},
);
} }
return !result.error; return !result.error;
}, [serverUrl]); }, [serverUrl]);
@@ -211,183 +90,18 @@ export default function CreateDirectMessage({
} }
}, [startingConversation, selectedIds, createGroupChannel, createDirectChannel]); }, [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,
};
startConversation(selectedId);
} else {
const wasSelected = selectedIds[user.id];
if (!wasSelected && selectedCount >= General.MAX_USERS_IN_GM - 1) {
return;
}
const newSelectedIds = Object.assign({}, selectedIds);
if (!wasSelected) {
newSelectedIds[user.id] = user;
}
setSelectedIds(newSelectedIds);
clearSearch();
}
}, [selectedIds, currentUserId, handleRemoveProfile, startConversation, clearSearch]);
const searchUsers = useCallback(async (searchTerm: string) => {
const lowerCasedTerm = searchTerm.toLowerCase();
setLoading(true);
let results;
if (restrictDirectMessage) {
results = await searchProfiles(serverUrl, lowerCasedTerm, {team_id: currentTeamId, allow_inactive: true});
} else {
results = await searchProfiles(serverUrl, lowerCasedTerm, {allow_inactive: true});
}
let data: UserProfile[] = [];
if (results.data) {
data = results.data;
}
setSearchResults(data);
setLoading(false);
}, [restrictDirectMessage, serverUrl, currentTeamId]);
const search = useCallback(() => {
searchUsers(term);
}, [searchUsers, term]);
const onSearch = useCallback((text: string) => {
if (text) {
setTerm(text);
if (searchTimeoutId.current) {
clearTimeout(searchTimeoutId.current);
}
searchTimeoutId.current = setTimeout(() => {
searchUsers(text);
}, General.SEARCH_TIMEOUT_MILLISECONDS);
} else {
clearSearch();
}
}, [searchUsers, clearSearch]);
const updateNavigationButtons = useCallback(async (startEnabled: boolean) => {
const closeIcon = await CompassIcon.getImageSource('close', 24, theme.sidebarHeaderTextColor);
setButtons(componentId, {
leftButtons: [{
id: CLOSE_BUTTON,
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);
getProfiles();
return () => {
mounted.current = false;
};
}, []);
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]);
if (startingConversation) {
return (
<View style={style.container}>
<Loading color={theme.centerChannelColor}/>
</View>
);
}
return ( return (
<SafeAreaView <MembersModal
style={style.container} componentId={componentId}
testID='create_direct_message.screen' selectUsersButtonIcon={'forum-outline'}
> selectUsersButtonText={intl.formatMessage(messages.buttonText)}
<View style={style.searchBar}> selectUsersMax={7}
<Search selectUsersWarn={5}
testID='create_direct_message.search_bar' selectedIds={selectedIds}
placeholder={intl.formatMessage({id: 'search_bar.search', defaultMessage: 'Search'})} setSelectedIds={setSelectedIds}
cancelButtonTitle={intl.formatMessage({id: 'mobile.post.cancel', defaultMessage: 'Cancel'})} startConversation={startConversation}
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.5)} startingConversation={startingConversation}
onChangeText={onSearch} />
onSubmitEditing={search}
onCancel={clearSearch}
autoCapitalize='none'
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
value={term}
/>
</View>
{selectedCount > 0 &&
<SelectedUsers
selectedIds={selectedIds}
warnCount={5}
maxCount={7}
onRemove={handleRemoveProfile}
teammateNameDisplay={teammateNameDisplay}
/>
}
<UserList
currentUserId={currentUserId}
handleSelectProfile={handleSelectProfile}
loading={loading}
profiles={data}
selectedIds={selectedIds}
showNoResults={!loading && page.current !== -1}
teammateNameDisplay={teammateNameDisplay}
fetchMore={getProfiles}
term={term}
testID='create_direct_message.user_list'
tutorialWatched={tutorialWatched}
/>
</SafeAreaView>
); );
} }

View File

@@ -3,12 +3,7 @@
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables'; import withObservables from '@nozbe/with-observables';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {General} from '@constants';
import {observeProfileLongPresTutorial} from '@queries/app/global';
import {observeConfig, observeCurrentTeamId, observeCurrentUserId} from '@queries/servers/system';
import {observeTeammateNameDisplay} from '@queries/servers/user'; import {observeTeammateNameDisplay} from '@queries/servers/user';
import CreateDirectMessage from './create_direct_message'; import CreateDirectMessage from './create_direct_message';
@@ -16,16 +11,8 @@ import CreateDirectMessage from './create_direct_message';
import type {WithDatabaseArgs} from '@typings/database/database'; import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
const restrictDirectMessage = observeConfig(database).pipe(
switchMap((cfg) => of$(cfg?.RestrictDirectMessage !== General.RESTRICT_DIRECT_MESSAGE_ANY)),
);
return { return {
teammateNameDisplay: observeTeammateNameDisplay(database), teammateNameDisplay: observeTeammateNameDisplay(database),
currentUserId: observeCurrentUserId(database),
currentTeamId: observeCurrentTeamId(database),
tutorialWatched: observeProfileLongPresTutorial(),
restrictDirectMessage,
}; };
}); });

View File

@@ -87,6 +87,9 @@ Navigation.setLazyComponentRegistrator((screenName) => {
case Screens.CREATE_DIRECT_MESSAGE: case Screens.CREATE_DIRECT_MESSAGE:
screen = withServerDatabase(require('@screens/create_direct_message').default); screen = withServerDatabase(require('@screens/create_direct_message').default);
break; break;
case Screens.CHANNEL_ADD_PEOPLE:
screen = withServerDatabase(require('@screens/channel_add_people').default);
break;
case Screens.EDIT_POST: case Screens.EDIT_POST:
screen = withServerDatabase(require('@screens/edit_post').default); screen = withServerDatabase(require('@screens/edit_post').default);
break; break;

View File

@@ -0,0 +1,33 @@
// 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 {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {General} from '@constants';
import {observeProfileLongPresTutorial} from '@queries/app/global';
import {observeConfig, observeCurrentTeamId, observeCurrentUserId} from '@queries/servers/system';
import {observeTeammateNameDisplay} from '@queries/servers/user';
import MembersModal from './members_modal';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
const restrictDirectMessage = observeConfig(database).pipe(
switchMap((cfg) => of$(cfg?.RestrictDirectMessage !== General.RESTRICT_DIRECT_MESSAGE_ANY)),
);
return {
teammateNameDisplay: observeTeammateNameDisplay(database),
currentUserId: observeCurrentUserId(database),
currentTeamId: observeCurrentTeamId(database),
tutorialWatched: observeProfileLongPresTutorial(),
restrictDirectMessage,
};
});
export default withDatabase(enhanced(MembersModal));

View File

@@ -0,0 +1,328 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// 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 {SafeAreaView} from 'react-native-safe-area-context';
import {fetchProfiles, fetchProfilesInTeam, searchProfiles} from '@actions/remote/user';
import CompassIcon from '@components/compass_icon';
import Loading from '@components/loading';
import Search from '@components/search';
import {General} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {debounce} from '@helpers/api/general';
import useNavButtonPressed from '@hooks/navigation_button_pressed';
import {dismissModal, setButtons} from '@screens/navigation';
import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme';
import {filterProfilesMatchingTerm} from '@utils/user';
import SelectedUsers from './selected_users';
import UserList from './user_list';
const CLOSE_BUTTON = 'close-dms';
type Props = {
componentId: string;
currentTeamId: string;
currentUserId: string;
restrictDirectMessage: boolean;
selectUsersButtonIcon: string;
selectUsersButtonText: string;
selectUsersMax: number;
selectUsersWarn: number;
selectedIds: {[id: string]: UserProfile};
setSelectedIds: (ids: {[id: string]: UserProfile}) => void;
startConversation: (selectedId?: {[id: string]: boolean}) => void;
startingConversation: boolean;
teammateNameDisplay: string;
tutorialWatched: boolean;
}
const close = () => {
Keyboard.dismiss();
dismissModal();
};
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return {
container: {
flex: 1,
},
searchBar: {
marginLeft: 12,
marginRight: Platform.select({ios: 4, default: 12}),
marginVertical: 12,
},
loadingContainer: {
alignItems: 'center',
backgroundColor: theme.centerChannelBg,
height: 70,
justifyContent: 'center',
},
loadingText: {
color: changeOpacity(theme.centerChannelColor, 0.6),
},
noResultContainer: {
flexGrow: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
noResultText: {
fontSize: 26,
color: changeOpacity(theme.centerChannelColor, 0.5),
},
};
});
function reduceProfiles(state: UserProfile[], action: {type: 'add'; values?: UserProfile[]}) {
if (action.type === 'add' && action.values?.length) {
return [...state, ...action.values];
}
return state;
}
export default function MembersModal({
componentId,
currentTeamId,
currentUserId,
selectUsersMax,
selectUsersWarn,
restrictDirectMessage,
selectUsersButtonIcon,
selectUsersButtonText,
selectedIds,
setSelectedIds,
startConversation,
startingConversation,
teammateNameDisplay,
tutorialWatched,
}: Props) {
const serverUrl = useServerUrl();
const theme = useTheme();
const style = getStyleFromTheme(theme);
const intl = useIntl();
const {formatMessage} = intl;
const searchTimeoutId = useRef<NodeJS.Timeout | null>(null);
const next = useRef(true);
const page = useRef(-1);
const mounted = useRef(false);
const [profiles, dispatchProfiles] = useReducer(reduceProfiles, []);
const [searchResults, setSearchResults] = useState<UserProfile[]>([]);
const [loading, setLoading] = useState(false);
const [term, setTerm] = useState('');
const selectedCount = Object.keys(selectedIds).length;
const isSearch = Boolean(term);
const loadedProfiles = ({users}: {users?: UserProfile[]}) => {
if (mounted.current) {
if (users && !users.length) {
next.current = false;
}
page.current += 1;
setLoading(false);
dispatchProfiles({type: 'add', values: users});
}
};
const clearSearch = useCallback(() => {
setTerm('');
setSearchResults([]);
}, []);
const getProfiles = useCallback(debounce(() => {
if (next.current && !loading && !term && mounted.current) {
setLoading(true);
if (restrictDirectMessage) {
fetchProfilesInTeam(serverUrl, currentTeamId, page.current + 1, General.PROFILE_CHUNK_SIZE).then(loadedProfiles);
} else {
fetchProfiles(serverUrl, page.current + 1, General.PROFILE_CHUNK_SIZE).then(loadedProfiles);
}
}
}, 100), [loading, isSearch, restrictDirectMessage, serverUrl, currentTeamId]);
const handleRemoveProfile = useCallback((id: string) => {
const newSelectedIds = Object.assign({}, selectedIds);
Reflect.deleteProperty(newSelectedIds, id);
setSelectedIds(newSelectedIds);
}, [selectedIds]);
const handleSelectProfile = useCallback((user: UserProfile) => {
if (selectedIds[user.id]) {
handleRemoveProfile(user.id);
return;
}
if (user.id === currentUserId) {
const selectedId = {
[currentUserId]: true,
};
startConversation(selectedId);
} else {
const wasSelected = selectedIds[user.id];
if (!wasSelected && selectedCount >= General.MAX_USERS_IN_GM - 1) {
return;
}
const newSelectedIds = Object.assign({}, selectedIds);
if (!wasSelected) {
newSelectedIds[user.id] = user;
}
setSelectedIds(newSelectedIds);
clearSearch();
}
}, [selectedIds, currentUserId, handleRemoveProfile, startConversation, clearSearch]);
const searchUsers = useCallback(async (searchTerm: string) => {
const lowerCasedTerm = searchTerm.toLowerCase();
setLoading(true);
let results;
if (restrictDirectMessage) {
results = await searchProfiles(serverUrl, lowerCasedTerm, {team_id: currentTeamId, allow_inactive: true});
} else {
results = await searchProfiles(serverUrl, lowerCasedTerm, {allow_inactive: true});
}
let data: UserProfile[] = [];
if (results.data) {
data = results.data;
}
setSearchResults(data);
setLoading(false);
}, [restrictDirectMessage, serverUrl, currentTeamId]);
const search = useCallback(() => {
searchUsers(term);
}, [searchUsers, term]);
const onSearch = useCallback((text: string) => {
if (text) {
setTerm(text);
if (searchTimeoutId.current) {
clearTimeout(searchTimeoutId.current);
}
searchTimeoutId.current = setTimeout(() => {
searchUsers(text);
}, General.SEARCH_TIMEOUT_MILLISECONDS);
} else {
clearSearch();
}
}, [searchUsers, clearSearch]);
const updateNavigationButtons = useCallback(async () => {
const closeIcon = await CompassIcon.getImageSource('close', 24, theme.sidebarHeaderTextColor);
setButtons(componentId, {
leftButtons: [{
id: CLOSE_BUTTON,
icon: closeIcon,
testID: 'close.button',
}],
});
}, [intl.locale, theme]);
useNavButtonPressed(CLOSE_BUTTON, componentId, close, [close]);
useEffect(() => {
mounted.current = true;
updateNavigationButtons();
getProfiles();
return () => {
mounted.current = false;
};
}, []);
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]);
if (startingConversation) {
return (
<View style={style.container}>
<Loading color={theme.centerChannelColor}/>
</View>
);
}
return (
<SafeAreaView
style={style.container}
testID='members.screen'
>
<View style={style.searchBar}>
<Search
testID='search_bar'
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}
onCancel={clearSearch}
autoCapitalize='none'
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
value={term}
/>
</View>
<UserList
currentUserId={currentUserId}
handleSelectProfile={handleSelectProfile}
loading={loading}
profiles={data}
selectedIds={selectedIds}
showNoResults={!loading && page.current !== -1}
teammateNameDisplay={teammateNameDisplay}
fetchMore={getProfiles}
term={term}
testID='user_list'
tutorialWatched={tutorialWatched}
/>
{selectedCount > 0 &&
<SelectedUsers
selectedIds={selectedIds}
warnCount={selectUsersWarn}
maxCount={selectUsersMax}
onRemove={handleRemoveProfile}
teammateNameDisplay={teammateNameDisplay}
onPress={startConversation}
buttonIcon={selectUsersButtonIcon}
buttonText={selectUsersButtonText}
/>
}
</SafeAreaView>
);
}

View File

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

View File

@@ -2,51 +2,92 @@
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import React, {useMemo} from 'react'; import React, {useMemo} from 'react';
import {View} from 'react-native'; import {ScrollView, View} from 'react-native';
import FormattedText from '@components/formatted_text'; import FormattedText from '@components/formatted_text';
import {useTheme} from '@context/theme'; import {useTheme} from '@context/theme';
import Button from '@screens/bottom_sheet/button';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import SelectedUser from './selected_user'; import SelectedUser, {USER_CHIP_BOTTOM_MARGIN, USER_CHIP_HEIGHT} from './selected_user';
type Props = { type Props = {
/* /*
* An object mapping user ids to a falsey value indicating whether or not they've been selected. * An object mapping user ids to a falsey value indicating whether or not they have been selected.
*/ */
selectedIds: {[id: string]: UserProfile}; selectedIds: {[id: string]: UserProfile};
/* /*
* How to display the names of users. * How to display the names of users.
*/ */
teammateNameDisplay: string; teammateNameDisplay: string;
/* /*
* The number of users that will be selected when we start to display a message indicating * 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. * the remaining number of users that can be selected.
*/ */
warnCount: number; warnCount: number;
/* /*
* The maximum number of users that can be selected. * The maximum number of users that can be selected.
*/ */
maxCount: number; maxCount: number;
/* /*
* A handler function that will deselect a user when clicked on. * A handler function that will 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; onRemove: (id: string) => void;
/*
* Name of the button Icon
*/
buttonIcon: string;
/*
* Text displayed on the action button
*/
buttonText: string;
} }
const MAX_ROWS = 3;
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return { return {
container: { container: {
marginHorizontal: 12, flexShrink: 1,
backgroundColor: theme.centerChannelBg,
borderBottomWidth: 0,
borderColor: changeOpacity(theme.centerChannelColor, 0.16),
borderTopLeftRadius: 12,
borderTopRightRadius: 12,
borderWidth: 1,
elevation: 4,
paddingHorizontal: 20,
shadowColor: theme.centerChannelColor,
shadowOffset: {
width: 0,
height: 8,
},
shadowOpacity: 0.16,
shadowRadius: 24,
},
buttonContainer: {
marginVertical: 20,
},
containerUsers: {
marginTop: 20,
maxHeight: (USER_CHIP_HEIGHT + USER_CHIP_BOTTOM_MARGIN) * MAX_ROWS,
}, },
users: { users: {
alignItems: 'flex-start', alignItems: 'flex-start',
flexDirection: 'row', flexDirection: 'row',
flexGrow: 1,
flexWrap: 'wrap', flexWrap: 'wrap',
}, },
message: { message: {
@@ -64,11 +105,18 @@ export default function SelectedUsers({
teammateNameDisplay, teammateNameDisplay,
warnCount, warnCount,
maxCount, maxCount,
onPress,
onRemove, onRemove,
buttonIcon,
buttonText,
}: Props) { }: Props) {
const theme = useTheme(); const theme = useTheme();
const style = getStyleFromTheme(theme); const style = getStyleFromTheme(theme);
const handleOnPress = async () => {
onPress();
};
const users = useMemo(() => { const users = useMemo(() => {
const u = []; const u = [];
for (const id of Object.keys(selectedIds)) { for (const id of Object.keys(selectedIds)) {
@@ -128,10 +176,22 @@ export default function SelectedUsers({
return ( return (
<View style={style.container}> <View style={style.container}>
<View style={style.users}> <View style={style.containerUsers}>
{users} <ScrollView
contentContainerStyle={style.users}
>
{users}
</ScrollView>
</View> </View>
{message} {message}
<View style={style.buttonContainer}>
<Button
onPress={handleOnPress}
icon={buttonIcon}
text={buttonText}
/>
</View>
</View> </View>
); );
} }

View File

@@ -396,6 +396,7 @@
"mobile.calls_you": "(you)", "mobile.calls_you": "(you)",
"mobile.camera_photo_permission_denied_description": "Take photos and upload them to your server or save them to your device. Open Settings to grant {applicationName} read and write access to your camera.", "mobile.camera_photo_permission_denied_description": "Take photos and upload them to your server or save them to your device. Open Settings to grant {applicationName} read and write access to your camera.",
"mobile.camera_photo_permission_denied_title": "{applicationName} would like to access your camera", "mobile.camera_photo_permission_denied_title": "{applicationName} would like to access your camera",
"mobile.channel_add_people.title": "Add Members",
"mobile.channel_info.alertNo": "No", "mobile.channel_info.alertNo": "No",
"mobile.channel_info.alertYes": "Yes", "mobile.channel_info.alertYes": "Yes",
"mobile.channel_list.recent": "Recent", "mobile.channel_list.recent": "Recent",
@@ -415,7 +416,7 @@
"mobile.create_direct_message.add_more": "You can add {remaining, number} more users", "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.cannot_add_more": "You cannot add more users",
"mobile.create_direct_message.one_more": "You can add 1 more user", "mobile.create_direct_message.one_more": "You can add 1 more user",
"mobile.create_direct_message.start": "Start", "mobile.create_direct_message.start": "Start Conversation",
"mobile.create_direct_message.you": "@{username} - you", "mobile.create_direct_message.you": "@{username} - you",
"mobile.create_post.read_only": "This channel is read-only.", "mobile.create_post.read_only": "This channel is read-only.",
"mobile.custom_status.choose_emoji": "Choose an emoji", "mobile.custom_status.choose_emoji": "Choose an emoji",