diff --git a/app/definition/ITeam.js b/app/definition/ITeam.js new file mode 100644 index 00000000..10919715 --- /dev/null +++ b/app/definition/ITeam.js @@ -0,0 +1,5 @@ +// https://github.com/RocketChat/Rocket.Chat/blob/develop/definition/ITeam.ts +export const TEAM_TYPE = { + PUBLIC: 0, + PRIVATE: 1 +}; diff --git a/app/i18n/locales/en.json b/app/i18n/locales/en.json index ec289d27..58438f31 100644 --- a/app/i18n/locales/en.json +++ b/app/i18n/locales/en.json @@ -709,5 +709,12 @@ "This_room_encryption_has_been_disabled_by__username_": "This room's encryption has been disabled by {{username}}", "Teams": "Teams", "No_team_channels_found": "No channels found", - "Team_not_found": "Team not found" -} \ No newline at end of file + "Team_not_found": "Team not found", + "Create_Team": "Create Team", + "Team_Name": "Team Name", + "Private_Team": "Private Team", + "Read_Only_Team": "Read Only Team", + "Broadcast_Team": "Broadcast Team", + "creating_team" : "creating team", + "team-name-already-exists": "A team with that name already exists" +} diff --git a/app/lib/rocketchat.js b/app/lib/rocketchat.js index 84f85a6f..adc7f80f 100644 --- a/app/lib/rocketchat.js +++ b/app/lib/rocketchat.js @@ -60,6 +60,7 @@ import UserPreferences from './userPreferences'; import { Encryption } from './encryption'; import EventEmitter from '../utils/events'; import { sanitizeLikeString } from './database/utils'; +import { TEAM_TYPE } from '../definition/ITeam'; const TOKEN_KEY = 'reactnativemeteor_usertoken'; const CURRENT_SERVER = 'currentServer'; @@ -732,7 +733,24 @@ const RocketChat = { prid, pmid, t_name, reply, users, encrypted }); }, - + createTeam({ + name, users, type, readOnly, broadcast, encrypted + }) { + const params = { + name, + users, + type: type ? TEAM_TYPE.PRIVATE : TEAM_TYPE.PUBLIC, + room: { + readOnly, + extraData: { + broadcast, + encrypted + } + } + }; + // RC 3.13.0 + return this.post('teams.create', params); + }, joinRoom(roomId, joinCode, type) { // TODO: join code // RC 0.48.0 diff --git a/app/sagas/createChannel.js b/app/sagas/createChannel.js index 613aedec..f2ecfe76 100644 --- a/app/sagas/createChannel.js +++ b/app/sagas/createChannel.js @@ -21,6 +21,10 @@ const createGroupChat = function createGroupChat() { return RocketChat.createGroupChat(); }; +const createTeam = function createTeam(data) { + return RocketChat.createTeam(data); +}; + const handleRequest = function* handleRequest({ data }) { try { const auth = yield select(state => state.login.isAuthenticated); @@ -29,7 +33,21 @@ const handleRequest = function* handleRequest({ data }) { } let sub; - if (data.group) { + if (data.isTeam) { + const { + type, + readOnly, + broadcast, + encrypted + } = data; + logEvent(events.CR_CREATE, { + type, + readOnly, + broadcast, + encrypted + }); + sub = yield call(createTeam, data); + } else if (data.group) { logEvent(events.SELECTED_USERS_CREATE_GROUP); const result = yield call(createGroupChat); if (result.success) { @@ -56,7 +74,7 @@ const handleRequest = function* handleRequest({ data }) { const subCollection = db.get('subscriptions'); yield db.action(async() => { await subCollection.create((s) => { - s._raw = sanitizedRaw({ id: sub.rid }, subCollection.schema); + s._raw = sanitizedRaw({ id: sub.team ? sub.team.roomId : sub.rid }, subCollection.schema); Object.assign(s, sub); }); }); @@ -64,7 +82,17 @@ const handleRequest = function* handleRequest({ data }) { // do nothing } - yield put(createChannelSuccess(sub)); + let successParams = {}; + if (data.isTeam) { + successParams = { + ...sub.team, + rid: sub.team.roomId, + t: sub.team.type ? 'p' : 'c' + }; + } else { + successParams = data; + } + yield put(createChannelSuccess(successParams)); } catch (err) { logEvent(events[data.group ? 'SELECTED_USERS_CREATE_GROUP_F' : 'CR_CREATE_F']); yield put(createChannelFailure(err)); @@ -81,7 +109,7 @@ const handleSuccess = function* handleSuccess({ data }) { const handleFailure = function handleFailure({ err }) { setTimeout(() => { - const msg = err.reason || I18n.t('There_was_an_error_while_action', { action: I18n.t('creating_channel') }); + const msg = err.data ? I18n.t(err.data.error) : err.reason || I18n.t('There_was_an_error_while_action', { action: I18n.t('creating_channel') }); showErrorAlert(msg); }, 300); }; diff --git a/app/utils/log/events.js b/app/utils/log/events.js index c9c9579f..490f0dfd 100644 --- a/app/utils/log/events.js +++ b/app/utils/log/events.js @@ -88,6 +88,7 @@ export default { // NEW MESSAGE VIEW NEW_MSG_CREATE_CHANNEL: 'new_msg_create_channel', + NEW_MSG_CREATE_TEAM: 'new_msg_create_team', NEW_MSG_CREATE_GROUP_CHAT: 'new_msg_create_group_chat', NEW_MSG_CREATE_DISCUSSION: 'new_msg_create_discussion', NEW_MSG_CHAT_WITH_USER: 'new_msg_chat_with_user', diff --git a/app/utils/room.js b/app/utils/room.js index 7077c73d..fef926d5 100644 --- a/app/utils/room.js +++ b/app/utils/room.js @@ -45,3 +45,5 @@ export const getBadgeColor = ({ subscription, messageId, theme }) => { }; export const makeThreadName = messageRecord => messageRecord.msg || messageRecord?.attachments[0]?.title; + +export const isTeamRoom = ({ teamId, joined }) => teamId && joined; diff --git a/app/views/CreateChannelView.js b/app/views/CreateChannelView.js index 9d1e450f..8090ef4f 100644 --- a/app/views/CreateChannelView.js +++ b/app/views/CreateChannelView.js @@ -68,12 +68,9 @@ const styles = StyleSheet.create({ }); class CreateChannelView extends React.Component { - static navigationOptions = () => ({ - title: I18n.t('Create_Channel') - }); - static propTypes = { navigation: PropTypes.object, + route: PropTypes.object, baseUrl: PropTypes.string, create: PropTypes.func.isRequired, removeUser: PropTypes.func.isRequired, @@ -89,12 +86,19 @@ class CreateChannelView extends React.Component { theme: PropTypes.string }; - state = { - channelName: '', - type: true, - readOnly: false, - encrypted: false, - broadcast: false + constructor(props) { + super(props); + const { route } = this.props; + const isTeam = route?.params?.isTeam || false; + this.state = { + channelName: '', + type: true, + readOnly: false, + encrypted: false, + broadcast: false, + isTeam + }; + this.setHeader(); } shouldComponentUpdate(nextProps, nextState) { @@ -134,6 +138,15 @@ class CreateChannelView extends React.Component { return false; } + setHeader = () => { + const { navigation } = this.props; + const { isTeam } = this.state; + + navigation.setOptions({ + title: isTeam ? I18n.t('Create_Team') : I18n.t('Create_Channel') + }); + } + toggleRightButton = (channelName) => { const { navigation } = this.props; navigation.setOptions({ @@ -152,9 +165,11 @@ class CreateChannelView extends React.Component { submit = () => { const { - channelName, type, readOnly, broadcast, encrypted + channelName, type, readOnly, broadcast, encrypted, isTeam } = this.state; - const { users: usersProps, isFetching, create } = this.props; + const { + users: usersProps, isFetching, create + } = this.props; if (!channelName.trim() || isFetching) { return; @@ -163,9 +178,9 @@ class CreateChannelView extends React.Component { // transform users object into array of usernames const users = usersProps.map(user => user.name); - // create channel + // create channel or team create({ - name: channelName, users, type, readOnly, broadcast, encrypted + name: channelName, users, type, readOnly, broadcast, encrypted, isTeam }); Review.pushPositiveEvent(); @@ -196,11 +211,12 @@ class CreateChannelView extends React.Component { } renderType() { - const { type } = this.state; + const { type, isTeam } = this.state; + return this.renderSwitch({ id: 'type', value: type, - label: 'Private_Channel', + label: isTeam ? 'Private_Team' : 'Private_Channel', onValueChange: (value) => { logEvent(events.CR_TOGGLE_TYPE); // If we set the channel as public, encrypted status should be false @@ -210,11 +226,12 @@ class CreateChannelView extends React.Component { } renderReadOnly() { - const { readOnly, broadcast } = this.state; + const { readOnly, broadcast, isTeam } = this.state; + return this.renderSwitch({ id: 'readonly', value: readOnly, - label: 'Read_Only_Channel', + label: isTeam ? 'Read_Only_Team' : 'Read_Only_Channel', onValueChange: (value) => { logEvent(events.CR_TOGGLE_READ_ONLY); this.setState({ readOnly: value }); @@ -244,11 +261,12 @@ class CreateChannelView extends React.Component { } renderBroadcast() { - const { broadcast, readOnly } = this.state; + const { broadcast, readOnly, isTeam } = this.state; + return this.renderSwitch({ id: 'broadcast', value: broadcast, - label: 'Broadcast_Channel', + label: isTeam ? 'Broadcast_Team' : 'Broadcast_Channel', onValueChange: (value) => { logEvent(events.CR_TOGGLE_BROADCAST); this.setState({ @@ -301,8 +319,10 @@ class CreateChannelView extends React.Component { } render() { - const { channelName } = this.state; - const { users, isFetching, theme } = this.props; + const { channelName, isTeam } = this.state; + const { + users, isFetching, theme + } = this.props; const userCount = users.length; return ( @@ -312,18 +332,18 @@ class CreateChannelView extends React.Component { keyboardVerticalOffset={128} > - + navigation.navigate('CreateChannelView') }); } + createTeam = () => { + logEvent(events.NEW_MSG_CREATE_TEAM); + const { navigation } = this.props; + navigation.navigate('SelectedUsersViewCreateChannel', { nextAction: () => navigation.navigate('CreateChannelView', { isTeam: true }) }); + } + createGroupChat = () => { logEvent(events.NEW_MSG_CREATE_GROUP_CHAT); const { createChannel, maxUsers, navigation } = this.props; @@ -172,6 +178,12 @@ class NewMessageView extends React.Component { testID: 'new-message-view-create-channel', first: true })} + {this.renderButton({ + onPress: this.createTeam, + title: I18n.t('Create_Team'), + icon: 'teams', + testID: 'new-message-view-create-team' + })} {maxUsers > 2 ? this.renderButton({ onPress: this.createGroupChat, title: I18n.t('Create_Direct_Messages'), @@ -253,7 +265,7 @@ const mapStateToProps = state => ({ }); const mapDispatchToProps = dispatch => ({ - createChannel: params => dispatch(createChannelRequest(params)) + create: params => dispatch(createChannelRequest(params)) }); export default connect(mapStateToProps, mapDispatchToProps)(withTheme(NewMessageView)); diff --git a/app/views/RoomView/RightButtons.js b/app/views/RoomView/RightButtons.js index debc3edb..81b8f153 100644 --- a/app/views/RoomView/RightButtons.js +++ b/app/views/RoomView/RightButtons.js @@ -7,6 +7,7 @@ import * as HeaderButton from '../../containers/HeaderButton'; import database from '../../lib/database'; import { getUserSelector } from '../../selectors/login'; import { logEvent, events } from '../../utils/log'; +import { isTeamRoom } from '../../utils/room'; class RightButtonsContainer extends Component { static propTypes = { @@ -15,10 +16,11 @@ class RightButtonsContainer extends Component { rid: PropTypes.string, t: PropTypes.string, tmid: PropTypes.string, - teamId: PropTypes.bool, + teamId: PropTypes.string, navigation: PropTypes.object, isMasterDetail: PropTypes.bool, - toggleFollowThread: PropTypes.func + toggleFollowThread: PropTypes.func, + joined: PropTypes.bool }; constructor(props) { @@ -163,7 +165,7 @@ class RightButtonsContainer extends Component { isFollowingThread, tunread, tunreadUser, tunreadGroup } = this.state; const { - t, tmid, threadsEnabled, teamId + t, tmid, threadsEnabled, teamId, joined } = this.props; if (t === 'l') { return null; @@ -181,7 +183,7 @@ class RightButtonsContainer extends Component { } return ( - {teamId ? ( + {isTeamRoom({ teamId, joined }) ? ( { const { - room, unreadsCount, roomUserId + room, unreadsCount, roomUserId, joined } = this.state; const { navigation, isMasterDetail, theme, baseUrl, user, insets, route @@ -331,7 +333,7 @@ class RoomView extends React.Component { let numIconsRight = 2; if (tmid) { numIconsRight = 1; - } else if (teamId) { + } else if (isTeamRoom({ teamId, joined })) { numIconsRight = 3; } const headerTitlePosition = getHeaderTitlePosition({ insets, numIconsRight }); @@ -380,6 +382,8 @@ class RoomView extends React.Component { rid={rid} tmid={tmid} teamId={teamId} + teamMain={teamMain} + joined={joined} t={t} navigation={navigation} toggleFollowThread={this.toggleFollowThread} diff --git a/app/views/SelectedUsersView.js b/app/views/SelectedUsersView.js index f9d14e16..bd6740e1 100644 --- a/app/views/SelectedUsersView.js +++ b/app/views/SelectedUsersView.js @@ -17,7 +17,6 @@ import sharedStyles from './Styles'; import * as HeaderButton from '../containers/HeaderButton'; import StatusBar from '../containers/StatusBar'; import { themes } from '../constants/colors'; -import { animateNextTransition } from '../utils/layoutAnimation'; import { withTheme } from '../theme'; import { getUserSelector } from '../selectors/login'; import { @@ -28,6 +27,9 @@ import { import { showErrorAlert } from '../utils/info'; import SafeAreaView from '../containers/SafeAreaView'; +const ITEM_WIDTH = 250; +const getItemLayout = (_, index) => ({ length: ITEM_WIDTH, offset: ITEM_WIDTH * index, index }); + class SelectedUsersView extends React.Component { static propTypes = { baseUrl: PropTypes.string, @@ -50,7 +52,7 @@ class SelectedUsersView extends React.Component { constructor(props) { super(props); this.init(); - + this.flatlist = React.createRef(); const maxUsers = props.route.params?.maxUsers; this.state = { maxUsers, @@ -151,7 +153,6 @@ class SelectedUsersView extends React.Component { return; } - animateNextTransition(); if (!this.isChecked(user.name)) { if (this.isGroupChat() && users.length === maxUsers) { return showErrorAlert(I18n.t('Max_number_of_users_allowed_is_number', { maxUsers }), I18n.t('Oops')); @@ -184,15 +185,23 @@ class SelectedUsersView extends React.Component { ); } + setFlatListRef = ref => this.flatlist = ref; + + onContentSizeChange = () => this.flatlist.scrollToEnd({ animated: true }); + renderSelected = () => { const { users, theme } = this.props; if (users.length === 0) { return null; } + return ( item._id} style={[sharedStyles.separatorTop, { borderColor: themes[theme].separatorColor }]} contentContainerStyle={{ marginVertical: 5 }} diff --git a/e2e/data.js b/e2e/data.js index 79e7842e..c69b7251 100644 --- a/e2e/data.js +++ b/e2e/data.js @@ -42,6 +42,11 @@ const data = { name: `detox-private-${ value }` } }, + teams: { + private: { + name: `detox-team-${ value }` + } + }, registeringUser: { username: `newuser${ value }`, password: `password${ value }`, diff --git a/e2e/helpers/data_setup.js b/e2e/helpers/data_setup.js index 1f8f8fb6..ce1d5083 100644 --- a/e2e/helpers/data_setup.js +++ b/e2e/helpers/data_setup.js @@ -1,5 +1,6 @@ const axios = require('axios').default; const data = require('../data'); +const { TEAM_TYPE } = require('../../app/definition/ITeam'); let server = data.server @@ -57,6 +58,24 @@ const createChannelIfNotExists = async (channelname) => { } } +const createTeamIfNotExists = async (teamname) => { + console.log(`Creating private team ${teamname}`) + try { + await rocketchat.post('teams.create', { + "name": teamname, + "type": TEAM_TYPE.PRIVATE + }) + } catch (createError) { + try { //Maybe it exists already? + await rocketchat.get(`teams.info?teamName=${teamname}`) + } catch (infoError) { + console.log(JSON.stringify(createError)) + console.log(JSON.stringify(infoError)) + throw "Failed to find or create private team" + } + } +} + const createGroupIfNotExists = async (groupname) => { console.log(`Creating private group ${groupname}`) try { @@ -133,6 +152,13 @@ const setup = async () => { } } + for (var teamKey in data.teams) { + if (data.teams.hasOwnProperty(teamKey)) { + const team = data.teams[teamKey] + await createTeamIfNotExists(team.name) + } + } + return } diff --git a/e2e/tests/team/01-createteam.spec.js b/e2e/tests/team/01-createteam.spec.js new file mode 100644 index 00000000..4dfe17ca --- /dev/null +++ b/e2e/tests/team/01-createteam.spec.js @@ -0,0 +1,82 @@ +const { + device, expect, element, by, waitFor +} = require('detox'); +const data = require('../../data'); +const { tapBack, sleep, navigateToLogin, login, tryTapping } = require('../../helpers/app'); + + + +describe('Create team screen', () => { + before(async() => { + await device.launchApp({ permissions: { notifications: 'YES' }, delete: true }); + await navigateToLogin(); + await login(data.users.regular.username, data.users.regular.password); + }); + + describe('New Message', async() => { + before(async() => { + await element(by.id('rooms-list-view-create-channel')).tap(); + }); + + describe('Render', async() => { + it('should have team button', async() => { + await waitFor(element(by.id('new-message-view-create-channel'))).toBeVisible().withTimeout(2000); + }); + }) + + describe('Usage', async() => { + it('should navigate to select users', async() => { + await element(by.id('new-message-view-create-channel')).tap(); + await waitFor(element(by.id('select-users-view'))).toExist().withTimeout(5000); + }); + }) + }); + + describe('Select Users', async() => { + it('should search users', async() => { + await element(by.id('select-users-view-search')).replaceText('rocket.cat'); + await waitFor(element(by.id(`select-users-view-item-rocket.cat`))).toBeVisible().withTimeout(10000); + }); + + it('should select/unselect user', async() => { + // Spotlight issues + await element(by.id('select-users-view-item-rocket.cat')).tap(); + await waitFor(element(by.id('selected-user-rocket.cat'))).toBeVisible().withTimeout(10000); + await element(by.id('selected-user-rocket.cat')).tap(); + await waitFor(element(by.id('selected-user-rocket.cat'))).toBeNotVisible().withTimeout(10000); + // Spotlight issues + await element(by.id('select-users-view-item-rocket.cat')).tap(); + await waitFor(element(by.id('selected-user-rocket.cat'))).toBeVisible().withTimeout(10000); + }); + + it('should create team', async() => { + await element(by.id('selected-users-view-submit')).tap(); + await waitFor(element(by.id('create-channel-view'))).toExist().withTimeout(10000); + }); + }) + + describe('Create Team', async() => { + describe('Usage', async() => { + it('should get invalid team name', async() => { + await element(by.id('create-channel-name')).typeText(`${data.teams.private.name}`); + await element(by.id('create-channel-submit')).tap(); + await element(by.text('OK')).tap(); + }); + + it('should create private team', async() => { + const room = `private${ data.random }`; + await element(by.id('create-channel-name')).replaceText(''); + await element(by.id('create-channel-name')).typeText(room); + await element(by.id('create-channel-submit')).tap(); + await waitFor(element(by.id('room-view'))).toExist().withTimeout(20000); + await expect(element(by.id('room-view'))).toExist(); + await waitFor(element(by.id(`room-view-title-${ room }`))).toExist().withTimeout(6000); + await expect(element(by.id(`room-view-title-${ room }`))).toExist(); + await tapBack(); + await waitFor(element(by.id('rooms-list-view'))).toExist().withTimeout(10000); + await waitFor(element(by.id(`rooms-list-view-item-${ room }`))).toExist().withTimeout(6000); + await expect(element(by.id(`rooms-list-view-item-${ room }`))).toExist(); + }); + }) + }); +});