Merge pull request #6877 from julmondragon/MM-42835
MM-42835_Invite People - add email+user invites
This commit is contained in:
commit
ba33f610c1
29 changed files with 2402 additions and 170 deletions
|
|
@ -114,6 +114,54 @@ export async function addUserToTeam(serverUrl: string, teamId: string, userId: s
|
|||
}
|
||||
}
|
||||
|
||||
export async function addUsersToTeam(serverUrl: string, teamId: string, userIds: string[], fetchOnly = false) {
|
||||
try {
|
||||
const client = NetworkManager.getClient(serverUrl);
|
||||
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||
EphemeralStore.startAddingToTeam(teamId);
|
||||
|
||||
const members = await client.addUsersToTeamGracefully(teamId, userIds);
|
||||
|
||||
if (!fetchOnly) {
|
||||
const teamMemberships: TeamMembership[] = [];
|
||||
const roles = [];
|
||||
|
||||
for (const {member} of members) {
|
||||
teamMemberships.push(member);
|
||||
roles.push(...member.roles.split(' '));
|
||||
}
|
||||
|
||||
fetchRolesIfNeeded(serverUrl, Array.from(new Set(roles)));
|
||||
|
||||
if (operator) {
|
||||
await operator.handleTeamMemberships({teamMemberships, prepareRecordsOnly: true});
|
||||
}
|
||||
}
|
||||
|
||||
EphemeralStore.finishAddingToTeam(teamId);
|
||||
return {members};
|
||||
} catch (error) {
|
||||
if (EphemeralStore.isAddingToTeam(teamId)) {
|
||||
EphemeralStore.finishAddingToTeam(teamId);
|
||||
}
|
||||
|
||||
forceLogoutIfNecessary(serverUrl, error as ClientError);
|
||||
return {error};
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendEmailInvitesToTeam(serverUrl: string, teamId: string, emails: string[]) {
|
||||
try {
|
||||
const client = NetworkManager.getClient(serverUrl);
|
||||
const members = await client.sendEmailInvitesToTeamGracefully(teamId, emails);
|
||||
|
||||
return {members};
|
||||
} catch (error) {
|
||||
forceLogoutIfNecessary(serverUrl, error as ClientError);
|
||||
return {error};
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMyTeams(serverUrl: string, fetchOnly = false): Promise<MyTeamsRequest> {
|
||||
let client;
|
||||
try {
|
||||
|
|
@ -428,3 +476,28 @@ export async function handleKickFromTeam(serverUrl: string, teamId: string) {
|
|||
logDebug('Failed to kick user from team', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTeamMembersByIds(serverUrl: string, teamId: string, userIds: string[], fetchOnly?: boolean) {
|
||||
try {
|
||||
const client = NetworkManager.getClient(serverUrl);
|
||||
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||
const members = await client.getTeamMembersByIds(teamId, userIds);
|
||||
|
||||
if (!fetchOnly) {
|
||||
const roles = [];
|
||||
|
||||
for (const {roles: memberRoles} of members) {
|
||||
roles.push(...memberRoles.split(' '));
|
||||
}
|
||||
|
||||
fetchRolesIfNeeded(serverUrl, Array.from(new Set(roles)));
|
||||
|
||||
await operator.handleTeamMemberships({teamMemberships: members, prepareRecordsOnly: true});
|
||||
}
|
||||
|
||||
return {members};
|
||||
} catch (error) {
|
||||
forceLogoutIfNecessary(serverUrl, error as ClientError);
|
||||
return {error};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,10 @@ export interface ClientTeamsMix {
|
|||
getMyTeamMembers: () => Promise<TeamMembership[]>;
|
||||
getTeamMembers: (teamId: string, page?: number, perPage?: number) => Promise<TeamMembership[]>;
|
||||
getTeamMember: (teamId: string, userId: string) => Promise<TeamMembership>;
|
||||
getTeamMembersByIds: (teamId: string, userIds: string[]) => Promise<TeamMembership[]>;
|
||||
addToTeam: (teamId: string, userId: string) => Promise<TeamMembership>;
|
||||
addUsersToTeamGracefully: (teamId: string, userIds: string[]) => Promise<TeamMemberWithError[]>;
|
||||
sendEmailInvitesToTeamGracefully: (teamId: string, emails: string[]) => Promise<TeamInviteWithError[]>;
|
||||
joinTeam: (inviteId: string) => Promise<TeamMembership>;
|
||||
removeFromTeam: (teamId: string, userId: string) => Promise<any>;
|
||||
getTeamStats: (teamId: string) => Promise<any>;
|
||||
|
|
@ -120,6 +123,13 @@ const ClientTeams = (superclass: any) => class extends superclass {
|
|||
);
|
||||
};
|
||||
|
||||
getTeamMembersByIds = (teamId: string, userIds: string[]) => {
|
||||
return this.doFetch(
|
||||
`${this.getTeamMembersRoute(teamId)}/ids`,
|
||||
{method: 'post', body: userIds},
|
||||
);
|
||||
};
|
||||
|
||||
addToTeam = async (teamId: string, userId: string) => {
|
||||
this.analytics.trackAPI('api_teams_invite_members', {team_id: teamId});
|
||||
|
||||
|
|
@ -130,6 +140,27 @@ const ClientTeams = (superclass: any) => class extends superclass {
|
|||
);
|
||||
};
|
||||
|
||||
addUsersToTeamGracefully = (teamId: string, userIds: string[]) => {
|
||||
this.analytics.trackAPI('api_teams_batch_add_members', {team_id: teamId, count: userIds.length});
|
||||
|
||||
const members: Array<{team_id: string; user_id: string}> = [];
|
||||
userIds.forEach((id) => members.push({team_id: teamId, user_id: id}));
|
||||
|
||||
return this.doFetch(
|
||||
`${this.getTeamMembersRoute(teamId)}/batch?graceful=true`,
|
||||
{method: 'post', body: members},
|
||||
);
|
||||
};
|
||||
|
||||
sendEmailInvitesToTeamGracefully = (teamId: string, emails: string[]) => {
|
||||
this.analytics.trackAPI('api_teams_invite_members', {team_id: teamId});
|
||||
|
||||
return this.doFetch(
|
||||
`${this.getTeamRoute(teamId)}/invite/email?graceful=true`,
|
||||
{method: 'post', body: emails},
|
||||
);
|
||||
};
|
||||
|
||||
joinTeam = async (inviteId: string) => {
|
||||
const query = buildQueryString({invite_id: inviteId});
|
||||
return this.doFetch(
|
||||
|
|
|
|||
30
app/components/illustrations/alert.tsx
Normal file
30
app/components/illustrations/alert.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import * as React from 'react';
|
||||
import Svg, {Path} from 'react-native-svg';
|
||||
|
||||
function AlertSvgComponent() {
|
||||
return (
|
||||
<Svg
|
||||
width={55}
|
||||
height={55}
|
||||
viewBox='0 0 55 55'
|
||||
fill='none'
|
||||
>
|
||||
<Path
|
||||
d='M4.43715 54.2949C1.46653 54.2949 0.12055 52.1536 1.44612 49.5365L25.4558 2.37353C26.8154 -0.236815 28.9566 -0.236815 30.289 2.37353L54.292 49.5365C55.6515 52.1468 54.292 54.2949 51.3009 54.2949H4.43715Z'
|
||||
fill='#FFBC1F'
|
||||
/>
|
||||
<Path
|
||||
d='M24.1032 19.8165L26.5708 36.3963C26.5946 36.7253 26.7422 37.0331 26.9837 37.2578C27.2252 37.4824 27.5428 37.6073 27.8726 37.6073C28.2025 37.6073 28.5201 37.4824 28.7616 37.2578C29.0031 37.0331 29.1506 36.7253 29.1744 36.3963L31.642 19.8165C32.0907 13.3518 23.6478 13.3518 24.1032 19.8165Z'
|
||||
fill='#2D3039'
|
||||
/>
|
||||
<Path
|
||||
d='M27.8688 39.3942C28.6161 39.3955 29.3461 39.6183 29.9668 40.0344C30.5874 40.4506 31.0708 41.0413 31.3559 41.7321C31.6409 42.4228 31.7147 43.1825 31.5681 43.9153C31.4215 44.648 31.061 45.3208 30.5322 45.8487C30.0033 46.3766 29.3299 46.7359 28.5969 46.8812C27.8639 47.0265 27.1043 46.9512 26.4141 46.6649C25.7239 46.3787 25.134 45.8943 24.719 45.2729C24.304 44.6515 24.0825 43.921 24.0825 43.1737C24.0825 42.6768 24.1804 42.1848 24.3708 41.7258C24.5612 41.2668 24.8402 40.8498 25.1919 40.4988C25.5436 40.1477 25.9611 39.8695 26.4204 39.6799C26.8798 39.4904 27.3719 39.3933 27.8688 39.3942Z'
|
||||
fill='#2D3039'
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default AlertSvgComponent;
|
||||
34
app/components/illustrations/error.tsx
Normal file
34
app/components/illustrations/error.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import * as React from 'react';
|
||||
import Svg, {G, Path, Defs, ClipPath, Rect} from 'react-native-svg';
|
||||
|
||||
function ErrorSvgComponent() {
|
||||
return (
|
||||
<Svg
|
||||
width={46}
|
||||
height={45}
|
||||
viewBox='0 0 46 45'
|
||||
fill='none'
|
||||
>
|
||||
<G clipPath='url(#clip0_1304_35713)'>
|
||||
<Path
|
||||
d='M45.2126 6.63077L38.8691 0.287231L23.0033 16.153L7.13065 0.287231L0.787109 6.63077L16.6529 22.5035L0.787109 38.3692L7.13065 44.7128L23.0033 28.847L38.8691 44.7128L45.2126 38.3692L29.3469 22.5035L45.2126 6.63077Z'
|
||||
fill='#D24B4E'
|
||||
/>
|
||||
</G>
|
||||
<Defs>
|
||||
<ClipPath id='clip0_1304_35713'>
|
||||
<Rect
|
||||
width='44.4255'
|
||||
height='44.4255'
|
||||
fill='white'
|
||||
transform='translate(0.787109 0.287231)'
|
||||
/>
|
||||
</ClipPath>
|
||||
</Defs>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default ErrorSvgComponent;
|
||||
22
app/components/illustrations/success.tsx
Normal file
22
app/components/illustrations/success.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import * as React from 'react';
|
||||
import Svg, {Path} from 'react-native-svg';
|
||||
|
||||
function SuccessSvgComponent() {
|
||||
return (
|
||||
<Svg
|
||||
width={46}
|
||||
height={47}
|
||||
viewBox='0 0 46 47'
|
||||
fill='none'
|
||||
>
|
||||
<Path
|
||||
d='M41.1767 0.776611L13.0005 31.7625L4.82284 25.5642H0.276367L13.0005 46.2234L45.7232 0.776611H41.1767Z'
|
||||
fill='#3DB887'
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default SuccessSvgComponent;
|
||||
100
app/components/selected_chip/index.tsx
Normal file
100
app/components/selected_chip/index.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback} from 'react';
|
||||
import {Text, TouchableOpacity, View} from 'react-native';
|
||||
import Animated, {FadeIn, FadeOut} from 'react-native-reanimated';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
type SelectedChipProps = {
|
||||
id: string;
|
||||
text: string;
|
||||
extra?: React.ReactNode;
|
||||
onRemove: (id: string) => void;
|
||||
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: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'row',
|
||||
borderRadius: 16,
|
||||
height: USER_CHIP_HEIGHT,
|
||||
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
|
||||
marginBottom: USER_CHIP_BOTTOM_MARGIN,
|
||||
marginRight: 8,
|
||||
paddingHorizontal: 7,
|
||||
},
|
||||
extraContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
color: theme.centerChannelColor,
|
||||
},
|
||||
text: {
|
||||
marginLeft: 8,
|
||||
color: theme.centerChannelColor,
|
||||
...typography('Body', 100, 'SemiBold'),
|
||||
},
|
||||
remove: {
|
||||
justifyContent: 'center',
|
||||
marginLeft: 7,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export default function SelectedChip({
|
||||
id,
|
||||
text,
|
||||
extra,
|
||||
onRemove,
|
||||
testID,
|
||||
}: SelectedChipProps) {
|
||||
const theme = useTheme();
|
||||
const style = getStyleFromTheme(theme);
|
||||
|
||||
const onPress = useCallback(() => {
|
||||
onRemove(id);
|
||||
}, [onRemove, id]);
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(FADE_DURATION)}
|
||||
exiting={FadeOut.duration(FADE_DURATION)}
|
||||
style={style.container}
|
||||
testID={testID}
|
||||
>
|
||||
{extra && (
|
||||
<View style={style.extraContent}>
|
||||
{extra}
|
||||
</View>
|
||||
)}
|
||||
<Text
|
||||
style={style.text}
|
||||
testID={`${testID}.display_name`}
|
||||
>
|
||||
{text}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={style.remove}
|
||||
onPress={onPress}
|
||||
testID={`${testID}.remove.button`}
|
||||
>
|
||||
<CompassIcon
|
||||
name='close-circle'
|
||||
size={18}
|
||||
color={changeOpacity(theme.centerChannelColor, 0.32)}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import {LayoutChangeEvent, Platform, ScrollView, useWindowDimensions, View} from
|
|||
import Animated, {useAnimatedStyle, useDerivedValue, useSharedValue, withTiming} from 'react-native-reanimated';
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||
|
||||
import {USER_CHIP_BOTTOM_MARGIN, USER_CHIP_HEIGHT} from '@components/selected_chip';
|
||||
import Toast from '@components/toast';
|
||||
import {General} from '@constants';
|
||||
import {useTheme} from '@context/theme';
|
||||
|
|
@ -13,7 +14,7 @@ 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';
|
||||
import SelectedUser from './selected_user';
|
||||
|
||||
type Props = {
|
||||
|
||||
|
|
|
|||
|
|
@ -3,18 +3,9 @@
|
|||
|
||||
import React, {useCallback} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {
|
||||
Text,
|
||||
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';
|
||||
import SelectedChip from '@components/selected_chip';
|
||||
import {displayUsername} from '@utils/user';
|
||||
|
||||
type Props = {
|
||||
|
|
@ -40,87 +31,34 @@ 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: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'row',
|
||||
borderRadius: 16,
|
||||
height: USER_CHIP_HEIGHT,
|
||||
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
|
||||
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'),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export default function SelectedUser({
|
||||
teammateNameDisplay,
|
||||
user,
|
||||
onRemove,
|
||||
testID,
|
||||
}: Props) {
|
||||
const theme = useTheme();
|
||||
const style = getStyleFromTheme(theme);
|
||||
const intl = useIntl();
|
||||
|
||||
const onPress = useCallback(() => {
|
||||
onRemove(user.id);
|
||||
}, [onRemove, user.id]);
|
||||
const onPress = useCallback((id: string) => {
|
||||
onRemove(id);
|
||||
}, [onRemove]);
|
||||
|
||||
const userItemTestID = `${testID}.${user.id}`;
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(FADE_DURATION)}
|
||||
exiting={FadeOut.duration(FADE_DURATION)}
|
||||
style={style.container}
|
||||
testID={`${testID}.${user.id}`}
|
||||
>
|
||||
<View style={style.profileContainer}>
|
||||
<SelectedChip
|
||||
id={user.id}
|
||||
text={displayUsername(user, intl.locale, teammateNameDisplay)}
|
||||
extra={(
|
||||
<ProfilePicture
|
||||
author={user}
|
||||
size={20}
|
||||
iconSize={20}
|
||||
testID={`${userItemTestID}.profile_picture`}
|
||||
/>
|
||||
</View>
|
||||
<Text
|
||||
style={style.text}
|
||||
testID={`${testID}.${user.id}.display_name`}
|
||||
>
|
||||
{displayUsername(user, intl.locale, teammateNameDisplay)}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={style.remove}
|
||||
onPress={onPress}
|
||||
testID={`${testID}.${user.id}.remove.button`}
|
||||
>
|
||||
<CompassIcon
|
||||
name='close-circle'
|
||||
size={18}
|
||||
color={changeOpacity(theme.centerChannelColor, 0.32)}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
)}
|
||||
onRemove={onPress}
|
||||
testID={userItemTestID}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import React, {useMemo} from 'react';
|
||||
import {IntlShape, useIntl} from 'react-intl';
|
||||
import {StyleProp, Text, View, ViewStyle} from 'react-native';
|
||||
|
||||
|
|
@ -81,7 +81,6 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
|
|||
color: changeOpacity(theme.centerChannelColor, 0.64),
|
||||
fontSize: 15,
|
||||
fontFamily: 'OpenSans',
|
||||
flexShrink: 5,
|
||||
},
|
||||
icon: {
|
||||
marginLeft: 4,
|
||||
|
|
@ -113,6 +112,19 @@ const UserItem = ({
|
|||
|
||||
const userItemTestId = `${testID}.${user?.id}`;
|
||||
|
||||
let rowUsernameFlexShrink = 1;
|
||||
if (user) {
|
||||
for (const rowInfoElem of [bot, guest, Boolean(name.length), isCurrentUser]) {
|
||||
if (rowInfoElem) {
|
||||
rowUsernameFlexShrink++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const usernameTextStyle = useMemo(() => {
|
||||
return [style.rowUsername, {flexShrink: rowUsernameFlexShrink}];
|
||||
}, [user, rowUsernameFlexShrink]);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[style.row, containerStyle]}
|
||||
|
|
@ -127,7 +139,7 @@ const UserItem = ({
|
|||
/>
|
||||
</View>
|
||||
<View
|
||||
style={[style.rowInfo, {maxWidth: shared ? '75%' : '80%'}]}
|
||||
style={[style.rowInfo, {maxWidth: shared ? '75%' : '85%'}]}
|
||||
>
|
||||
{bot && <BotTag testID={`${userItemTestId}.bot.tag`}/>}
|
||||
{guest && <GuestTag testID={`${userItemTestId}.guest.tag`}/>}
|
||||
|
|
@ -148,15 +160,15 @@ const UserItem = ({
|
|||
testID={`${userItemTestId}.current_user_indicator`}
|
||||
/>
|
||||
}
|
||||
{Boolean(user) &&
|
||||
<Text
|
||||
style={style.rowUsername}
|
||||
numberOfLines={1}
|
||||
testID={`${userItemTestId}.username`}
|
||||
>
|
||||
{` @${user!.username}`}
|
||||
</Text>
|
||||
}
|
||||
{Boolean(user) && (
|
||||
<Text
|
||||
style={usernameTextStyle}
|
||||
numberOfLines={1}
|
||||
testID={`${userItemTestId}.username`}
|
||||
>
|
||||
{` @${user!.username}`}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
{Boolean(isCustomStatusEnabled && !bot && customStatus?.emoji && !customStatusExpired) && (
|
||||
<CustomStatusEmoji
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ export const GLOBAL_THREADS = 'GlobalThreads';
|
|||
export const HOME = 'Home';
|
||||
export const INTEGRATION_SELECTOR = 'IntegrationSelector';
|
||||
export const INTERACTIVE_DIALOG = 'InteractiveDialog';
|
||||
export const INVITE = 'Invite';
|
||||
export const IN_APP_NOTIFICATION = 'InAppNotification';
|
||||
export const JOIN_TEAM = 'JoinTeam';
|
||||
export const LATEX = 'Latex';
|
||||
|
|
@ -94,6 +95,7 @@ export default {
|
|||
HOME,
|
||||
INTEGRATION_SELECTOR,
|
||||
INTERACTIVE_DIALOG,
|
||||
INVITE,
|
||||
IN_APP_NOTIFICATION,
|
||||
JOIN_TEAM,
|
||||
LATEX,
|
||||
|
|
@ -144,6 +146,7 @@ export const MODAL_SCREENS_WITHOUT_BACK = new Set<string>([
|
|||
EDIT_SERVER,
|
||||
FIND_CHANNELS,
|
||||
GALLERY,
|
||||
INVITE,
|
||||
PERMALINK,
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -5,5 +5,6 @@ export default {
|
|||
DELETED_ROOT_POST_ERROR: 'api.post.create_post.root_id.app_error',
|
||||
TOWN_SQUARE_READ_ONLY_ERROR: 'api.post.create_post.town_square_read_only',
|
||||
PLUGIN_DISMISSED_POST_ERROR: 'plugin.message_will_be_posted.dismiss_post',
|
||||
SEND_EMAIL_WITH_DEFAULTS_ERROR: 'api.team.invite_members.unable_to_send_email_with_defaults.app_error',
|
||||
TEAM_MEMBERSHIP_DENIAL_ERROR_ID: 'api.team.add_members.user_denied',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -33,7 +33,6 @@ type Props = {
|
|||
canJoinChannels: boolean;
|
||||
canInvitePeople: boolean;
|
||||
displayName?: string;
|
||||
inviteId?: string;
|
||||
iconPad?: boolean;
|
||||
onHeaderPress?: () => void;
|
||||
pushProxyStatus: string;
|
||||
|
|
@ -107,7 +106,6 @@ const ChannelListHeader = ({
|
|||
canJoinChannels,
|
||||
canInvitePeople,
|
||||
displayName,
|
||||
inviteId,
|
||||
iconPad,
|
||||
onHeaderPress,
|
||||
pushProxyStatus,
|
||||
|
|
@ -134,8 +132,6 @@ const ChannelListHeader = ({
|
|||
canCreateChannels={canCreateChannels}
|
||||
canJoinChannels={canJoinChannels}
|
||||
canInvitePeople={canInvitePeople}
|
||||
displayName={displayName}
|
||||
inviteId={inviteId}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -52,9 +52,6 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
|
|||
displayName: team.pipe(
|
||||
switchMap((t) => of$(t?.displayName)),
|
||||
),
|
||||
inviteId: team.pipe(
|
||||
switchMap((t) => of$(t?.inviteId)),
|
||||
),
|
||||
pushProxyStatus: observePushVerificationStatus(database),
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,13 +3,9 @@
|
|||
|
||||
import React, {useCallback} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {Platform} from 'react-native';
|
||||
import Share from 'react-native-share';
|
||||
import {ShareOptions} from 'react-native-share/lib/typescript/types';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import {Screens} from '@constants';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {dismissBottomSheet, showModal} from '@screens/navigation';
|
||||
|
||||
|
|
@ -20,20 +16,17 @@ type Props = {
|
|||
canCreateChannels: boolean;
|
||||
canJoinChannels: boolean;
|
||||
canInvitePeople: boolean;
|
||||
displayName?: string;
|
||||
inviteId?: string;
|
||||
}
|
||||
|
||||
const PlusMenuList = ({canCreateChannels, canJoinChannels, canInvitePeople, displayName, inviteId}: Props) => {
|
||||
const PlusMenuList = ({canCreateChannels, canJoinChannels, canInvitePeople}: Props) => {
|
||||
const intl = useIntl();
|
||||
const theme = useTheme();
|
||||
const serverUrl = useServerUrl();
|
||||
|
||||
const browseChannels = useCallback(async () => {
|
||||
await dismissBottomSheet();
|
||||
|
||||
const title = intl.formatMessage({id: 'browse_channels.title', defaultMessage: 'Browse channels'});
|
||||
const closeButton = await CompassIcon.getImageSource('close', 24, theme.sidebarHeaderTextColor);
|
||||
const closeButton = CompassIcon.getImageSourceSync('close', 24, theme.sidebarHeaderTextColor);
|
||||
|
||||
showModal(Screens.BROWSE_CHANNELS, title, {
|
||||
closeButton,
|
||||
|
|
@ -51,64 +44,20 @@ const PlusMenuList = ({canCreateChannels, canJoinChannels, canInvitePeople, disp
|
|||
await dismissBottomSheet();
|
||||
|
||||
const title = intl.formatMessage({id: 'create_direct_message.title', defaultMessage: 'Create Direct Message'});
|
||||
const closeButton = await CompassIcon.getImageSource('close', 24, theme.sidebarHeaderTextColor);
|
||||
const closeButton = CompassIcon.getImageSourceSync('close', 24, theme.sidebarHeaderTextColor);
|
||||
showModal(Screens.CREATE_DIRECT_MESSAGE, title, {
|
||||
closeButton,
|
||||
});
|
||||
}, [intl, theme]);
|
||||
|
||||
const invitePeopleToTeam = async () => {
|
||||
const invitePeopleToTeam = useCallback(async () => {
|
||||
await dismissBottomSheet();
|
||||
|
||||
const url = `${serverUrl}/signup_user_complete/?id=${inviteId}`;
|
||||
const title = intl.formatMessage({id: 'invite_people_to_team.title', defaultMessage: 'Join the {team} team'}, {team: displayName});
|
||||
const message = intl.formatMessage({id: 'invite_people_to_team.message', defaultMessage: 'Here’s a link to collaborate and communicate with us on Mattermost.'});
|
||||
const icon = 'data:<data_type>/<file_extension>;base64,<base64_data>';
|
||||
|
||||
const options: ShareOptions = Platform.select({
|
||||
ios: {
|
||||
activityItemSources: [
|
||||
{
|
||||
placeholderItem: {
|
||||
type: 'url',
|
||||
content: url,
|
||||
},
|
||||
item: {
|
||||
default: {
|
||||
type: 'text',
|
||||
content: `${message} ${url}`,
|
||||
},
|
||||
copyToPasteBoard: {
|
||||
type: 'url',
|
||||
content: url,
|
||||
},
|
||||
},
|
||||
subject: {
|
||||
default: title,
|
||||
},
|
||||
linkMetadata: {
|
||||
originalUrl: url,
|
||||
url,
|
||||
title,
|
||||
icon,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
default: {
|
||||
title,
|
||||
subject: title,
|
||||
url,
|
||||
showAppsToView: true,
|
||||
},
|
||||
});
|
||||
|
||||
Share.open(
|
||||
options,
|
||||
).catch(() => {
|
||||
// do nothing
|
||||
});
|
||||
};
|
||||
showModal(
|
||||
Screens.INVITE,
|
||||
intl.formatMessage({id: 'invite.title', defaultMessage: 'Invite'}),
|
||||
);
|
||||
}, [intl, theme]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -127,6 +127,9 @@ Navigation.setLazyComponentRegistrator((screenName) => {
|
|||
case Screens.INTEGRATION_SELECTOR:
|
||||
screen = withServerDatabase(require('@screens/integration_selector').default);
|
||||
break;
|
||||
case Screens.INVITE:
|
||||
screen = withServerDatabase(require('@screens/invite').default);
|
||||
break;
|
||||
case Screens.IN_APP_NOTIFICATION: {
|
||||
const notificationScreen = require('@screens/in_app_notification').default;
|
||||
Navigation.registerComponent(Screens.IN_APP_NOTIFICATION, () =>
|
||||
|
|
|
|||
41
app/screens/invite/index.ts
Normal file
41
app/screens/invite/index.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
// 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, distinctUntilChanged, map} from 'rxjs/operators';
|
||||
|
||||
import {observeCurrentTeam} from '@queries/servers/team';
|
||||
import {observeTeammateNameDisplay, observeCurrentUser} from '@queries/servers/user';
|
||||
import {isSystemAdmin} from '@utils/user';
|
||||
|
||||
import Invite from './invite';
|
||||
|
||||
import type {WithDatabaseArgs} from '@typings/database/database';
|
||||
|
||||
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
|
||||
const team = observeCurrentTeam(database);
|
||||
|
||||
return {
|
||||
teamId: team.pipe(
|
||||
switchMap((t) => of$(t?.id)),
|
||||
),
|
||||
teamDisplayName: team.pipe(
|
||||
switchMap((t) => of$(t?.displayName)),
|
||||
),
|
||||
teamLastIconUpdate: team.pipe(
|
||||
switchMap((t) => of$(t?.lastTeamIconUpdatedAt)),
|
||||
),
|
||||
teamInviteId: team.pipe(
|
||||
switchMap((t) => of$(t?.inviteId)),
|
||||
),
|
||||
teammateNameDisplay: observeTeammateNameDisplay(database),
|
||||
isAdmin: observeCurrentUser(database).pipe(
|
||||
map((user) => isSystemAdmin(user?.roles || '')),
|
||||
distinctUntilChanged(),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
export default withDatabase(enhanced(Invite));
|
||||
419
app/screens/invite/invite.tsx
Normal file
419
app/screens/invite/invite.tsx
Normal file
|
|
@ -0,0 +1,419 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useEffect, useState, useRef} from 'react';
|
||||
import {IntlShape, useIntl} from 'react-intl';
|
||||
import {Keyboard, View, LayoutChangeEvent} from 'react-native';
|
||||
import {OptionsTopBarButton} from 'react-native-navigation';
|
||||
import {SafeAreaView} from 'react-native-safe-area-context';
|
||||
|
||||
import {getTeamMembersByIds, addUsersToTeam, sendEmailInvitesToTeam} from '@actions/remote/team';
|
||||
import {searchProfiles} from '@actions/remote/user';
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import Loading from '@components/loading';
|
||||
import {ServerErrors} from '@constants';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {useModalPosition} from '@hooks/device';
|
||||
import useNavButtonPressed from '@hooks/navigation_button_pressed';
|
||||
import {dismissModal, setButtons} from '@screens/navigation';
|
||||
import {isEmail} from '@utils/helpers';
|
||||
import {mergeNavigationOptions} from '@utils/navigation';
|
||||
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
|
||||
import {isGuest} from '@utils/user';
|
||||
|
||||
import Selection from './selection';
|
||||
import Summary from './summary';
|
||||
|
||||
import type {NavButtons} from '@typings/screens/navigation';
|
||||
|
||||
const CLOSE_BUTTON_ID = 'close-invite';
|
||||
const SEND_BUTTON_ID = 'send-invite';
|
||||
const TIMEOUT_MILLISECONDS = 200;
|
||||
const DEFAULT_RESULT = {sent: [], notSent: []};
|
||||
|
||||
const makeLeftButton = (theme: Theme): OptionsTopBarButton => ({
|
||||
id: CLOSE_BUTTON_ID,
|
||||
icon: CompassIcon.getImageSourceSync('close', 24, theme.sidebarHeaderTextColor),
|
||||
testID: 'invite.close.button',
|
||||
});
|
||||
|
||||
const makeRightButton = (theme: Theme, formatMessage: IntlShape['formatMessage'], enabled: boolean): OptionsTopBarButton => ({
|
||||
id: SEND_BUTTON_ID,
|
||||
text: formatMessage({id: 'invite.send_invite', defaultMessage: 'Send'}),
|
||||
showAsAction: 'always',
|
||||
testID: 'invite.send.button',
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
disabledColor: changeOpacity(theme.sidebarHeaderTextColor, 0.4),
|
||||
enabled,
|
||||
});
|
||||
|
||||
const closeModal = async () => {
|
||||
Keyboard.dismiss();
|
||||
await dismissModal();
|
||||
};
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme(() => {
|
||||
return {
|
||||
container: {
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export type EmailInvite = string;
|
||||
|
||||
export type SearchResult = UserProfile|EmailInvite;
|
||||
|
||||
export type InviteResult = {
|
||||
userId: string;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type Result = {
|
||||
sent: InviteResult[];
|
||||
notSent: InviteResult[];
|
||||
}
|
||||
|
||||
enum Stage {
|
||||
SELECTION = 'selection',
|
||||
RESULT = 'result',
|
||||
LOADING = 'loading',
|
||||
}
|
||||
|
||||
type InviteProps = {
|
||||
componentId: string;
|
||||
teamId: string;
|
||||
teamDisplayName: string;
|
||||
teamLastIconUpdate: number;
|
||||
teamInviteId: string;
|
||||
teammateNameDisplay: string;
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
export default function Invite({
|
||||
componentId,
|
||||
teamId,
|
||||
teamDisplayName,
|
||||
teamLastIconUpdate,
|
||||
teamInviteId,
|
||||
teammateNameDisplay,
|
||||
isAdmin,
|
||||
}: InviteProps) {
|
||||
const intl = useIntl();
|
||||
const {formatMessage, locale} = intl;
|
||||
const theme = useTheme();
|
||||
const styles = getStyleSheet(theme);
|
||||
const serverUrl = useServerUrl();
|
||||
const mainView = useRef<View>(null);
|
||||
const modalPosition = useModalPosition(mainView);
|
||||
|
||||
const searchTimeoutId = useRef<NodeJS.Timeout | null>(null);
|
||||
const retryTimeoutId = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const [term, setTerm] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
||||
const [selectedIds, setSelectedIds] = useState<{[id: string]: SearchResult}>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<Result>(DEFAULT_RESULT);
|
||||
const [wrapperHeight, setWrapperHeight] = useState(0);
|
||||
const [stage, setStage] = useState(Stage.SELECTION);
|
||||
const [sendError, setSendError] = useState('');
|
||||
|
||||
const selectedCount = Object.keys(selectedIds).length;
|
||||
|
||||
const onLayoutWrapper = useCallback((e: LayoutChangeEvent) => {
|
||||
setWrapperHeight(e.nativeEvent.layout.height);
|
||||
}, []);
|
||||
|
||||
const searchUsers = useCallback(async (searchTerm: string) => {
|
||||
if (searchTerm === '') {
|
||||
handleClearSearch();
|
||||
return;
|
||||
}
|
||||
|
||||
const {data} = await searchProfiles(serverUrl, searchTerm.toLowerCase());
|
||||
const results: SearchResult[] = data ?? [];
|
||||
|
||||
if (!results.length && isEmail(searchTerm.trim())) {
|
||||
results.push(searchTerm.trim() as EmailInvite);
|
||||
}
|
||||
|
||||
setSearchResults(results);
|
||||
}, [serverUrl, teamId]);
|
||||
|
||||
const handleReset = () => {
|
||||
setStage(Stage.LOADING);
|
||||
setSendError('');
|
||||
setTerm('');
|
||||
setSearchResults([]);
|
||||
setResult(DEFAULT_RESULT);
|
||||
setStage(Stage.SELECTION);
|
||||
};
|
||||
|
||||
const handleClearSearch = useCallback(() => {
|
||||
setTerm('');
|
||||
setSearchResults([]);
|
||||
}, []);
|
||||
|
||||
const handleSearchChange = useCallback((text: string) => {
|
||||
setLoading(true);
|
||||
setTerm(text);
|
||||
|
||||
if (searchTimeoutId.current) {
|
||||
clearTimeout(searchTimeoutId.current);
|
||||
}
|
||||
|
||||
searchTimeoutId.current = setTimeout(async () => {
|
||||
await searchUsers(text);
|
||||
setLoading(false);
|
||||
}, TIMEOUT_MILLISECONDS);
|
||||
}, [searchUsers]);
|
||||
|
||||
const handleSelectItem = useCallback((item: SearchResult) => {
|
||||
const email = typeof item === 'string';
|
||||
const id = email ? item : (item as UserProfile).id;
|
||||
const newSelectedIds = Object.assign({}, selectedIds);
|
||||
|
||||
if (!selectedIds[id]) {
|
||||
newSelectedIds[id] = item;
|
||||
}
|
||||
|
||||
setSelectedIds(newSelectedIds);
|
||||
|
||||
handleClearSearch();
|
||||
}, [selectedIds, handleClearSearch]);
|
||||
|
||||
const handleRetry = () => {
|
||||
setSendError('');
|
||||
setStage(Stage.LOADING);
|
||||
|
||||
retryTimeoutId.current = setTimeout(() => {
|
||||
handleSend();
|
||||
}, TIMEOUT_MILLISECONDS);
|
||||
};
|
||||
|
||||
const handleSendError = () => {
|
||||
setSendError(formatMessage({id: 'invite.send_error', defaultMessage: 'Something went wrong while trying to send invitations. Please check your network connection and try again.'}));
|
||||
setResult(DEFAULT_RESULT);
|
||||
setStage(Stage.RESULT);
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!selectedCount) {
|
||||
return;
|
||||
}
|
||||
|
||||
setStage(Stage.LOADING);
|
||||
|
||||
const userIds = [];
|
||||
const emails = [];
|
||||
|
||||
for (const [id, item] of Object.entries(selectedIds)) {
|
||||
if (typeof item === 'string') {
|
||||
emails.push(item);
|
||||
} else {
|
||||
userIds.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
const currentMemberIds = new Set();
|
||||
|
||||
if (userIds.length) {
|
||||
const {members: currentTeamMembers = [], error: getTeamMembersByIdsError} = await getTeamMembersByIds(serverUrl, teamId, userIds);
|
||||
|
||||
if (getTeamMembersByIdsError) {
|
||||
handleSendError();
|
||||
return;
|
||||
}
|
||||
|
||||
for (const {user_id: currentMemberId} of currentTeamMembers) {
|
||||
currentMemberIds.add(currentMemberId);
|
||||
}
|
||||
}
|
||||
|
||||
const sent: InviteResult[] = [];
|
||||
const notSent: InviteResult[] = [];
|
||||
const usersToAdd = [];
|
||||
|
||||
for (const userId of userIds) {
|
||||
if (isGuest((selectedIds[userId] as UserProfile).roles)) {
|
||||
notSent.push({userId, reason: formatMessage({id: 'invite.members.user_is_guest', defaultMessage: 'Contact your admin to make this guest a full member'})});
|
||||
} else if (currentMemberIds.has(userId)) {
|
||||
notSent.push({userId, reason: formatMessage({id: 'invite.members.already_member', defaultMessage: 'This person is already a team member'})});
|
||||
} else {
|
||||
usersToAdd.push(userId);
|
||||
}
|
||||
}
|
||||
|
||||
if (usersToAdd.length) {
|
||||
const {members, error: addUsersToTeamError} = await addUsersToTeam(serverUrl, teamId, usersToAdd);
|
||||
|
||||
if (addUsersToTeamError) {
|
||||
handleSendError();
|
||||
return;
|
||||
}
|
||||
|
||||
if (members) {
|
||||
const membersWithError: Record<string, string> = {};
|
||||
for (const {user_id, error} of members) {
|
||||
if (error) {
|
||||
membersWithError[user_id] = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
for (const userId of usersToAdd) {
|
||||
if (membersWithError[userId]) {
|
||||
notSent.push({userId, reason: membersWithError[userId]});
|
||||
} else {
|
||||
sent.push({userId, reason: formatMessage({id: 'invite.summary.member_invite', defaultMessage: 'Invited as a member of {teamDisplayName}'}, {teamDisplayName})});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (emails.length) {
|
||||
const {members, error: sendEmailInvitesToTeamError} = await sendEmailInvitesToTeam(serverUrl, teamId, emails);
|
||||
|
||||
if (sendEmailInvitesToTeamError) {
|
||||
handleSendError();
|
||||
return;
|
||||
}
|
||||
|
||||
if (members) {
|
||||
const membersWithError: Record<string, string> = {};
|
||||
for (const {email, error} of members) {
|
||||
if (error) {
|
||||
membersWithError[email] = isAdmin && error.server_error_id === ServerErrors.SEND_EMAIL_WITH_DEFAULTS_ERROR ? (
|
||||
formatMessage({id: 'invite.summary.smtp_failure', defaultMessage: 'SMTP is not configured in System Console'})
|
||||
) : (
|
||||
error.message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const email of emails) {
|
||||
if (membersWithError[email]) {
|
||||
notSent.push({userId: email, reason: membersWithError[email]});
|
||||
} else {
|
||||
sent.push({userId: email, reason: formatMessage({id: 'invite.summary.email_invite', defaultMessage: 'An invitation email has been sent'})});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setResult({sent, notSent});
|
||||
setStage(Stage.RESULT);
|
||||
};
|
||||
|
||||
useNavButtonPressed(CLOSE_BUTTON_ID, componentId, closeModal, [closeModal]);
|
||||
useNavButtonPressed(SEND_BUTTON_ID, componentId, handleSend, [handleSend]);
|
||||
|
||||
useEffect(() => {
|
||||
const buttons: NavButtons = {
|
||||
leftButtons: [makeLeftButton(theme)],
|
||||
rightButtons: stage === Stage.SELECTION ? [makeRightButton(theme, formatMessage, selectedCount > 0)] : [],
|
||||
};
|
||||
|
||||
setButtons(componentId, buttons);
|
||||
}, [theme, locale, componentId, selectedCount > 0, stage === Stage.SELECTION, sendError]);
|
||||
|
||||
useEffect(() => {
|
||||
mergeNavigationOptions(componentId, {
|
||||
topBar: {
|
||||
title: {
|
||||
color: theme.sidebarHeaderTextColor,
|
||||
text: stage === Stage.RESULT ? (
|
||||
formatMessage({id: 'invite.title.summary', defaultMessage: 'Invite summary'})
|
||||
) : (
|
||||
formatMessage({id: 'invite.title', defaultMessage: 'Invite'})
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
}, [componentId, locale, theme, stage === Stage.RESULT]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (searchTimeoutId.current) {
|
||||
clearTimeout(searchTimeoutId.current);
|
||||
}
|
||||
|
||||
if (retryTimeoutId.current) {
|
||||
clearTimeout(retryTimeoutId.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleRemoveItem = useCallback((id: string) => {
|
||||
const newSelectedIds = Object.assign({}, selectedIds);
|
||||
|
||||
Reflect.deleteProperty(newSelectedIds, id);
|
||||
|
||||
setSelectedIds(newSelectedIds);
|
||||
}, [selectedIds]);
|
||||
|
||||
const renderContent = () => {
|
||||
switch (stage) {
|
||||
case Stage.LOADING:
|
||||
return (
|
||||
<Loading
|
||||
containerStyle={styles.loadingContainer}
|
||||
size='large'
|
||||
color={theme.centerChannelColor}
|
||||
/>
|
||||
);
|
||||
case Stage.RESULT:
|
||||
return (
|
||||
<Summary
|
||||
result={result}
|
||||
selectedIds={selectedIds}
|
||||
error={sendError}
|
||||
onClose={closeModal}
|
||||
onRetry={handleRetry}
|
||||
onBack={handleReset}
|
||||
testID='invite.screen.summary'
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Selection
|
||||
teamId={teamId}
|
||||
teamDisplayName={teamDisplayName}
|
||||
teamLastIconUpdate={teamLastIconUpdate}
|
||||
teamInviteId={teamInviteId}
|
||||
teammateNameDisplay={teammateNameDisplay}
|
||||
serverUrl={serverUrl}
|
||||
term={term}
|
||||
searchResults={searchResults}
|
||||
selectedIds={selectedIds}
|
||||
modalPosition={modalPosition}
|
||||
wrapperHeight={wrapperHeight}
|
||||
loading={loading}
|
||||
onSearchChange={handleSearchChange}
|
||||
onSelectItem={handleSelectItem}
|
||||
onRemoveItem={handleRemoveItem}
|
||||
onClose={closeModal}
|
||||
testID='invite.screen.selection'
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView
|
||||
style={styles.container}
|
||||
onLayout={onLayoutWrapper}
|
||||
ref={mainView}
|
||||
testID='invite.screen'
|
||||
>
|
||||
{renderContent()}
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
348
app/screens/invite/selection.tsx
Normal file
348
app/screens/invite/selection.tsx
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useState, useMemo} from 'react';
|
||||
import {
|
||||
Keyboard,
|
||||
Platform,
|
||||
View,
|
||||
LayoutChangeEvent,
|
||||
useWindowDimensions,
|
||||
FlatList,
|
||||
ListRenderItemInfo,
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
import Animated, {useAnimatedStyle, useDerivedValue} from 'react-native-reanimated';
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||
|
||||
import SelectedChip from '@components/selected_chip';
|
||||
import SelectedUser from '@components/selected_users/selected_user';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import UserItem from '@components/user_item';
|
||||
import {MAX_LIST_HEIGHT, MAX_LIST_TABLET_DIFF} from '@constants/autocomplete';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {useAutocompleteDefaultAnimatedValues} from '@hooks/autocomplete';
|
||||
import {useIsTablet, useKeyboardHeight} from '@hooks/device';
|
||||
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
|
||||
|
||||
import {SearchResult} from './invite';
|
||||
import SelectionSearchBar from './selection_search_bar';
|
||||
import SelectionTeamBar from './selection_team_bar';
|
||||
import TextItem, {TextItemType} from './text_item';
|
||||
|
||||
const AUTOCOMPLETE_ADJUST = 5;
|
||||
const KEYBOARD_HEIGHT_ADJUST = 3;
|
||||
|
||||
const INITIAL_BATCH_TO_RENDER = 15;
|
||||
const SCROLL_EVENT_THROTTLE = 60;
|
||||
|
||||
const keyboardDismissProp = Platform.select({
|
||||
android: {
|
||||
onScrollBeginDrag: Keyboard.dismiss,
|
||||
},
|
||||
ios: {
|
||||
keyboardDismissMode: 'on-drag' as const,
|
||||
},
|
||||
});
|
||||
|
||||
const keyExtractor = (item: SearchResult) => (
|
||||
typeof item === 'string' ? item : (item as UserProfile).id
|
||||
);
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
return {
|
||||
container: {
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
},
|
||||
searchList: {
|
||||
left: 20,
|
||||
right: 20,
|
||||
position: 'absolute',
|
||||
bottom: Platform.select({ios: 'auto', default: undefined}),
|
||||
},
|
||||
searchListBorder: {
|
||||
borderWidth: 1,
|
||||
borderColor: changeOpacity(theme.centerChannelColor, 0.2),
|
||||
borderRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
searchListPadding: {
|
||||
paddingVertical: 8,
|
||||
},
|
||||
searchListShadow: {
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 6,
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 6,
|
||||
},
|
||||
borderRadius: 4,
|
||||
backgroundColor: theme.centerChannelBg,
|
||||
},
|
||||
searchListFlatList: {
|
||||
backgroundColor: theme.centerChannelBg,
|
||||
borderRadius: 4,
|
||||
},
|
||||
selectedItems: {
|
||||
display: 'flex',
|
||||
flexGrowth: 1,
|
||||
},
|
||||
selectedItemsContainer: {
|
||||
alignItems: 'flex-start',
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
marginHorizontal: 20,
|
||||
marginVertical: 16,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
type SelectionProps = {
|
||||
teamId: string;
|
||||
teamDisplayName: string;
|
||||
teamLastIconUpdate: number;
|
||||
teamInviteId: string;
|
||||
teammateNameDisplay: string;
|
||||
serverUrl: string;
|
||||
term: string;
|
||||
searchResults: SearchResult[];
|
||||
selectedIds: {[id: string]: SearchResult};
|
||||
modalPosition: number;
|
||||
wrapperHeight: number;
|
||||
loading: boolean;
|
||||
testID: string;
|
||||
onSearchChange: (text: string) => void;
|
||||
onSelectItem: (item: SearchResult) => void;
|
||||
onRemoveItem: (id: string) => void;
|
||||
onClose: () => Promise<void>;
|
||||
}
|
||||
|
||||
export default function Selection({
|
||||
teamId,
|
||||
teamDisplayName,
|
||||
teamLastIconUpdate,
|
||||
teamInviteId,
|
||||
teammateNameDisplay,
|
||||
serverUrl,
|
||||
term,
|
||||
searchResults,
|
||||
selectedIds,
|
||||
modalPosition,
|
||||
wrapperHeight,
|
||||
loading,
|
||||
testID,
|
||||
onSearchChange,
|
||||
onSelectItem,
|
||||
onRemoveItem,
|
||||
onClose,
|
||||
}: SelectionProps) {
|
||||
const theme = useTheme();
|
||||
const styles = getStyleSheet(theme);
|
||||
const dimensions = useWindowDimensions();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isTablet = useIsTablet();
|
||||
const keyboardHeight = useKeyboardHeight();
|
||||
|
||||
const [teamBarHeight, setTeamBarHeight] = useState(0);
|
||||
const [searchBarHeight, setSearchBarHeight] = useState(0);
|
||||
|
||||
const onLayoutSelectionTeamBar = useCallback((e: LayoutChangeEvent) => {
|
||||
setTeamBarHeight(e.nativeEvent.layout.height);
|
||||
}, []);
|
||||
|
||||
const onLayoutSearchBar = useCallback((e: LayoutChangeEvent) => {
|
||||
setSearchBarHeight(e.nativeEvent.layout.height);
|
||||
}, []);
|
||||
|
||||
const handleOnRemoveItem = (id: string) => {
|
||||
onRemoveItem(id);
|
||||
};
|
||||
|
||||
const bottomSpace = dimensions.height - wrapperHeight - modalPosition;
|
||||
const otherElementsSize = teamBarHeight + searchBarHeight;
|
||||
const insetsAdjust = (keyboardHeight + KEYBOARD_HEIGHT_ADJUST) || insets.bottom;
|
||||
|
||||
const keyboardOverlap = Platform.select({
|
||||
ios: isTablet ? (
|
||||
Math.max(0, keyboardHeight - bottomSpace)
|
||||
) : (
|
||||
insetsAdjust
|
||||
),
|
||||
default: 0,
|
||||
});
|
||||
const keyboardAdjust = Platform.select({
|
||||
ios: isTablet ? keyboardOverlap : insetsAdjust,
|
||||
default: 0,
|
||||
});
|
||||
|
||||
const workingSpace = wrapperHeight - keyboardOverlap;
|
||||
const spaceOnTop = otherElementsSize - AUTOCOMPLETE_ADJUST;
|
||||
const spaceOnBottom = workingSpace - (otherElementsSize + keyboardAdjust);
|
||||
const autocompletePosition = spaceOnBottom > spaceOnTop ? (
|
||||
otherElementsSize
|
||||
) : (
|
||||
workingSpace - otherElementsSize
|
||||
);
|
||||
const autocompleteAvailableSpace = spaceOnBottom > spaceOnTop ? spaceOnBottom : spaceOnTop;
|
||||
const isLandscape = dimensions.width > dimensions.height;
|
||||
const maxHeightAdjust = (isTablet && isLandscape) ? MAX_LIST_TABLET_DIFF : 0;
|
||||
const defaultMaxHeight = MAX_LIST_HEIGHT - maxHeightAdjust;
|
||||
|
||||
const [animatedAutocompletePosition, animatedAutocompleteAvailableSpace] = useAutocompleteDefaultAnimatedValues(autocompletePosition, autocompleteAvailableSpace);
|
||||
|
||||
const maxHeight = useDerivedValue(() => {
|
||||
return Math.min(animatedAutocompleteAvailableSpace.value, defaultMaxHeight);
|
||||
}, [animatedAutocompleteAvailableSpace, defaultMaxHeight]);
|
||||
|
||||
const searchListContainerAnimatedStyle = useAnimatedStyle(() => ({
|
||||
top: animatedAutocompletePosition.value,
|
||||
maxHeight: maxHeight.value,
|
||||
}), [animatedAutocompletePosition, maxHeight]);
|
||||
|
||||
const searchListContainerStyle = useMemo(() => {
|
||||
const style = [];
|
||||
|
||||
style.push(
|
||||
styles.searchList,
|
||||
searchListContainerAnimatedStyle,
|
||||
);
|
||||
|
||||
if (Platform.OS === 'ios') {
|
||||
style.push(styles.searchListShadow);
|
||||
}
|
||||
|
||||
return style;
|
||||
}, [searchResults, styles, searchListContainerAnimatedStyle]);
|
||||
|
||||
const searchListFlatListStyle = useMemo(() => {
|
||||
const style = [];
|
||||
|
||||
style.push(styles.searchListFlatList);
|
||||
|
||||
if (searchResults.length) {
|
||||
style.push(styles.searchListBorder, styles.searchListPadding);
|
||||
}
|
||||
|
||||
return style;
|
||||
}, [searchResults, styles]);
|
||||
|
||||
const renderNoResults = useCallback(() => {
|
||||
if (!term || loading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.searchListBorder, styles.searchListPadding]}>
|
||||
<TextItem
|
||||
text={term}
|
||||
type={TextItemType.SEARCH_NO_RESULTS}
|
||||
testID='invite.search_list_no_results'
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}, [term, loading]);
|
||||
|
||||
const renderItem = useCallback(({item}: ListRenderItemInfo<SearchResult>) => {
|
||||
const key = keyExtractor(item);
|
||||
|
||||
return (
|
||||
<TouchableWithFeedback
|
||||
key={key}
|
||||
index={key}
|
||||
onPress={() => onSelectItem(item)}
|
||||
underlayColor={changeOpacity(theme.buttonBg, 0.08)}
|
||||
type='native'
|
||||
testID={`invite.search_list_item.${key}`}
|
||||
>
|
||||
{typeof item === 'string' ? (
|
||||
<TextItem
|
||||
text={item}
|
||||
type={TextItemType.SEARCH_INVITE}
|
||||
testID='invite.search_list_text_item'
|
||||
/>
|
||||
) : (
|
||||
<UserItem
|
||||
user={item}
|
||||
testID='invite.search_list_user_item'
|
||||
/>
|
||||
)}
|
||||
</TouchableWithFeedback>
|
||||
);
|
||||
}, [searchResults, onSelectItem]);
|
||||
|
||||
const renderSelectedItems = useMemo(() => {
|
||||
const selectedItems = [];
|
||||
|
||||
for (const id of Object.keys(selectedIds)) {
|
||||
const selectedItem = selectedIds[id];
|
||||
|
||||
selectedItems.push(typeof selectedItem === 'string' ? (
|
||||
<SelectedChip
|
||||
id={id}
|
||||
key={id}
|
||||
text={selectedItem}
|
||||
onRemove={handleOnRemoveItem}
|
||||
testID={`invite.selected_item.${selectedItem}`}
|
||||
/>
|
||||
) : (
|
||||
<SelectedUser
|
||||
key={id}
|
||||
user={selectedItem}
|
||||
teammateNameDisplay={teammateNameDisplay}
|
||||
onRemove={handleOnRemoveItem}
|
||||
testID='invite.selected_item'
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
return selectedItems;
|
||||
}, [selectedIds]);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.container}
|
||||
testID={testID}
|
||||
>
|
||||
<SelectionTeamBar
|
||||
teamId={teamId}
|
||||
teamDisplayName={teamDisplayName}
|
||||
teamLastIconUpdate={teamLastIconUpdate}
|
||||
teamInviteId={teamInviteId}
|
||||
serverUrl={serverUrl}
|
||||
onLayoutContainer={onLayoutSelectionTeamBar}
|
||||
onClose={onClose}
|
||||
/>
|
||||
<SelectionSearchBar
|
||||
term={term}
|
||||
onSearchChange={onSearchChange}
|
||||
onLayoutContainer={onLayoutSearchBar}
|
||||
/>
|
||||
{Object.keys(selectedIds).length > 0 && (
|
||||
<ScrollView
|
||||
style={styles.selectedItems}
|
||||
contentContainerStyle={styles.selectedItemsContainer}
|
||||
testID='invite.selected_items'
|
||||
>
|
||||
{renderSelectedItems}
|
||||
</ScrollView>
|
||||
)}
|
||||
<Animated.View style={searchListContainerStyle}>
|
||||
<FlatList
|
||||
data={searchResults}
|
||||
keyboardShouldPersistTaps='always'
|
||||
{...keyboardDismissProp}
|
||||
keyExtractor={keyExtractor}
|
||||
initialNumToRender={INITIAL_BATCH_TO_RENDER}
|
||||
ListEmptyComponent={renderNoResults}
|
||||
maxToRenderPerBatch={INITIAL_BATCH_TO_RENDER + 1}
|
||||
renderItem={renderItem}
|
||||
scrollEventThrottle={SCROLL_EVENT_THROTTLE}
|
||||
testID='invite.search_list'
|
||||
style={searchListFlatListStyle}
|
||||
/>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
131
app/screens/invite/selection_search_bar.tsx
Normal file
131
app/screens/invite/selection_search_bar.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useState, useMemo} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {View, TextInput, LayoutChangeEvent} from 'react-native';
|
||||
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {makeStyleSheetFromTheme, changeOpacity, getKeyboardAppearanceFromTheme} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
const SEARCH_BAR_TITLE_MARGIN_TOP = 24;
|
||||
const SEARCH_BAR_MARGIN_TOP = 16;
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
return {
|
||||
container: {
|
||||
display: 'flex',
|
||||
},
|
||||
searchBarTitleText: {
|
||||
marginHorizontal: 20,
|
||||
marginTop: SEARCH_BAR_TITLE_MARGIN_TOP,
|
||||
color: theme.centerChannelColor,
|
||||
...typography('Heading', 700, 'SemiBold'),
|
||||
},
|
||||
searchBar: {
|
||||
marginHorizontal: 20,
|
||||
marginTop: SEARCH_BAR_MARGIN_TOP,
|
||||
},
|
||||
searchInput: {
|
||||
height: 48,
|
||||
backgroundColor: 'transparent',
|
||||
...typography('Body', 200, 'Regular'),
|
||||
lineHeight: 20,
|
||||
color: theme.centerChannelColor,
|
||||
borderWidth: 1,
|
||||
borderColor: changeOpacity(theme.centerChannelColor, 0.16),
|
||||
borderRadius: 4,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
searchInputPlaceholder: {
|
||||
color: changeOpacity(theme.centerChannelColor, 0.64),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
type SelectionSearchBarProps = {
|
||||
term: string;
|
||||
onSearchChange: (text: string) => void;
|
||||
onLayoutContainer: (e: LayoutChangeEvent) => void;
|
||||
}
|
||||
|
||||
export default function SelectionSearchBar({
|
||||
term,
|
||||
onSearchChange,
|
||||
onLayoutContainer,
|
||||
}: SelectionSearchBarProps) {
|
||||
const {formatMessage} = useIntl();
|
||||
const theme = useTheme();
|
||||
const styles = getStyleSheet(theme);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
const onLayoutSearchBar = useCallback((e: LayoutChangeEvent) => {
|
||||
onLayoutContainer(e);
|
||||
}, [onLayoutContainer]);
|
||||
|
||||
const onTextInputFocus = () => {
|
||||
setIsFocused(true);
|
||||
};
|
||||
|
||||
const onTextInputBlur = () => {
|
||||
setIsFocused(false);
|
||||
};
|
||||
|
||||
const handleSearchChange = (text: string) => {
|
||||
onSearchChange(text);
|
||||
};
|
||||
|
||||
const searchInputStyle = useMemo(() => {
|
||||
const style = [];
|
||||
|
||||
style.push(styles.searchInput);
|
||||
|
||||
if (isFocused) {
|
||||
style.push({
|
||||
borderWidth: 2,
|
||||
borderColor: theme.buttonBg,
|
||||
});
|
||||
}
|
||||
|
||||
return style;
|
||||
}, [isFocused, styles]);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.container}
|
||||
onLayout={onLayoutSearchBar}
|
||||
testID='invite.search_bar'
|
||||
>
|
||||
<FormattedText
|
||||
id='invite.sendInvitationsTo'
|
||||
defaultMessage='Send invitations to…'
|
||||
style={styles.searchBarTitleText}
|
||||
testID='invite.search_bar_title'
|
||||
/>
|
||||
<View style={styles.searchBar}>
|
||||
<TextInput
|
||||
autoCorrect={false}
|
||||
autoCapitalize={'none'}
|
||||
autoFocus={true}
|
||||
blurOnSubmit={false}
|
||||
disableFullscreenUI={true}
|
||||
enablesReturnKeyAutomatically={true}
|
||||
returnKeyType='search'
|
||||
style={searchInputStyle}
|
||||
placeholder={formatMessage({id: 'invite.searchPlaceholder', defaultMessage: 'Type a name or email address…'})}
|
||||
placeholderTextColor={styles.searchInputPlaceholder.color}
|
||||
onChangeText={handleSearchChange}
|
||||
onFocus={onTextInputFocus}
|
||||
onBlur={onTextInputBlur}
|
||||
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
|
||||
value={term}
|
||||
pointerEvents='auto'
|
||||
underlineColorAndroid='transparent'
|
||||
testID='invite.search_bar_input'
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
215
app/screens/invite/selection_team_bar.tsx
Normal file
215
app/screens/invite/selection_team_bar.tsx
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
// 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 {
|
||||
Platform,
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
LayoutChangeEvent,
|
||||
} from 'react-native';
|
||||
import Share from 'react-native-share';
|
||||
import {ShareOptions} from 'react-native-share/lib/typescript/types';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import TeamIcon from '@components/team_sidebar/team_list/team_item/team_icon';
|
||||
import {useServerDisplayName} from '@context/server';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {preventDoubleTap} from '@utils/tap';
|
||||
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
return {
|
||||
container: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 20,
|
||||
backgroundColor: changeOpacity(theme.centerChannelColor, 0.04),
|
||||
},
|
||||
iconContainer: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
},
|
||||
textContainer: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
teamText: {
|
||||
color: theme.centerChannelColor,
|
||||
marginLeft: 12,
|
||||
...typography('Body', 200, 'SemiBold'),
|
||||
},
|
||||
serverText: {
|
||||
color: changeOpacity(theme.centerChannelColor, 0.72),
|
||||
marginLeft: 12,
|
||||
...typography('Body', 75, 'Regular'),
|
||||
},
|
||||
shareLink: {
|
||||
display: 'flex',
|
||||
marginLeft: 'auto',
|
||||
},
|
||||
shareLinkButton: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
height: 40,
|
||||
paddingHorizontal: 20,
|
||||
backgroundColor: changeOpacity(theme.buttonBg, 0.08),
|
||||
borderRadius: 4,
|
||||
},
|
||||
shareLinkText: {
|
||||
color: theme.buttonBg,
|
||||
...typography('Body', 100, 'SemiBold'),
|
||||
paddingLeft: 7,
|
||||
},
|
||||
shareLinkIcon: {
|
||||
color: theme.buttonBg,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
type SelectionTeamBarProps = {
|
||||
teamId: string;
|
||||
teamDisplayName: string;
|
||||
teamLastIconUpdate: number;
|
||||
teamInviteId: string;
|
||||
serverUrl: string;
|
||||
onLayoutContainer: (e: LayoutChangeEvent) => void;
|
||||
onClose: () => Promise<void>;
|
||||
}
|
||||
|
||||
export default function SelectionTeamBar({
|
||||
teamId,
|
||||
teamDisplayName,
|
||||
teamLastIconUpdate,
|
||||
teamInviteId,
|
||||
serverUrl,
|
||||
onLayoutContainer,
|
||||
onClose,
|
||||
}: SelectionTeamBarProps) {
|
||||
const {formatMessage} = useIntl();
|
||||
const theme = useTheme();
|
||||
const styles = getStyleSheet(theme);
|
||||
const serverDisplayName = useServerDisplayName();
|
||||
|
||||
const handleOnLayoutContainer = useCallback((e: LayoutChangeEvent) => {
|
||||
onLayoutContainer(e);
|
||||
}, [onLayoutContainer]);
|
||||
|
||||
const handleOnShareLink = async () => {
|
||||
const url = `${serverUrl}/signup_user_complete/?id=${teamInviteId}`;
|
||||
const title = formatMessage({id: 'invite_people_to_team.title', defaultMessage: 'Join the {team} team'}, {team: teamDisplayName});
|
||||
const message = formatMessage({id: 'invite_people_to_team.message', defaultMessage: 'Here’s a link to collaborate and communicate with us on Mattermost.'});
|
||||
const icon = 'data:<data_type>/<file_extension>;base64,<base64_data>';
|
||||
|
||||
const options: ShareOptions = Platform.select({
|
||||
ios: {
|
||||
activityItemSources: [
|
||||
{
|
||||
placeholderItem: {
|
||||
type: 'url',
|
||||
content: url,
|
||||
},
|
||||
item: {
|
||||
default: {
|
||||
type: 'text',
|
||||
content: `${message} ${url}`,
|
||||
},
|
||||
copyToPasteBoard: {
|
||||
type: 'url',
|
||||
content: url,
|
||||
},
|
||||
},
|
||||
subject: {
|
||||
default: title,
|
||||
},
|
||||
linkMetadata: {
|
||||
originalUrl: url,
|
||||
url,
|
||||
title,
|
||||
icon,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
default: {
|
||||
title,
|
||||
subject: title,
|
||||
url,
|
||||
showAppsToView: true,
|
||||
},
|
||||
});
|
||||
|
||||
await onClose();
|
||||
|
||||
Share.open(
|
||||
options,
|
||||
).catch(() => {
|
||||
// do nothing
|
||||
});
|
||||
};
|
||||
|
||||
const handleShareLink = useCallback(preventDoubleTap(() => handleOnShareLink()), []);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.container}
|
||||
onLayout={handleOnLayoutContainer}
|
||||
>
|
||||
<View style={styles.iconContainer}>
|
||||
<TeamIcon
|
||||
id={teamId}
|
||||
displayName={teamDisplayName}
|
||||
lastIconUpdate={teamLastIconUpdate}
|
||||
selected={false}
|
||||
textColor={theme.centerChannelColor}
|
||||
backgroundColor={changeOpacity(theme.centerChannelColor, 0.16)}
|
||||
testID='invite.team_icon'
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.textContainer}>
|
||||
<Text
|
||||
style={styles.teamText}
|
||||
numberOfLines={1}
|
||||
testID='invite.team_display_name'
|
||||
>
|
||||
{teamDisplayName}
|
||||
</Text>
|
||||
<Text
|
||||
style={styles.serverText}
|
||||
numberOfLines={1}
|
||||
testID='invite.server_display_name'
|
||||
>
|
||||
{serverDisplayName}
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={handleShareLink}
|
||||
style={styles.shareLink}
|
||||
>
|
||||
<View
|
||||
style={styles.shareLinkButton}
|
||||
testID='invite.share_link.button'
|
||||
>
|
||||
<CompassIcon
|
||||
name='export-variant'
|
||||
size={18}
|
||||
style={styles.shareLinkIcon}
|
||||
/>
|
||||
<FormattedText
|
||||
id='invite.shareLink'
|
||||
defaultMessage='Share link'
|
||||
style={styles.shareLinkText}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
299
app/screens/invite/summary.tsx
Normal file
299
app/screens/invite/summary.tsx
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useMemo} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {View, Text, ScrollView} from 'react-native';
|
||||
import Button from 'react-native-button';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import AlertSvg from '@components/illustrations/alert';
|
||||
import ErrorSvg from '@components/illustrations/error';
|
||||
import SuccessSvg from '@components/illustrations/success';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {t} from '@i18n';
|
||||
import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles';
|
||||
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
import {SearchResult, Result} from './invite';
|
||||
import SummaryReport, {SummaryReportType} from './summary_report';
|
||||
|
||||
const MAX_WIDTH_CONTENT = 480;
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
return {
|
||||
container: {
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
},
|
||||
summary: {
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
},
|
||||
summaryContainer: {
|
||||
display: 'flex',
|
||||
flexGrow: 1,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'stretch',
|
||||
marginTop: 20,
|
||||
marginHorizontal: 20,
|
||||
paddingBottom: 20,
|
||||
},
|
||||
summaryContent: {
|
||||
flexGrow: 1,
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
maxWidth: MAX_WIDTH_CONTENT,
|
||||
},
|
||||
summarySvg: {
|
||||
marginBottom: 20,
|
||||
alignSelf: 'center',
|
||||
},
|
||||
summaryMessageText: {
|
||||
color: theme.centerChannelColor,
|
||||
...typography('Heading', 700, 'SemiBold'),
|
||||
textAlign: 'center',
|
||||
marginHorizontal: 32,
|
||||
marginBottom: 24,
|
||||
},
|
||||
summaryErrorText: {
|
||||
color: changeOpacity(theme.centerChannelColor, 0.72),
|
||||
...typography('Body', 200, 'Regular'),
|
||||
textAlign: 'center',
|
||||
},
|
||||
footer: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: changeOpacity(theme.centerChannelColor, 0.16),
|
||||
padding: 20,
|
||||
},
|
||||
summaryButtonContainer: {
|
||||
flexGrow: 1,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
summaryButtonTextContainer: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
height: 24,
|
||||
},
|
||||
summaryButtonIcon: {
|
||||
marginRight: 7,
|
||||
color: theme.buttonColor,
|
||||
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
enum SummaryButtonType {
|
||||
DONE = 'done',
|
||||
RETRY = 'retry',
|
||||
BACK = 'back',
|
||||
}
|
||||
|
||||
type SummaryProps = {
|
||||
result: Result;
|
||||
selectedIds: {[id: string]: SearchResult};
|
||||
error?: string;
|
||||
testID: string;
|
||||
onClose: () => void;
|
||||
onRetry: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export default function Summary({
|
||||
result,
|
||||
selectedIds,
|
||||
error,
|
||||
testID,
|
||||
onClose,
|
||||
onRetry,
|
||||
onBack,
|
||||
}: SummaryProps) {
|
||||
const {formatMessage, locale} = useIntl();
|
||||
const theme = useTheme();
|
||||
const styles = getStyleSheet(theme);
|
||||
|
||||
const {sent, notSent} = result;
|
||||
const sentCount = sent.length;
|
||||
const notSentCount = notSent.length;
|
||||
|
||||
const styleSummaryMessageText = useMemo(() => {
|
||||
const style = [];
|
||||
|
||||
style.push(styles.summaryMessageText);
|
||||
|
||||
if (error) {
|
||||
style.push({marginBottom: 8});
|
||||
}
|
||||
|
||||
return style;
|
||||
}, [error, styles]);
|
||||
|
||||
let svg = <></>;
|
||||
let message = '';
|
||||
|
||||
if (error) {
|
||||
svg = <ErrorSvg/>;
|
||||
message = formatMessage(
|
||||
{
|
||||
id: 'invite.summary.error',
|
||||
defaultMessage: '{invitationsCount, plural, one {Invitation} other {Invitations}} could not be sent successfully',
|
||||
},
|
||||
{invitationsCount: sentCount + notSentCount},
|
||||
);
|
||||
} else if (!sentCount && notSentCount) {
|
||||
svg = <ErrorSvg/>;
|
||||
message = formatMessage(
|
||||
{
|
||||
id: 'invite.summary.not_sent',
|
||||
defaultMessage: '{notSentCount, plural, one {Invitation wasn’t} other {Invitations weren’t}} sent',
|
||||
},
|
||||
{notSentCount},
|
||||
);
|
||||
} else if (sentCount && !notSentCount) {
|
||||
svg = <SuccessSvg/>;
|
||||
message = formatMessage(
|
||||
{
|
||||
id: 'invite.summary.sent',
|
||||
defaultMessage: 'Your {sentCount, plural, one {invitation has} other {invitations have}} been sent',
|
||||
},
|
||||
{sentCount},
|
||||
);
|
||||
} else if (sentCount && notSentCount) {
|
||||
svg = <AlertSvg/>;
|
||||
message = formatMessage(
|
||||
{
|
||||
id: 'invite.summary.some_not_sent',
|
||||
defaultMessage: '{notSentCount, plural, one {An invitation was} other {Some invitations were}} not sent',
|
||||
},
|
||||
{notSentCount},
|
||||
);
|
||||
}
|
||||
|
||||
const renderButton = useCallback((type: SummaryButtonType) => {
|
||||
let onPress;
|
||||
let iconName = '';
|
||||
let text;
|
||||
let styleButtonText = buttonTextStyle(theme, 'lg', 'primary');
|
||||
let styleButtonBackground = buttonBackgroundStyle(theme, 'lg', 'primary');
|
||||
const styleButtonIcon = [];
|
||||
styleButtonIcon.push(styles.summaryButtonIcon);
|
||||
|
||||
switch (type) {
|
||||
case SummaryButtonType.BACK:
|
||||
onPress = onBack;
|
||||
iconName = 'chevron-left';
|
||||
text = {
|
||||
id: t('invite.summary.back'),
|
||||
defaultMessage: 'Go back',
|
||||
};
|
||||
styleButtonText = buttonTextStyle(theme, 'lg', 'tertiary');
|
||||
styleButtonBackground = [buttonBackgroundStyle(theme, 'lg', 'tertiary'), {marginRight: 8}];
|
||||
styleButtonIcon.push({color: theme.buttonBg});
|
||||
break;
|
||||
case SummaryButtonType.RETRY:
|
||||
onPress = onRetry;
|
||||
iconName = 'refresh';
|
||||
text = {
|
||||
id: t('invite.summary.try_again'),
|
||||
defaultMessage: 'Try again',
|
||||
};
|
||||
break;
|
||||
case SummaryButtonType.DONE:
|
||||
default:
|
||||
onPress = onClose;
|
||||
text = {
|
||||
id: t('invite.summary.done'),
|
||||
defaultMessage: 'Done',
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
containerStyle={[styleButtonBackground, {flexGrow: 1}]}
|
||||
onPress={onPress}
|
||||
testID={`invite.summary_button.${SummaryButtonType.RETRY}`}
|
||||
>
|
||||
<View style={styles.summaryButtonTextContainer}>
|
||||
{iconName && (
|
||||
<CompassIcon
|
||||
name={iconName}
|
||||
size={24}
|
||||
style={styleButtonIcon}
|
||||
/>
|
||||
)}
|
||||
<FormattedText
|
||||
{...text}
|
||||
style={styleButtonText}
|
||||
/>
|
||||
</View>
|
||||
</Button>
|
||||
);
|
||||
}, [theme, locale, onClose, onRetry, onBack]);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.container}
|
||||
testID={testID}
|
||||
>
|
||||
<ScrollView
|
||||
style={styles.summary}
|
||||
contentContainerStyle={styles.summaryContainer}
|
||||
testID='invite.summary'
|
||||
>
|
||||
<View style={styles.summaryContent}>
|
||||
<View style={styles.summarySvg}>
|
||||
{svg}
|
||||
</View>
|
||||
<Text style={styleSummaryMessageText}>
|
||||
{message}
|
||||
</Text>
|
||||
{error ? (
|
||||
<Text style={styles.summaryErrorText}>
|
||||
{error}
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
{notSent.length > 0 && (
|
||||
<SummaryReport
|
||||
type={SummaryReportType.NOT_SENT}
|
||||
invites={notSent}
|
||||
selectedIds={selectedIds}
|
||||
testID='invite.summary_report'
|
||||
/>
|
||||
)}
|
||||
{sent.length > 0 && (
|
||||
<SummaryReport
|
||||
type={SummaryReportType.SENT}
|
||||
invites={sent}
|
||||
selectedIds={selectedIds}
|
||||
testID='invite.summary_report'
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
<View style={styles.footer}>
|
||||
<View style={styles.summaryButtonContainer}>
|
||||
{error ? (
|
||||
<>
|
||||
{renderButton(SummaryButtonType.BACK)}
|
||||
{renderButton(SummaryButtonType.RETRY)}
|
||||
</>
|
||||
) : (
|
||||
renderButton(SummaryButtonType.DONE)
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
149
app/screens/invite/summary_report.tsx
Normal file
149
app/screens/invite/summary_report.tsx
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {View, Text} from 'react-native';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import UserItem from '@components/user_item';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
import {SearchResult, InviteResult} from './invite';
|
||||
import TextItem, {TextItemType} from './text_item';
|
||||
|
||||
const COLOR_SUCCESS = '#3db887';
|
||||
const COLOR_ERROR = '#d24b4e';
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
return {
|
||||
container: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderWidth: 1,
|
||||
borderColor: changeOpacity(theme.centerChannelColor, 0.16),
|
||||
borderRadius: 4,
|
||||
marginBottom: 16,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
title: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
titleText: {
|
||||
marginLeft: 12,
|
||||
...typography('Heading', 300, 'SemiBold'),
|
||||
color: theme.centerChannelColor,
|
||||
},
|
||||
item: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
paddingVertical: 12,
|
||||
},
|
||||
user: {
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
height: 'auto',
|
||||
},
|
||||
reason: {
|
||||
paddingLeft: 56,
|
||||
paddingRight: 20,
|
||||
...typography('Body', 75, 'Regular'),
|
||||
color: changeOpacity(theme.centerChannelColor, 0.64),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export enum SummaryReportType {
|
||||
SENT = 'sent',
|
||||
NOT_SENT = 'not_sent',
|
||||
}
|
||||
|
||||
type SummaryReportProps = {
|
||||
type: SummaryReportType;
|
||||
invites: InviteResult[];
|
||||
selectedIds: {[id: string]: SearchResult};
|
||||
testID: string;
|
||||
}
|
||||
|
||||
export default function SummaryReport({
|
||||
type,
|
||||
invites,
|
||||
selectedIds,
|
||||
testID,
|
||||
}: SummaryReportProps) {
|
||||
const {formatMessage} = useIntl();
|
||||
const theme = useTheme();
|
||||
const styles = getStyleSheet(theme);
|
||||
|
||||
const count = invites.length;
|
||||
|
||||
const sent = type === SummaryReportType.SENT;
|
||||
const message = sent ? (
|
||||
formatMessage(
|
||||
{
|
||||
id: 'invite.summary.report.sent',
|
||||
defaultMessage: '{count} successful {count, plural, one {invitation} other {invitations}}',
|
||||
},
|
||||
{count},
|
||||
)
|
||||
) : (
|
||||
formatMessage(
|
||||
{
|
||||
id: 'invite.summary.report.notSent',
|
||||
defaultMessage: '{count} {count, plural, one {invitation} other {invitations}} not sent',
|
||||
},
|
||||
{count},
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.container}
|
||||
testID={`${testID}.${type}`}
|
||||
>
|
||||
<View style={styles.title}>
|
||||
<CompassIcon
|
||||
name={sent ? 'check-circle' : 'close-circle'}
|
||||
size={24}
|
||||
style={{color: sent ? COLOR_SUCCESS : COLOR_ERROR}}
|
||||
/>
|
||||
<Text style={styles.titleText}>
|
||||
{message}
|
||||
</Text>
|
||||
</View>
|
||||
{invites.map(({userId, reason}) => {
|
||||
const item = selectedIds[userId];
|
||||
|
||||
return (
|
||||
<View
|
||||
key={userId}
|
||||
style={styles.item}
|
||||
>
|
||||
{typeof item === 'string' ? (
|
||||
<TextItem
|
||||
text={item}
|
||||
type={TextItemType.SUMMARY}
|
||||
testID={`${testID}.text_item`}
|
||||
/>
|
||||
) : (
|
||||
<UserItem
|
||||
user={item}
|
||||
containerStyle={styles.user}
|
||||
testID={`${testID}.user_item`}
|
||||
/>
|
||||
)}
|
||||
<Text style={styles.reason}>
|
||||
{reason}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
111
app/screens/invite/text_item.tsx
Normal file
111
app/screens/invite/text_item.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {View, Text} from 'react-native';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
return {
|
||||
item: {
|
||||
paddingHorizontal: 20,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
search: {
|
||||
height: 40,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
itemText: {
|
||||
display: 'flex',
|
||||
...typography('Body', 200, 'Regular'),
|
||||
color: theme.centerChannelColor,
|
||||
},
|
||||
itemTerm: {
|
||||
display: 'flex',
|
||||
...typography('Body', 200, 'SemiBold'),
|
||||
color: theme.centerChannelColor,
|
||||
marginLeft: 4,
|
||||
},
|
||||
itemImage: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: 24,
|
||||
width: 24,
|
||||
borderRadius: 12,
|
||||
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
|
||||
marginRight: 12,
|
||||
},
|
||||
itemIcon: {
|
||||
color: changeOpacity(theme.centerChannelColor, 0.56),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export enum TextItemType {
|
||||
SEARCH_INVITE = 'search_invite',
|
||||
SEARCH_NO_RESULTS = 'search_no_results',
|
||||
SUMMARY = 'summary',
|
||||
}
|
||||
|
||||
type TextItemProps = {
|
||||
text?: string;
|
||||
type: TextItemType;
|
||||
testID: string;
|
||||
}
|
||||
|
||||
export default function TextItem({
|
||||
text = '',
|
||||
type,
|
||||
testID,
|
||||
}: TextItemProps) {
|
||||
const {formatMessage} = useIntl();
|
||||
const theme = useTheme();
|
||||
const styles = getStyleSheet(theme);
|
||||
|
||||
const search = type === TextItemType.SEARCH_INVITE || type === TextItemType.SEARCH_NO_RESULTS;
|
||||
const email = type === TextItemType.SEARCH_INVITE || type === TextItemType.SUMMARY;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[styles.item, search ? styles.search : {}]}
|
||||
testID={`${testID}.${text}`}
|
||||
>
|
||||
{email && (
|
||||
<View style={styles.itemImage}>
|
||||
<CompassIcon
|
||||
name={search ? 'email-plus-outline' : 'email-outline'}
|
||||
size={14}
|
||||
style={styles.itemIcon}
|
||||
/>
|
||||
</View>)
|
||||
}
|
||||
{search && (
|
||||
<Text
|
||||
style={styles.itemText}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{email ? (
|
||||
formatMessage({id: 'invite.search.email_invite', defaultMessage: 'invite'})
|
||||
) : (
|
||||
formatMessage({id: 'invite.search.no_results', defaultMessage: 'No one found matching'})
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
<Text
|
||||
style={[search ? styles.itemTerm : styles.itemText, {flex: 1}]}
|
||||
numberOfLines={1}
|
||||
testID={`${testID}.text.${text}`}
|
||||
>
|
||||
{text}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
@ -327,6 +327,28 @@
|
|||
"intro.welcome.public": "Add some more team members to the channel or start a conversation below.",
|
||||
"invite_people_to_team.message": "Here’s a link to collaborate and communicate with us on Mattermost.",
|
||||
"invite_people_to_team.title": "Join the {team} team",
|
||||
"invite.members.already_member": "This person is already a team member",
|
||||
"invite.members.user_is_guest": "Contact your admin to make this guest a full member",
|
||||
"invite.search.email_invite": "invite",
|
||||
"invite.search.no_results": "No one found matching",
|
||||
"invite.searchPlaceholder": "Type a name or email address…",
|
||||
"invite.send_error": "Something went wrong while trying to send invitations. Please check your network connection and try again.",
|
||||
"invite.send_invite": "Send",
|
||||
"invite.sendInvitationsTo": "Send invitations to…",
|
||||
"invite.shareLink": "Share link",
|
||||
"invite.summary.done": "Done",
|
||||
"invite.summary.email_invite": "An invitation email has been sent",
|
||||
"invite.summary.error": "{invitationsCount, plural, one {Invitation} other {Invitations}} could not be sent successfully",
|
||||
"invite.summary.member_invite": "Invited as a member of {teamDisplayName}",
|
||||
"invite.summary.not_sent": "{notSentCount, plural, one {Invitation wasn’t} other {Invitations weren’t}} sent",
|
||||
"invite.summary.report.notSent": "{count} {count, plural, one {invitation} other {invitations}} not sent",
|
||||
"invite.summary.report.sent": "{count} successful {count, plural, one {invitation} other {invitations}}",
|
||||
"invite.summary.sent": "Your {sentCount, plural, one {invitation has} other {invitations have}} been sent",
|
||||
"invite.summary.smtp_failure": "SMTP is not configured in System Console",
|
||||
"invite.summary.some_not_sent": "{notSentCount, plural, one {An invitation was} other {Some invitations were}} not sent",
|
||||
"invite.summary.try_again": "Try again",
|
||||
"invite.title": "Invite",
|
||||
"invite.title.summary": "Invite summary",
|
||||
"join_team.error.group_error": "You need to be a member of a linked group to join this team.",
|
||||
"join_team.error.message": "There has been an error joining the team",
|
||||
"join_team.error.title": "Error joining a team",
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import EmojiPickerScreen from './emoji_picker';
|
|||
import FindChannelsScreen from './find_channels';
|
||||
import GlobalThreadsScreen from './global_threads';
|
||||
import HomeScreen from './home';
|
||||
import Invite from './invite';
|
||||
import LoginScreen from './login';
|
||||
import MentionNotificationSettingsScreen from './mention_notification_settings';
|
||||
import NotificationSettingsScreen from './notification_settings';
|
||||
|
|
@ -69,6 +70,7 @@ export {
|
|||
FindChannelsScreen,
|
||||
GlobalThreadsScreen,
|
||||
HomeScreen,
|
||||
Invite,
|
||||
LoginScreen,
|
||||
MentionNotificationSettingsScreen,
|
||||
NotificationSettingsScreen,
|
||||
|
|
|
|||
127
detox/e2e/support/ui/screen/invite.ts
Normal file
127
detox/e2e/support/ui/screen/invite.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {ChannelListScreen} from '@support/ui/screen';
|
||||
import {timeouts, wait} from '@support/utils';
|
||||
import {expect} from 'detox';
|
||||
|
||||
class InviteScreen {
|
||||
testID = {
|
||||
inviteScreen: 'invite.screen',
|
||||
screenSummary: 'invite.screen.summary',
|
||||
screenSelection: 'invite.screen.selection',
|
||||
closeButton: 'invite.close.button',
|
||||
sendButton: 'invite.send.button',
|
||||
teamIcon: 'invite.team_icon',
|
||||
teamDisplayName: 'invite.team_display_name',
|
||||
serverDisplayName: 'invite.server_display_name',
|
||||
shareLinkButton: 'invite.share_link.button',
|
||||
searchBarTitle: 'invite.search_bar_title',
|
||||
searchBarInput: 'invite.search_bar_input',
|
||||
selectedItems: 'invite.selected_items',
|
||||
selectedItemPrefix: 'invite.selected_item',
|
||||
searchList: 'invite.search_list',
|
||||
searchListItemPrefix: 'invite.search_list_item.',
|
||||
searchListTextItemPrefix: 'invite.search_list_text_item',
|
||||
searchListUserItemPrefix: 'invite.search_list_user_item',
|
||||
searchListNoResultsPrefix: 'invite.search_list_no_results',
|
||||
summaryReportPrefix: 'invite.summary_report',
|
||||
summaryReportTextItemPrefix: 'invite.summary_report.text_item',
|
||||
summaryReportUserItemPrefix: 'invite.summary_report.user_item',
|
||||
};
|
||||
|
||||
inviteScreen = element(by.id(this.testID.inviteScreen));
|
||||
screenSummary = element(by.id(this.testID.screenSummary));
|
||||
screenSelection = element(by.id(this.testID.screenSelection));
|
||||
closeButton = element(by.id(this.testID.closeButton));
|
||||
sendButton = element(by.id(this.testID.sendButton));
|
||||
teamIcon = element(by.id(this.testID.teamIcon));
|
||||
teamDisplayName = element(by.id(this.testID.teamDisplayName));
|
||||
serverDisplayName = element(by.id(this.testID.serverDisplayName));
|
||||
shareLinkButton = element(by.id(this.testID.shareLinkButton));
|
||||
searchBarTitle = element(by.id(this.testID.searchBarTitle));
|
||||
searchBarInput = element(by.id(this.testID.searchBarInput));
|
||||
selectedItems = element(by.id(this.testID.selectedItems));
|
||||
selectedItemPrefix = element(by.id(this.testID.selectedItemPrefix));
|
||||
searchList = element(by.id(this.testID.searchList));
|
||||
searchListItemPrefix = element(by.id(this.testID.searchListItemPrefix));
|
||||
searchListTextItemPrefix = element(by.id(this.testID.searchListTextItemPrefix));
|
||||
searchListUserItemPrefix = element(by.id(this.testID.searchListUserItemPrefix));
|
||||
searchListNoResultsPrefix = element(by.id(this.testID.searchListNoResultsPrefix));
|
||||
summaryReportTextItemPrefix = element(by.id(this.testID.summaryReportTextItemPrefix));
|
||||
summaryReportUserItemPrefix = element(by.id(this.testID.summaryReportUserItemPrefix));
|
||||
|
||||
getSearchListTextItem = (id: string) => {
|
||||
return element(by.id(`${this.testID.searchListTextItemPrefix}.${id}`));
|
||||
};
|
||||
|
||||
getSearchListTextItemText = (id: string) => {
|
||||
return element(by.id(`${this.testID.searchListTextItemPrefix}.text.${id}`));
|
||||
};
|
||||
|
||||
getSearchListUserItem = (id: string) => {
|
||||
return element(by.id(`${this.testID.searchListUserItemPrefix}.${id}`));
|
||||
};
|
||||
|
||||
getSearchListUserItemText = (id: string) => {
|
||||
return element(by.id(`${this.testID.searchListUserItemPrefix}.${id}.username`));
|
||||
};
|
||||
|
||||
getSearchListNoResults = (id: string) => {
|
||||
return element(by.id(`${this.testID.searchListNoResultsPrefix}.${id}`));
|
||||
};
|
||||
|
||||
getSearchListNoResultsText = (id: string) => {
|
||||
return element(by.id(`${this.testID.searchListNoResultsPrefix}.text.${id}`));
|
||||
};
|
||||
|
||||
getSelectedItem = (id: string) => {
|
||||
return element(by.id(`${this.testID.selectedItemPrefix}.${id}`));
|
||||
};
|
||||
|
||||
getSummaryReportSent = () => {
|
||||
return element(by.id(`${this.testID.summaryReportPrefix}.sent`));
|
||||
};
|
||||
|
||||
getSummaryReportNotSent = () => {
|
||||
return element(by.id(`${this.testID.summaryReportPrefix}.not_sent`));
|
||||
};
|
||||
|
||||
getSummaryReportTextItem = (id: string) => {
|
||||
return element(by.id(`${this.testID.summaryReportTextItemPrefix}.${id}`));
|
||||
};
|
||||
|
||||
getSummaryReportTextItemText = (id: string) => {
|
||||
return element(by.id(`${this.testID.summaryReportTextItemPrefix}.text.${id}`));
|
||||
};
|
||||
|
||||
getSummaryReportUserItem = (id: string) => {
|
||||
return element(by.id(`${this.testID.summaryReportUserItemPrefix}.${id}`));
|
||||
};
|
||||
|
||||
getSummaryReportUserItemText = (id: string) => {
|
||||
return element(by.id(`${this.testID.summaryReportUserItemPrefix}.${id}.username`));
|
||||
};
|
||||
|
||||
toBeVisible = async () => {
|
||||
await waitFor(this.inviteScreen).toExist().withTimeout(timeouts.TEN_SEC);
|
||||
|
||||
return this.inviteScreen;
|
||||
};
|
||||
|
||||
open = async () => {
|
||||
await ChannelListScreen.headerPlusButton.tap();
|
||||
await wait(timeouts.ONE_SEC);
|
||||
await ChannelListScreen.invitePeopleToTeamItem.tap();
|
||||
|
||||
return this.toBeVisible();
|
||||
};
|
||||
|
||||
close = async () => {
|
||||
await this.closeButton.tap();
|
||||
await expect(this.inviteScreen).not.toBeVisible();
|
||||
};
|
||||
}
|
||||
|
||||
const inviteScreen = new InviteScreen();
|
||||
export default inviteScreen;
|
||||
|
|
@ -45,6 +45,7 @@ class ServerScreen {
|
|||
connectToServer = async (serverUrl: string, serverDisplayName: string) => {
|
||||
await this.toBeVisible();
|
||||
await this.serverUrlInput.replaceText(serverUrl);
|
||||
await this.serverUrlInput.tapReturnKey();
|
||||
await this.serverDisplayNameInput.replaceText(serverDisplayName);
|
||||
await this.connectButton.tap();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,32 +7,34 @@
|
|||
// - Use element testID when selecting an element. Create one if none.
|
||||
// *******************************************************************
|
||||
|
||||
import {Setup} from '@support/server_api';
|
||||
import {Setup, User} from '@support/server_api';
|
||||
import {
|
||||
serverOneUrl,
|
||||
siteOneUrl,
|
||||
} from '@support/test_config';
|
||||
import {
|
||||
ChannelListScreen,
|
||||
Invite,
|
||||
HomeScreen,
|
||||
LoginScreen,
|
||||
ServerScreen,
|
||||
} from '@support/ui/screen';
|
||||
import {isIos, timeouts, wait} from '@support/utils';
|
||||
import {isIos, timeouts} from '@support/utils';
|
||||
import {expect} from 'detox';
|
||||
|
||||
function systemDialog(label: string) {
|
||||
if (device.getPlatform() === 'ios') {
|
||||
if (isIos()) {
|
||||
return element(by.label(label)).atIndex(0);
|
||||
}
|
||||
return element(by.text(label));
|
||||
}
|
||||
|
||||
describe('Teams - Invite people', () => {
|
||||
describe('Teams - Invite', () => {
|
||||
const serverOneDisplayName = 'Server 1';
|
||||
|
||||
let testTeam: any;
|
||||
let testUser: any;
|
||||
let testUser1: any;
|
||||
|
||||
beforeAll(async () => {
|
||||
const {team, user} = await Setup.apiInit(siteOneUrl);
|
||||
|
|
@ -40,38 +42,208 @@ describe('Teams - Invite people', () => {
|
|||
testTeam = team;
|
||||
testUser = user;
|
||||
|
||||
const {user: user1} = await User.apiCreateUser(siteOneUrl, {prefix: 'i'});
|
||||
|
||||
testUser1 = user1;
|
||||
|
||||
// # Log in to server
|
||||
await ServerScreen.connectToServer(serverOneUrl, serverOneDisplayName);
|
||||
await LoginScreen.login(testUser);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await device.reloadReactNative();
|
||||
|
||||
// * Verify on channel list screen
|
||||
await ChannelListScreen.toBeVisible();
|
||||
|
||||
// # Open invite screen
|
||||
await Invite.open();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// # Close invite screen
|
||||
await Invite.close();
|
||||
|
||||
// # Log out
|
||||
await HomeScreen.logout();
|
||||
});
|
||||
|
||||
it('MM-T5360 - should open the invite screen', async () => {
|
||||
// * Verify invite screen Header buttons
|
||||
await expect(Invite.closeButton).toBeVisible();
|
||||
await expect(Invite.sendButton).toBeVisible();
|
||||
|
||||
// * Verify Team data
|
||||
await expect(Invite.teamDisplayName).toHaveText(testTeam.display_name);
|
||||
await expect(Invite.teamIcon).toBeVisible();
|
||||
|
||||
// * Verify default Selection
|
||||
await expect(Invite.screenSelection).toBeVisible();
|
||||
|
||||
// * Verify Server data
|
||||
await expect(Invite.serverDisplayName).toHaveText(serverOneDisplayName);
|
||||
|
||||
// * Verify Share Link
|
||||
await expect(Invite.shareLinkButton).toBeVisible();
|
||||
|
||||
// * Verify Search bar
|
||||
await expect(Invite.searchBarTitle).toBeVisible();
|
||||
await expect(Invite.searchBarInput).toBeVisible();
|
||||
});
|
||||
|
||||
it('MM-T5221 - should be able to share a URL invite to the team', async () => {
|
||||
// # Open plus menu
|
||||
await ChannelListScreen.headerPlusButton.tap();
|
||||
|
||||
// * Verify invite people to team item is available
|
||||
await wait(timeouts.ONE_SEC);
|
||||
await expect(ChannelListScreen.invitePeopleToTeamItem).toExist();
|
||||
|
||||
// # Tap on invite people to team item
|
||||
await ChannelListScreen.invitePeopleToTeamItem.tap();
|
||||
// # Tap on Share link
|
||||
await Invite.shareLinkButton.tap();
|
||||
|
||||
if (isIos()) {
|
||||
const dialog = systemDialog(`Join the ${testTeam.display_name} team`);
|
||||
|
||||
// * Verify share dialog is open
|
||||
await expect(systemDialog(`Join the ${testTeam.display_name} team`)).toExist();
|
||||
await expect(dialog).toExist();
|
||||
|
||||
// # Close share dialog
|
||||
await device.reloadReactNative();
|
||||
await dialog.swipe('down');
|
||||
}
|
||||
});
|
||||
|
||||
it('MM-T5361 - should show no results item in search list', async () => {
|
||||
const noUser = 'qwertyuiop';
|
||||
|
||||
// # Search for a non existent user
|
||||
await Invite.searchBarInput.replaceText(noUser);
|
||||
|
||||
// * Validate no results item in search list
|
||||
await expect(Invite.getSearchListNoResults(noUser)).toBeVisible();
|
||||
await expect(Invite.getSearchListNoResultsText(noUser)).toHaveText(noUser);
|
||||
});
|
||||
|
||||
it('MM-T5362 - should be able to send email invite', async () => {
|
||||
const noUserEmailFormat = 'qwerty@ui.op';
|
||||
|
||||
// # Search for a non existent user with email format
|
||||
await Invite.searchBarInput.replaceText(noUserEmailFormat);
|
||||
|
||||
// * Validate email invite item in search list
|
||||
await expect(Invite.getSearchListTextItem(noUserEmailFormat)).toBeVisible();
|
||||
await expect(Invite.getSearchListTextItemText(noUserEmailFormat)).toHaveText(noUserEmailFormat);
|
||||
|
||||
// # Select email invite item
|
||||
await Invite.getSearchListTextItem(noUserEmailFormat).tap();
|
||||
await expect(Invite.getSearchListTextItem(noUserEmailFormat)).not.toBeVisible();
|
||||
|
||||
// * Validate email invite is added to selected items
|
||||
await expect(Invite.getSelectedItem(noUserEmailFormat)).toBeVisible();
|
||||
|
||||
// # Send invitation
|
||||
await Invite.sendButton.tap();
|
||||
|
||||
// * Validate summary report sent
|
||||
await expect(Invite.screenSummary).toBeVisible();
|
||||
await expect(Invite.getSummaryReportSent()).toBeVisible();
|
||||
await expect(Invite.getSummaryReportNotSent()).not.toExist();
|
||||
await expect(Invite.getSummaryReportTextItem(noUserEmailFormat)).toBeVisible();
|
||||
await expect(Invite.getSummaryReportTextItemText(noUserEmailFormat)).toHaveText(noUserEmailFormat);
|
||||
});
|
||||
|
||||
it('MM-T5363 - should be able to send user invite', async () => {
|
||||
const username = ` @${testUser1.username}`;
|
||||
|
||||
// # Search for a existent user
|
||||
await Invite.searchBarInput.replaceText(testUser1.username);
|
||||
|
||||
// * Validate user item in search list
|
||||
await expect(Invite.getSearchListUserItem(testUser1.id)).toBeVisible();
|
||||
await expect(Invite.getSearchListUserItemText(testUser1.id)).toHaveText(username);
|
||||
|
||||
// # Select user item
|
||||
await Invite.getSearchListUserItem(testUser1.id).tap();
|
||||
await expect(Invite.getSearchListUserItem(testUser1.id)).not.toBeVisible();
|
||||
|
||||
// * Validate user is added to selected items
|
||||
await expect(Invite.getSelectedItem(testUser1.id)).toBeVisible();
|
||||
|
||||
// # Send invitation
|
||||
await Invite.sendButton.tap();
|
||||
|
||||
// * Validate summary report sent
|
||||
await expect(Invite.screenSummary).toBeVisible();
|
||||
await expect(Invite.getSummaryReportSent()).toBeVisible();
|
||||
await expect(Invite.getSummaryReportNotSent()).not.toExist();
|
||||
await expect(Invite.getSummaryReportUserItem(testUser1.id)).toBeVisible();
|
||||
await expect(Invite.getSummaryReportUserItemText(testUser1.id)).toHaveText(username);
|
||||
});
|
||||
|
||||
it('MM-T5364 - should not be able to send user invite to user already in team', async () => {
|
||||
const username = ` @${testUser1.username}`;
|
||||
|
||||
// # Search for a existent user already in team
|
||||
await Invite.searchBarInput.replaceText(testUser1.username);
|
||||
|
||||
// * Validate user item in search list
|
||||
await expect(Invite.getSearchListUserItem(testUser1.id)).toBeVisible();
|
||||
|
||||
// # Select user item
|
||||
await Invite.getSearchListUserItem(testUser1.id).tap();
|
||||
|
||||
// * Validate user is added to selected items
|
||||
await expect(Invite.getSelectedItem(testUser1.id)).toBeVisible();
|
||||
|
||||
// # Send invitation
|
||||
await Invite.sendButton.tap();
|
||||
|
||||
// * Validate summary report not sent
|
||||
await expect(Invite.screenSummary).toBeVisible();
|
||||
await expect(Invite.getSummaryReportSent()).not.toExist();
|
||||
await expect(Invite.getSummaryReportNotSent()).toBeVisible();
|
||||
await expect(Invite.getSummaryReportUserItem(testUser1.id)).toBeVisible();
|
||||
await expect(Invite.getSummaryReportUserItemText(testUser1.id)).toHaveText(username);
|
||||
});
|
||||
|
||||
it('MM-T5365 - should handle both sent and not sent invites', async () => {
|
||||
const {user: testUser2} = await User.apiCreateUser(siteOneUrl, {prefix: 'i'});
|
||||
|
||||
const username1 = ` @${testUser1.username}`;
|
||||
const username2 = ` @${testUser2.username}`;
|
||||
|
||||
// # Search for a existent user
|
||||
await Invite.searchBarInput.replaceText(testUser2.username);
|
||||
|
||||
// * Validate user item in search list
|
||||
await expect(Invite.getSearchListUserItem(testUser2.id)).toBeVisible();
|
||||
|
||||
// # Select user item
|
||||
await Invite.getSearchListUserItem(testUser2.id).tap();
|
||||
|
||||
// * Validate user is added to selected items
|
||||
await expect(Invite.getSelectedItem(testUser2.id)).toBeVisible();
|
||||
|
||||
// # Search for a existent user already in team
|
||||
await Invite.searchBarInput.replaceText(testUser1.username);
|
||||
|
||||
// # Wait for user item in search list
|
||||
await waitFor(Invite.getSearchListUserItem(testUser1.id)).toExist().withTimeout(timeouts.TWO_SEC);
|
||||
|
||||
// # Select user item
|
||||
await Invite.getSearchListUserItem(testUser1.id).tap();
|
||||
|
||||
// * Validate user is added to selected items
|
||||
await expect(Invite.getSelectedItem(testUser1.id)).toBeVisible();
|
||||
|
||||
// # Send invitation
|
||||
await Invite.sendButton.tap();
|
||||
|
||||
// * Validate summary
|
||||
await expect(Invite.screenSummary).toBeVisible();
|
||||
|
||||
// * Validate summary report not sent
|
||||
await expect(Invite.getSummaryReportNotSent()).toBeVisible();
|
||||
await expect(Invite.getSummaryReportUserItem(testUser1.id)).toBeVisible();
|
||||
await expect(Invite.getSummaryReportUserItemText(testUser1.id)).toHaveText(username1);
|
||||
|
||||
// * Validate summary report sent
|
||||
await expect(Invite.getSummaryReportSent()).toBeVisible();
|
||||
await expect(Invite.getSummaryReportUserItem(testUser2.id)).toBeVisible();
|
||||
await expect(Invite.getSummaryReportUserItemText(testUser2.id)).toHaveText(username2);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
5
types/api/teams.d.ts
vendored
5
types/api/teams.d.ts
vendored
|
|
@ -19,6 +19,11 @@ type TeamMemberWithError = {
|
|||
error: ApiError;
|
||||
}
|
||||
|
||||
type TeamInviteWithError = {
|
||||
email: string;
|
||||
error: ApiError;
|
||||
}
|
||||
|
||||
type TeamType = 'O' | 'I';
|
||||
|
||||
type Team = {
|
||||
|
|
|
|||
Loading…
Reference in a new issue