diff --git a/app/components/slide_up_panel_item/index.tsx b/app/components/slide_up_panel_item/index.tsx index 840d8ec5e0..143483c32a 100644 --- a/app/components/slide_up_panel_item/index.tsx +++ b/app/components/slide_up_panel_item/index.tsx @@ -21,6 +21,7 @@ type SlideUpPanelProps = { textStyles?: TextStyle; testID?: string; text: string; + topDivider?: boolean; } export const ITEM_HEIGHT = 48; @@ -61,10 +62,15 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { color: theme.centerChannelColor, ...typography('Body', 200, 'Regular'), }, + divider: { + borderTopWidth: 1, + borderStyle: 'solid', + borderColor: changeOpacity(theme.centerChannelColor, 0.08), + }, }; }); -const SlideUpPanelItem = ({destructive, icon, imageStyles, onPress, testID, text, textStyles, rightIcon = false}: SlideUpPanelProps) => { +const SlideUpPanelItem = ({destructive, icon, imageStyles, onPress, testID, text, textStyles, rightIcon = false, topDivider}: SlideUpPanelProps) => { const theme = useTheme(); const handleOnPress = useCallback(preventDoubleTap(onPress, 500), []); const style = getStyleSheet(theme); @@ -99,10 +105,12 @@ const SlideUpPanelItem = ({destructive, icon, imageStyles, onPress, testID, text } } + const divider = topDivider ? style.divider : {}; + return ( diff --git a/app/database/migration/server/index.ts b/app/database/migration/server/index.ts index 11f715c9d4..51ebc76666 100644 --- a/app/database/migration/server/index.ts +++ b/app/database/migration/server/index.ts @@ -11,10 +11,22 @@ import {MM_TABLES} from '@constants/database'; const {SERVER: { GROUP, MY_CHANNEL, + TEAM, THREAD, }} = MM_TABLES; export default schemaMigrations({migrations: [ + { + toVersion: 4, + steps: [ + addColumns({ + table: TEAM, + columns: [ + {name: 'invite_id', type: 'string'}, + ], + }), + ], + }, { toVersion: 3, steps: [ diff --git a/app/database/models/server/team.ts b/app/database/models/server/team.ts index 9d4dabc72e..11efd6b600 100644 --- a/app/database/models/server/team.ts +++ b/app/database/models/server/team.ts @@ -87,6 +87,9 @@ export default class TeamModel extends Model implements TeamModelInterface { /** allowed_domains : List of domains that can join this team */ @field('allowed_domains') allowedDomains!: string; + /** invite_id : The token id to use in invites to the team */ + @field('invite_id') inviteId!: string; + /** categories : All the categories associated with this team */ @children(CATEGORY) categories!: Query; diff --git a/app/database/operator/server_data_operator/transformers/team.ts b/app/database/operator/server_data_operator/transformers/team.ts index f9945558ab..24a6be3ef6 100644 --- a/app/database/operator/server_data_operator/transformers/team.ts +++ b/app/database/operator/server_data_operator/transformers/team.ts @@ -72,6 +72,7 @@ export const transformTeamRecord = ({action, database, value}: TransformerArgs): team.allowedDomains = raw.allowed_domains; team.isGroupConstrained = Boolean(raw.group_constrained); team.lastTeamIconUpdatedAt = raw.last_team_icon_update; + team.inviteId = raw.invite_id; }; return prepareBaseRecord({ diff --git a/app/database/schema/server/index.ts b/app/database/schema/server/index.ts index 48c9a8d5af..f4466e0f40 100644 --- a/app/database/schema/server/index.ts +++ b/app/database/schema/server/index.ts @@ -37,7 +37,7 @@ import { } from './table_schemas'; export const serverSchema: AppSchema = appSchema({ - version: 3, + version: 4, tables: [ CategorySchema, CategoryChannelSchema, diff --git a/app/database/schema/server/table_schemas/team.ts b/app/database/schema/server/table_schemas/team.ts index 17a127b008..26ad4bf986 100644 --- a/app/database/schema/server/table_schemas/team.ts +++ b/app/database/schema/server/table_schemas/team.ts @@ -19,5 +19,6 @@ export default tableSchema({ {name: 'name', type: 'string'}, {name: 'type', type: 'string'}, {name: 'update_at', type: 'number'}, + {name: 'invite_id', type: 'string'}, ], }); diff --git a/app/database/schema/server/test.ts b/app/database/schema/server/test.ts index 0b6fd6e29b..34ee07d841 100644 --- a/app/database/schema/server/test.ts +++ b/app/database/schema/server/test.ts @@ -43,7 +43,7 @@ const { describe('*** Test schema for SERVER database ***', () => { it('=> The SERVER SCHEMA should strictly match', () => { expect(serverSchema).toEqual({ - version: 3, + version: 4, tables: { [CATEGORY]: { name: CATEGORY, @@ -463,6 +463,7 @@ describe('*** Test schema for SERVER database ***', () => { name: {name: 'name', type: 'string'}, type: {name: 'type', type: 'string'}, update_at: {name: 'update_at', type: 'number'}, + invite_id: {name: 'invite_id', type: 'string'}, }, columnArray: [ {name: 'allowed_domains', type: 'string'}, @@ -474,6 +475,7 @@ describe('*** Test schema for SERVER database ***', () => { {name: 'name', type: 'string'}, {name: 'type', type: 'string'}, {name: 'update_at', type: 'number'}, + {name: 'invite_id', type: 'string'}, ], }, [TEAM_CHANNEL_HISTORY]: { diff --git a/app/screens/home/channel_list/categories_list/header/header.test.tsx b/app/screens/home/channel_list/categories_list/header/header.test.tsx index 2b425144e9..2832e7f3db 100644 --- a/app/screens/home/channel_list/categories_list/header/header.test.tsx +++ b/app/screens/home/channel_list/categories_list/header/header.test.tsx @@ -15,6 +15,7 @@ describe('components/channel_list/header', () => { pushProxyStatus={PUSH_PROXY_STATUS_VERIFIED} canCreateChannels={true} canJoinChannels={true} + canInvitePeople={true} displayName={'Test!'} />, ); diff --git a/app/screens/home/channel_list/categories_list/header/header.tsx b/app/screens/home/channel_list/categories_list/header/header.tsx index befe2c3575..c391deedc0 100644 --- a/app/screens/home/channel_list/categories_list/header/header.tsx +++ b/app/screens/home/channel_list/categories_list/header/header.tsx @@ -28,7 +28,9 @@ import PlusMenu from './plus_menu'; type Props = { canCreateChannels: boolean; canJoinChannels: boolean; - displayName: string; + canInvitePeople: boolean; + displayName?: string; + inviteId?: string; iconPad?: boolean; onHeaderPress?: () => void; pushProxyStatus: string; @@ -92,7 +94,9 @@ const hitSlop: Insets = {top: 10, bottom: 30, left: 20, right: 20}; const ChannelListHeader = ({ canCreateChannels, canJoinChannels, + canInvitePeople, displayName, + inviteId, iconPad, onHeaderPress, pushProxyStatus, @@ -118,6 +122,9 @@ const ChannelListHeader = ({ ); }; @@ -132,6 +139,10 @@ const ChannelListHeader = ({ items += 1; } + if (canInvitePeople) { + items += 1; + } + bottomSheet({ closeButtonId, renderContent, diff --git a/app/screens/home/channel_list/categories_list/header/index.ts b/app/screens/home/channel_list/categories_list/header/index.ts index 82a25f25d2..ef99d7958c 100644 --- a/app/screens/home/channel_list/categories_list/header/index.ts +++ b/app/screens/home/channel_list/categories_list/header/index.ts @@ -8,7 +8,7 @@ import {switchMap} from 'rxjs/operators'; import {Permissions} from '@constants'; import {observePermissionForTeam} from '@queries/servers/role'; -import {observePushVerificationStatus} from '@queries/servers/system'; +import {observeConfig, observePushVerificationStatus} from '@queries/servers/system'; import {observeCurrentTeam} from '@queries/servers/team'; import {observeCurrentUser} from '@queries/servers/user'; @@ -21,6 +21,12 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { const currentUser = observeCurrentUser(database); + const config = observeConfig(database); + + const enableOpenServer = config.pipe( + switchMap((t) => of$(t?.EnableOpenServer === 'true')), + ); + const canJoinChannels = combineLatest([currentUser, team]).pipe( switchMap(([u, t]) => observePermissionForTeam(database, t, u, Permissions.JOIN_PUBLIC_CHANNELS, true)), ); @@ -37,12 +43,22 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { switchMap(([open, priv]) => of$(open || priv)), ); + const canAddUserToTeam = combineLatest([currentUser, team]).pipe( + switchMap(([u, t]) => observePermissionForTeam(database, t, u, Permissions.ADD_USER_TO_TEAM, false)), + ); + return { canCreateChannels, canJoinChannels, + canInvitePeople: combineLatest([enableOpenServer, canAddUserToTeam]).pipe( + switchMap(([openServer, addUser]) => of$(openServer && addUser)), + ), displayName: team.pipe( switchMap((t) => of$(t?.displayName)), ), + inviteId: team.pipe( + switchMap((t) => of$(t?.inviteId)), + ), pushProxyStatus: observePushVerificationStatus(database), }; }); diff --git a/app/screens/home/channel_list/categories_list/header/plus_menu/index.tsx b/app/screens/home/channel_list/categories_list/header/plus_menu/index.tsx index 40b854e747..35bc9b8067 100644 --- a/app/screens/home/channel_list/categories_list/header/plus_menu/index.tsx +++ b/app/screens/home/channel_list/categories_list/header/plus_menu/index.tsx @@ -3,9 +3,13 @@ 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'; @@ -14,11 +18,15 @@ import PlusMenuItem from './item'; type Props = { canCreateChannels: boolean; canJoinChannels: boolean; + canInvitePeople: boolean; + displayName?: string; + inviteId?: string; } -const PlusMenuList = ({canCreateChannels, canJoinChannels}: Props) => { +const PlusMenuList = ({canCreateChannels, canJoinChannels, canInvitePeople, displayName, inviteId}: Props) => { const intl = useIntl(); const theme = useTheme(); + const serverUrl = useServerUrl(); const browseChannels = useCallback(async () => { await dismissBottomSheet(); @@ -48,6 +56,59 @@ const PlusMenuList = ({canCreateChannels, canJoinChannels}: Props) => { }); }, [intl, theme]); + const invitePeopleToTeam = 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:/;base64,'; + + 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 + }); + }; + return ( <> {canJoinChannels && @@ -66,6 +127,12 @@ const PlusMenuList = ({canCreateChannels, canJoinChannels}: Props) => { pickerAction='openDirectMessage' onPress={openDirectMessage} /> + {canInvitePeople && + + } ); }; diff --git a/app/screens/home/channel_list/categories_list/header/plus_menu/item.tsx b/app/screens/home/channel_list/categories_list/header/plus_menu/item.tsx index e46b285ebb..590fa77ae6 100644 --- a/app/screens/home/channel_list/categories_list/header/plus_menu/item.tsx +++ b/app/screens/home/channel_list/categories_list/header/plus_menu/item.tsx @@ -7,7 +7,7 @@ import {useIntl} from 'react-intl'; import SlideUpPanelItem from '@components/slide_up_panel_item'; type PlusMenuItemProps = { - pickerAction: 'browseChannels' | 'createNewChannel' | 'openDirectMessage'; + pickerAction: 'browseChannels' | 'createNewChannel' | 'openDirectMessage' | 'invitePeopleToTeam'; onPress: () => void; }; @@ -32,14 +32,20 @@ const PlusMenuItem = ({pickerAction, onPress}: PlusMenuItemProps) => { text: intl.formatMessage({id: 'plus_menu.open_direct_message.title', defaultMessage: 'Open a Direct Message'}), testID: 'plus_menu_item.open_direct_message', }, + invitePeopleToTeam: { + icon: 'account-plus-outline', + text: intl.formatMessage({id: 'plus_menu.invite_people_to_team.title', defaultMessage: 'Invite people to the team'}), + testID: 'plus_menu_item.invite_people_to_team', + topDivider: true, + }, }; + const itemType = menuItems[pickerAction]; + return ( ); }; diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index de610b01ad..a5b42d8067 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -315,6 +315,8 @@ "intro.welcome": "Welcome to {displayName} channel.", "intro.welcome.private": "Only invited members can see messages posted in this private channel.", "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", "last_users_message.added_to_channel.type": "were **added to the channel** by {actor}.", "last_users_message.added_to_team.type": "were **added to the team** by {actor}.", "last_users_message.first": "{firstUser} and ", @@ -684,6 +686,7 @@ "pinned_messages.empty.title": "No pinned messages yet", "plus_menu.browse_channels.title": "Browse Channels", "plus_menu.create_new_channel.title": "Create New Channel", + "plus_menu.invite_people_to_team.title": "Invite people to the team", "plus_menu.open_direct_message.title": "Open a Direct Message", "post_body.check_for_out_of_channel_groups_mentions.message": "did not get notified by this mention because they are not in the channel. They are also not a member of the groups linked to this channel.", "post_body.check_for_out_of_channel_mentions.link.and": " and ", diff --git a/detox/e2e/support/ui/component/plus_menu.ts b/detox/e2e/support/ui/component/plus_menu.ts index 713e2d39c0..ef23c34fa9 100644 --- a/detox/e2e/support/ui/component/plus_menu.ts +++ b/detox/e2e/support/ui/component/plus_menu.ts @@ -6,11 +6,13 @@ class PlusMenu { browseChannelsItem: 'plus_menu_item.browse_channels', createNewChannelItem: 'plus_menu_item.create_new_channel', openDirectMessageItem: 'plus_menu_item.open_direct_message', + invitePeopleToTeamItem: 'plus_menu_item.invite_people_to_team', }; browseChannelsItem = element(by.id(this.testID.browseChannelsItem)); createNewChannelItem = element(by.id(this.testID.createNewChannelItem)); openDirectMessageItem = element(by.id(this.testID.openDirectMessageItem)); + invitePeopleToTeamItem = element(by.id(this.testID.invitePeopleToTeamItem)); } const plusMenu = new PlusMenu(); diff --git a/detox/e2e/support/ui/screen/channel_list.ts b/detox/e2e/support/ui/screen/channel_list.ts index 413555ae61..869950e250 100644 --- a/detox/e2e/support/ui/screen/channel_list.ts +++ b/detox/e2e/support/ui/screen/channel_list.ts @@ -39,6 +39,7 @@ class ChannelListScreen { browseChannelsItem = PlusMenu.browseChannelsItem; createNewChannelItem = PlusMenu.createNewChannelItem; openDirectMessageItem = PlusMenu.openDirectMessageItem; + invitePeopleToTeamItem = PlusMenu.invitePeopleToTeamItem; getCategoryCollapsed = (categoryKey: string) => { return element(by.id(`${this.testID.categoryHeaderPrefix}${categoryKey}.collapsed.true`)); diff --git a/detox/e2e/test/teams/invite_people.e2e.ts b/detox/e2e/test/teams/invite_people.e2e.ts new file mode 100644 index 0000000000..6975899cc6 --- /dev/null +++ b/detox/e2e/test/teams/invite_people.e2e.ts @@ -0,0 +1,76 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// ******************************************************************* +// - [#] indicates a test step (e.g. # Go to a screen) +// - [*] indicates an assertion (e.g. * Check the title) +// - Use element testID when selecting an element. Create one if none. +// ******************************************************************* + +import {Setup} from '@support/server_api'; +import { + serverOneUrl, + siteOneUrl, +} from '@support/test_config'; +import { + ChannelListScreen, + HomeScreen, + LoginScreen, + ServerScreen, +} from '@support/ui/screen'; +import {isIos} from '@support/utils'; +import {expect} from 'detox'; + +function systemDialog(label: string) { + if (device.getPlatform() === 'ios') { + return element(by.label(label)).atIndex(0); + } + return element(by.text(label)); +} + +describe('Teams - Invite people', () => { + const serverOneDisplayName = 'Server 1'; + + let testTeam: any; + let testUser: any; + + beforeAll(async () => { + const {team, user} = await Setup.apiInit(siteOneUrl); + + testTeam = team; + testUser = user; + + // # Log in to server + await ServerScreen.connectToServer(serverOneUrl, serverOneDisplayName); + await LoginScreen.login(testUser); + }); + + beforeEach(async () => { + // * Verify on channel list screen + await ChannelListScreen.toBeVisible(); + }); + + afterAll(async () => { + // # Close share dialog + await ChannelListScreen.headerTeamDisplayName.tap(); + + // # Log out + await HomeScreen.logout(); + }); + + it('MM-T4729_2 - 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 expect(ChannelListScreen.invitePeopleToTeamItem).toExist(); + + // # Tap on invite people to team item + await ChannelListScreen.invitePeopleToTeamItem.tap(); + + if (isIos()) { + // * Verify share dialog is open + await expect(systemDialog(`Join the ${testTeam.display_name} team`)).toExist(); + } + }); +}); diff --git a/types/database/models/servers/team.ts b/types/database/models/servers/team.ts index 4e8cce42ae..7599f0eb4d 100644 --- a/types/database/models/servers/team.ts +++ b/types/database/models/servers/team.ts @@ -47,6 +47,9 @@ declare class TeamModel extends Model { /** allowed_domains : List of domains that can join this team */ allowedDomains: string; + /** invite_id : The token id to use in invites to the team */ + inviteId: string; + /** categories : All the categories associated with this team */ categories: Query;