diff --git a/app/actions/actionsTypes.js b/app/actions/actionsTypes.js index 3fec00201..1deeb4376 100644 --- a/app/actions/actionsTypes.js +++ b/app/actions/actionsTypes.js @@ -66,3 +66,4 @@ export const INVITE_LINKS = createRequestTypes('INVITE_LINKS', [ ]); export const SETTINGS = createRequestTypes('SETTINGS', ['CLEAR', 'ADD']); export const APP_STATE = createRequestTypes('APP_STATE', ['FOREGROUND', 'BACKGROUND']); +export const ENTERPRISE_MODULES = createRequestTypes('ENTERPRISE_MODULES', ['CLEAR', 'SET']); diff --git a/app/actions/enterpriseModules.js b/app/actions/enterpriseModules.js new file mode 100644 index 000000000..74b097873 --- /dev/null +++ b/app/actions/enterpriseModules.js @@ -0,0 +1,14 @@ +import { ENTERPRISE_MODULES } from './actionsTypes'; + +export function setEnterpriseModules(modules) { + return { + type: ENTERPRISE_MODULES.SET, + payload: modules + }; +} + +export function clearEnterpriseModules() { + return { + type: ENTERPRISE_MODULES.CLEAR + }; +} diff --git a/app/i18n/locales/en.js b/app/i18n/locales/en.js index 061a6228d..ef35a18a7 100644 --- a/app/i18n/locales/en.js +++ b/app/i18n/locales/en.js @@ -339,6 +339,7 @@ export default { Offline: 'Offline', Oops: 'Oops!', Omnichannel: 'Omnichannel', + Omnichannel_enable_alert: 'You\'re not available on Omnichannel. Would you like to be available?', Onboarding_description: 'A workspace is your team or organization’s space to collaborate. Ask the workspace admin for address to join or create one for your team.', Onboarding_join_workspace: 'Join a workspace', Onboarding_subtitle: 'Beyond Team Collaboration', diff --git a/app/i18n/locales/pt-BR.js b/app/i18n/locales/pt-BR.js index 84941b09d..792498fc0 100644 --- a/app/i18n/locales/pt-BR.js +++ b/app/i18n/locales/pt-BR.js @@ -314,6 +314,8 @@ export default { Not_RC_Server: 'Este não é um servidor Rocket.Chat.\n{{contact}}', No_available_agents_to_transfer: 'Nenhum agente disponível para transferência', Offline: 'Offline', + Omnichannel: 'Omnichannel', + Omnichannel_enable_alert: 'Você não está disponível no Omnichannel. Você quer ficar disponível?', Oops: 'Ops!', Onboarding_description: 'Workspace é o espaço de colaboração do seu time ou organização. Peça um convite ou o endereço ao seu administrador ou crie uma workspace para o seu time.', Onboarding_join_workspace: 'Entre numa workspace', diff --git a/app/lib/database/model/Server.js b/app/lib/database/model/Server.js index d30b3a3f4..5e98ea954 100644 --- a/app/lib/database/model/Server.js +++ b/app/lib/database/model/Server.js @@ -27,4 +27,6 @@ export default class Server extends Model { @field('biometry') biometry; @field('unique_id') uniqueID; + + @field('enterprise_modules') enterpriseModules; } diff --git a/app/lib/database/model/serversMigrations.js b/app/lib/database/model/serversMigrations.js index 8d74b0434..86995e5c4 100644 --- a/app/lib/database/model/serversMigrations.js +++ b/app/lib/database/model/serversMigrations.js @@ -37,6 +37,17 @@ export default schemaMigrations({ ] }) ] + }, + { + toVersion: 6, + steps: [ + addColumns({ + table: 'servers', + columns: [ + { name: 'enterprise_modules', type: 'string', isOptional: true } + ] + }) + ] } ] }); diff --git a/app/lib/database/schema/servers.js b/app/lib/database/schema/servers.js index b02859e10..11c115ade 100644 --- a/app/lib/database/schema/servers.js +++ b/app/lib/database/schema/servers.js @@ -1,7 +1,7 @@ import { appSchema, tableSchema } from '@nozbe/watermelondb'; export default appSchema({ - version: 5, + version: 6, tables: [ tableSchema({ name: 'users', @@ -29,7 +29,8 @@ export default appSchema({ { name: 'auto_lock', type: 'boolean', isOptional: true }, { name: 'auto_lock_time', type: 'number', isOptional: true }, { name: 'biometry', type: 'boolean', isOptional: true }, - { name: 'unique_id', type: 'string', isOptional: true } + { name: 'unique_id', type: 'string', isOptional: true }, + { name: 'enterprise_modules', type: 'string', isOptional: true } ] }) ] diff --git a/app/lib/methods/enterpriseModules.js b/app/lib/methods/enterpriseModules.js new file mode 100644 index 000000000..60600afae --- /dev/null +++ b/app/lib/methods/enterpriseModules.js @@ -0,0 +1,63 @@ +import semver from 'semver'; + +import reduxStore from '../createStore'; +import database from '../database'; +import log from '../../utils/log'; +import { setEnterpriseModules as setEnterpriseModulesAction, clearEnterpriseModules } from '../../actions/enterpriseModules'; + +export const LICENSE_OMNICHANNEL_MOBILE_ENTERPRISE = 'omnichannel-mobile-enterprise'; +export const LICENSE_LIVECHAT_ENTERPRISE = 'livechat-enterprise'; + +export async function setEnterpriseModules() { + try { + const { server: serverId } = reduxStore.getState().server; + const serversDB = database.servers; + const serversCollection = serversDB.collections.get('servers'); + const server = await serversCollection.find(serverId); + if (server.enterpriseModules) { + reduxStore.dispatch(setEnterpriseModulesAction(server.enterpriseModules.split(','))); + return; + } + reduxStore.dispatch(clearEnterpriseModules()); + } catch (e) { + log(e); + } +} + +export function getEnterpriseModules() { + return new Promise(async(resolve) => { + try { + const { version: serverVersion, server: serverId } = reduxStore.getState().server; + if (serverVersion && semver.gte(semver.coerce(serverVersion), '3.1.0')) { + // RC 3.1.0 + const enterpriseModules = await this.methodCallWrapper('license:getModules'); + if (enterpriseModules) { + const serversDB = database.servers; + const serversCollection = serversDB.collections.get('servers'); + const server = await serversCollection.find(serverId); + await serversDB.action(async() => { + await server.update((s) => { + s.enterpriseModules = enterpriseModules.join(','); + }); + }); + reduxStore.dispatch(setEnterpriseModulesAction(enterpriseModules)); + return resolve(); + } + } + reduxStore.dispatch(clearEnterpriseModules()); + } catch (e) { + log(e); + } + return resolve(); + }); +} + +export function hasLicense(module) { + const { enterpriseModules } = reduxStore.getState(); + return enterpriseModules.includes(module); +} + +export function isOmnichannelModuleAvailable() { + const { enterpriseModules } = reduxStore.getState(); + return [LICENSE_OMNICHANNEL_MOBILE_ENTERPRISE, LICENSE_LIVECHAT_ENTERPRISE].some(module => enterpriseModules.includes(module)); +} diff --git a/app/lib/methods/subscriptions/rooms.js b/app/lib/methods/subscriptions/rooms.js index 52e474f14..fc76c11ae 100644 --- a/app/lib/methods/subscriptions/rooms.js +++ b/app/lib/methods/subscriptions/rooms.js @@ -244,7 +244,9 @@ export default function subscribeRooms() { const [, ev] = ddpMessage.fields.eventName.split('/'); if (/userData/.test(ev)) { const [{ diff }] = ddpMessage.fields.args; - store.dispatch(setUser({ statusLivechat: diff?.statusLivechat })); + if (diff?.statusLivechat) { + store.dispatch(setUser({ statusLivechat: diff.statusLivechat })); + } } if (/subscriptions/.test(ev)) { if (type === 'removed') { diff --git a/app/lib/rocketchat.js b/app/lib/rocketchat.js index 3ac30ebf6..33fb6990a 100644 --- a/app/lib/rocketchat.js +++ b/app/lib/rocketchat.js @@ -29,6 +29,9 @@ import getSettings, { getLoginSettings, setSettings } from './methods/getSetting import getRooms from './methods/getRooms'; import getPermissions from './methods/getPermissions'; import { getCustomEmojis, setCustomEmojis } from './methods/getCustomEmojis'; +import { + getEnterpriseModules, setEnterpriseModules, hasLicense, isOmnichannelModuleAvailable +} from './methods/enterpriseModules'; import getSlashCommands from './methods/getSlashCommands'; import getRoles from './methods/getRoles'; import canOpenRoom from './methods/canOpenRoom'; @@ -519,6 +522,7 @@ const RocketChat = { } else if (!filterUsers && filterRooms) { data = data.filter(item => item.t !== 'd' || RocketChat.isGroupChat(item)); } + data = data.slice(0, 7); data = data.map((sub) => { @@ -620,6 +624,10 @@ const RocketChat = { getPermissions, getCustomEmojis, setCustomEmojis, + getEnterpriseModules, + setEnterpriseModules, + hasLicense, + isOmnichannelModuleAvailable, getSlashCommands, getRoles, parseSettings: settings => settings.reduce((ret, item) => { diff --git a/app/reducers/enterpriseModules.js b/app/reducers/enterpriseModules.js new file mode 100644 index 000000000..2f1a7ac9a --- /dev/null +++ b/app/reducers/enterpriseModules.js @@ -0,0 +1,14 @@ +import { ENTERPRISE_MODULES } from '../actions/actionsTypes'; + +const initialState = []; + +export default (state = initialState, action) => { + switch (action.type) { + case ENTERPRISE_MODULES.SET: + return action.payload; + case ENTERPRISE_MODULES.CLEAR: + return initialState; + default: + return state; + } +}; diff --git a/app/reducers/index.js b/app/reducers/index.js index 968254ffd..05bcb534f 100644 --- a/app/reducers/index.js +++ b/app/reducers/index.js @@ -17,6 +17,7 @@ import usersTyping from './usersTyping'; import inviteLinks from './inviteLinks'; import createDiscussion from './createDiscussion'; import inquiry from './inquiry'; +import enterpriseModules from './enterpriseModules'; export default combineReducers({ settings, @@ -36,5 +37,6 @@ export default combineReducers({ usersTyping, inviteLinks, createDiscussion, - inquiry + inquiry, + enterpriseModules }); diff --git a/app/sagas/login.js b/app/sagas/login.js index 4bc9aeae6..1b40c84b1 100644 --- a/app/sagas/login.js +++ b/app/sagas/login.js @@ -14,7 +14,7 @@ import { loginFailure, loginSuccess, setUser, logout } from '../actions/login'; import { roomsRequest } from '../actions/rooms'; -import { inquiryRequest } from '../actions/inquiry'; +import { inquiryRequest, inquiryReset } from '../actions/inquiry'; import { toMomentLocale } from '../utils/moment'; import RocketChat from '../lib/rocketchat'; import log, { logEvent, events } from '../utils/log'; @@ -85,6 +85,14 @@ const fetchUsersPresence = function* fetchUserPresence() { RocketChat.subscribeUsersPresence(); }; +const fetchEnterpriseModules = function* fetchEnterpriseModules({ user }) { + yield RocketChat.getEnterpriseModules(); + + if (user && user.statusLivechat === 'available' && RocketChat.isOmnichannelModuleAvailable()) { + yield put(inquiryRequest()); + } +}; + const handleLoginSuccess = function* handleLoginSuccess({ user }) { try { const adding = yield select(state => state.server.adding); @@ -94,13 +102,13 @@ const handleLoginSuccess = function* handleLoginSuccess({ user }) { const server = yield select(getServer); yield put(roomsRequest()); - yield put(inquiryRequest()); yield fork(fetchPermissions); yield fork(fetchCustomEmojis); yield fork(fetchRoles); yield fork(fetchSlashCommands); yield fork(registerPushToken); yield fork(fetchUsersPresence); + yield fork(fetchEnterpriseModules, { user }); I18n.locale = user.language; moment.locale(toMomentLocale(user.language)); @@ -210,8 +218,12 @@ const handleSetUser = function* handleSetUser({ user }) { yield put(setActiveUsers({ [userId]: user })); } - if (user && user.statusLivechat) { - yield put(inquiryRequest()); + if (user?.statusLivechat && RocketChat.isOmnichannelModuleAvailable()) { + if (user.statusLivechat === 'available') { + yield put(inquiryRequest()); + } else { + yield put(inquiryReset()); + } } }; diff --git a/app/sagas/selectServer.js b/app/sagas/selectServer.js index c3b883c31..81b3f7548 100644 --- a/app/sagas/selectServer.js +++ b/app/sagas/selectServer.js @@ -109,6 +109,7 @@ const handleSelectServer = function* handleSelectServer({ server, version, fetch // and block the selectServerSuccess raising multiples errors RocketChat.setSettings(); RocketChat.setCustomEmojis(); + RocketChat.setEnterpriseModules(); let serverInfo; if (fetchVersion) { diff --git a/app/views/RoomsListView/ListHeader/OmnichannelStatus.js b/app/views/RoomsListView/ListHeader/OmnichannelStatus.js new file mode 100644 index 000000000..cc3df2ad9 --- /dev/null +++ b/app/views/RoomsListView/ListHeader/OmnichannelStatus.js @@ -0,0 +1,83 @@ +import React, { memo, useState, useEffect } from 'react'; +import { + View, Text, StyleSheet, Switch +} from 'react-native'; +import PropTypes from 'prop-types'; + +import Touch from '../../../utils/touch'; +import { CustomIcon } from '../../../lib/Icons'; +import I18n from '../../../i18n'; +import styles from '../styles'; +import { themes, SWITCH_TRACK_COLOR } from '../../../constants/colors'; +import { withTheme } from '../../../theme'; +import UnreadBadge from '../../../presentation/UnreadBadge'; +import RocketChat from '../../../lib/rocketchat'; + +const OmnichannelStatus = memo(({ + searching, goQueue, theme, queueSize, inquiryEnabled, user +}) => { + if (searching > 0 || !(RocketChat.isOmnichannelModuleAvailable() && user?.roles?.includes('livechat-agent'))) { + return null; + } + const [status, setStatus] = useState(user?.statusLivechat === 'available'); + + useEffect(() => { + setStatus(user.statusLivechat === 'available'); + }, [user.statusLivechat]); + + const toggleLivechat = async() => { + try { + setStatus(v => !v); + await RocketChat.changeLivechatStatus(); + } catch { + setStatus(v => !v); + } + }; + + return ( + + + + {I18n.t('Omnichannel')} + {inquiryEnabled + ? ( + + ) + : null} + + + + ); +}); + +OmnichannelStatus.propTypes = { + searching: PropTypes.bool, + goQueue: PropTypes.func, + queueSize: PropTypes.number, + inquiryEnabled: PropTypes.bool, + theme: PropTypes.string, + user: PropTypes.shape({ + roles: PropTypes.array, + statusLivechat: PropTypes.string + }) +}; + +export default withTheme(OmnichannelStatus); diff --git a/app/views/RoomsListView/ListHeader/Queue.js b/app/views/RoomsListView/ListHeader/Queue.js deleted file mode 100644 index 0a85d657a..000000000 --- a/app/views/RoomsListView/ListHeader/Queue.js +++ /dev/null @@ -1,49 +0,0 @@ -import React from 'react'; -import { View, Text, StyleSheet } from 'react-native'; -import PropTypes from 'prop-types'; - -import Touch from '../../../utils/touch'; -import I18n from '../../../i18n'; -import styles from '../styles'; -import { themes } from '../../../constants/colors'; -import { withTheme } from '../../../theme'; -import UnreadBadge from '../../../presentation/UnreadBadge'; - -const Queue = React.memo(({ - searching, goQueue, queueSize, inquiryEnabled, theme -}) => { - if (searching > 0 || !inquiryEnabled) { - return null; - } - return ( - - - {I18n.t('Queued_chats')} - - - - ); -}); - -Queue.propTypes = { - searching: PropTypes.bool, - goQueue: PropTypes.func, - queueSize: PropTypes.number, - inquiryEnabled: PropTypes.bool, - theme: PropTypes.string -}; - -export default withTheme(Queue); diff --git a/app/views/RoomsListView/ListHeader/Sort.js b/app/views/RoomsListView/ListHeader/Sort.js index a2bdfac42..972b29801 100644 --- a/app/views/RoomsListView/ListHeader/Sort.js +++ b/app/views/RoomsListView/ListHeader/Sort.js @@ -28,8 +28,8 @@ const Sort = React.memo(({ { borderBottomWidth: StyleSheet.hairlineWidth, borderColor: themes[theme].separatorColor } ]} > + {I18n.t('Sorting_by', { key: I18n.t(sortBy === 'alphabetical' ? 'name' : 'activity') })} - ); diff --git a/app/views/RoomsListView/ListHeader/index.js b/app/views/RoomsListView/ListHeader/index.js index 3de54b913..5fe4462fc 100644 --- a/app/views/RoomsListView/ListHeader/index.js +++ b/app/views/RoomsListView/ListHeader/index.js @@ -1,8 +1,8 @@ import React from 'react'; import PropTypes from 'prop-types'; -import Queue from './Queue'; import Sort from './Sort'; +import OmnichannelStatus from './OmnichannelStatus'; const ListHeader = React.memo(({ searching, @@ -10,11 +10,12 @@ const ListHeader = React.memo(({ toggleSort, goQueue, queueSize, - inquiryEnabled + inquiryEnabled, + user }) => ( <> - + )); @@ -24,7 +25,8 @@ ListHeader.propTypes = { toggleSort: PropTypes.func, goQueue: PropTypes.func, queueSize: PropTypes.number, - inquiryEnabled: PropTypes.bool + inquiryEnabled: PropTypes.bool, + user: PropTypes.object }; export default ListHeader; diff --git a/app/views/RoomsListView/SortDropdown/index.js b/app/views/RoomsListView/SortDropdown/index.js index f50c939d7..536f5738c 100644 --- a/app/views/RoomsListView/SortDropdown/index.js +++ b/app/views/RoomsListView/SortDropdown/index.js @@ -156,8 +156,8 @@ class Sort extends PureComponent { > + {I18n.t('Sorting_by', { key: I18n.t(sortBy === 'alphabetical' ? 'name' : 'activity') })} - diff --git a/app/views/RoomsListView/index.js b/app/views/RoomsListView/index.js index d9f37eba4..8f75eeb0b 100644 --- a/app/views/RoomsListView/index.js +++ b/app/views/RoomsListView/index.js @@ -62,7 +62,7 @@ import { goRoom } from '../../utils/goRoom'; import SafeAreaView from '../../containers/SafeAreaView'; import Header, { getHeaderTitlePosition } from '../../containers/Header'; import { withDimensions } from '../../dimensions'; -import { showErrorAlert } from '../../utils/info'; +import { showErrorAlert, showConfirmationAlert } from '../../utils/info'; import { getInquiryQueueSelector } from '../../selectors/inquiry'; const INITIAL_NUM_TO_RENDER = isTablet ? 20 : 12; @@ -109,7 +109,8 @@ class RoomsListView extends React.Component { user: PropTypes.shape({ id: PropTypes.string, username: PropTypes.string, - token: PropTypes.string + token: PropTypes.string, + statusLivechat: PropTypes.string }), server: PropTypes.string, searchText: PropTypes.string, @@ -450,7 +451,6 @@ class RoomsListView extends React.Component { .observe(); } - this.querySubscription = observable.subscribe((data) => { let tempChats = []; let chats = data; @@ -685,7 +685,28 @@ class RoomsListView extends React.Component { goQueue = () => { logEvent(events.RL_GO_QUEUE); - const { navigation, isMasterDetail, queueSize } = this.props; + const { + navigation, isMasterDetail, queueSize, inquiryEnabled, user + } = this.props; + + // if not-available, prompt to change to available + if (user?.statusLivechat !== 'available') { + showConfirmationAlert({ + message: I18n.t('Omnichannel_enable_alert'), + callToAction: I18n.t('Yes'), + onPress: async() => { + try { + await RocketChat.changeLivechatStatus(); + } catch { + // Do nothing + } + } + }); + } + + if (!inquiryEnabled) { + return; + } // prevent navigation to empty list if (!queueSize) { return showErrorAlert(I18n.t('Queue_is_empty'), I18n.t('Oops')); @@ -813,7 +834,9 @@ class RoomsListView extends React.Component { renderListHeader = () => { const { searching } = this.state; - const { sortBy, queueSize, inquiryEnabled } = this.props; + const { + sortBy, queueSize, inquiryEnabled, user + } = this.props; return ( ); }; diff --git a/app/views/RoomsListView/styles.js b/app/views/RoomsListView/styles.js index f304518e1..6577cbbc9 100644 --- a/app/views/RoomsListView/styles.js +++ b/app/views/RoomsListView/styles.js @@ -23,7 +23,11 @@ export default StyleSheet.create({ sortToggleText: { fontSize: 16, flex: 1, - marginLeft: 12, + ...sharedStyles.textRegular + }, + queueToggleText: { + fontSize: 16, + flex: 1, ...sharedStyles.textRegular }, dropdownContainer: { @@ -58,6 +62,11 @@ export default StyleSheet.create({ height: 22, marginHorizontal: 12 }, + queueIcon: { + width: 22, + height: 22, + marginHorizontal: 12 + }, groupTitleContainer: { paddingHorizontal: 12, paddingTop: 17, @@ -116,5 +125,8 @@ export default StyleSheet.create({ serverSeparator: { height: StyleSheet.hairlineWidth, marginLeft: 72 + }, + omnichannelToggle: { + marginRight: 12 } }); diff --git a/app/views/SettingsView/index.js b/app/views/SettingsView/index.js index ee362b772..83e265461 100644 --- a/app/views/SettingsView/index.js +++ b/app/views/SettingsView/index.js @@ -36,7 +36,6 @@ import { LISTENER } from '../../containers/Toast'; import EventEmitter from '../../utils/events'; import { appStart as appStartAction, ROOT_LOADING } from '../../actions/app'; import { onReviewPress } from '../../utils/review'; -import { getUserSelector } from '../../selectors/login'; import SafeAreaView from '../../containers/SafeAreaView'; const SectionSeparator = React.memo(({ theme }) => ( @@ -73,20 +72,9 @@ class SettingsView extends React.Component { isMasterDetail: PropTypes.bool, logout: PropTypes.func.isRequired, selectServerRequest: PropTypes.func, - user: PropTypes.shape({ - roles: PropTypes.array, - statusLivechat: PropTypes.string - }), appStart: PropTypes.func } - get showLivechat() { - const { user } = this.props; - const { roles } = user; - - return roles?.includes('livechat-agent'); - } - handleLogout = () => { logEvent(events.SE_LOG_OUT); showConfirmationAlert({ @@ -131,14 +119,6 @@ class SettingsView extends React.Component { } } - toggleLivechat = async() => { - try { - await RocketChat.changeLivechatStatus(); - } catch { - // Do nothing - } - } - navigateToScreen = (screen) => { logEvent(events[`SE_GO_${ screen.replace('View', '').toUpperCase() }`]); const { navigation } = this.props; @@ -204,18 +184,6 @@ class SettingsView extends React.Component { ); } - renderLivechatSwitch = () => { - const { user } = this.props; - const { statusLivechat } = user; - return ( - - ); - } - render() { const { server, isMasterDetail, theme } = this.props; return ( @@ -336,18 +304,6 @@ class SettingsView extends React.Component { - {this.showLivechat ? ( - <> - this.renderLivechatSwitch()} - theme={theme} - /> - - - ) : null} - ({ server: state.server, - user: getUserSelector(state), allowCrashReport: state.crashReport.allowCrashReport, isMasterDetail: state.app.isMasterDetail });