From e16e89bb00891c79015b62edf7bdd946ce740b48 Mon Sep 17 00:00:00 2001 From: Mattermost Build Date: Fri, 1 Apr 2022 16:29:51 +0200 Subject: [PATCH] MM-42821 - Calls mobile: Default calls doesn't work on mobile (#6107) (#6120) * use call config when checking for Enable/Disable and StartCall permissions * store config, refresh after 20 minutes * load config on startup * batch load calls and load config on startup/reconnect (cherry picked from commit 10479d1d35e433803385002a2bd9c1e9c25b9e1e) Co-authored-by: Christopher Poile --- app/actions/websocket/index.ts | 4 +- app/constants/calls.ts | 4 +- app/products/calls/client/rest.ts | 5 +- .../enable_disable_calls.test.js.snap | 149 --------- .../channel_info/calls_channel_info.tsx | 39 +++ .../enable_disable_calls.tsx | 25 +- .../start_call.tsx | 37 +-- .../components/enable_disable_calls.test.js | 39 --- .../__snapshots__/start_call.test.js.snap | 149 --------- .../calls/components/start_call/index.ts | 34 -- .../components/start_call/start_call.test.js | 134 -------- app/products/calls/hooks.ts | 37 ++- .../calls/store/action_types/calls.ts | 1 + .../calls/store/actions/calls.test.js | 30 +- app/products/calls/store/actions/calls.ts | 53 ++- .../calls/store/reducers/calls.test.js | 18 + app/products/calls/store/reducers/calls.ts | 13 +- .../calls/store/selectors/calls.test.js | 6 +- app/products/calls/store/selectors/calls.ts | 20 +- app/products/calls/store/types/calls.ts | 15 + app/screens/channel/channel_base.js | 4 +- app/screens/channel/index.js | 4 +- .../__snapshots__/channel_info.test.js.snap | 309 ++++++------------ app/screens/channel_info/channel_info.js | 40 +-- app/screens/channel_info/index.js | 11 +- 25 files changed, 366 insertions(+), 814 deletions(-) delete mode 100644 app/products/calls/components/__snapshots__/enable_disable_calls.test.js.snap create mode 100644 app/products/calls/components/channel_info/calls_channel_info.tsx rename app/products/calls/components/{ => channel_info}/enable_disable_calls.tsx (56%) rename app/products/calls/components/{start_call => channel_info}/start_call.tsx (52%) delete mode 100644 app/products/calls/components/enable_disable_calls.test.js delete mode 100644 app/products/calls/components/start_call/__snapshots__/start_call.test.js.snap delete mode 100644 app/products/calls/components/start_call/index.ts delete mode 100644 app/products/calls/components/start_call/start_call.test.js diff --git a/app/actions/websocket/index.ts b/app/actions/websocket/index.ts index f7128eb89..4da6f91de 100644 --- a/app/actions/websocket/index.ts +++ b/app/actions/websocket/index.ts @@ -24,7 +24,7 @@ import {TeamMembership} from '@mm-redux/types/teams'; import {WebSocketMessage} from '@mm-redux/types/websocket'; import EventEmitter from '@mm-redux/utils/event_emitter'; import {removeUserFromList} from '@mm-redux/utils/user_utils'; -import {loadCalls} from '@mmproducts/calls/store/actions/calls'; +import {batchLoadCalls} from '@mmproducts/calls/store/actions/calls'; import { handleCallStarted, handleCallUserConnected, @@ -171,7 +171,7 @@ export function doReconnect(now: number) { if (!me.error) { if (isSupportedServerCalls) { - dispatch(loadCalls()); + dispatch(batchLoadCalls(true)); } const roles = []; diff --git a/app/constants/calls.ts b/app/constants/calls.ts index 4db3f64f4..197f95029 100644 --- a/app/constants/calls.ts +++ b/app/constants/calls.ts @@ -1,6 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +const RefreshConfigMillis = 20 * 60 * 1000; // Refresh config after 20 minutes + const RequiredServer = { FULL_VERSION: '6.3.0', MAJOR_VERSION: 6, @@ -8,4 +10,4 @@ const RequiredServer = { PATCH_VERSION: 0, }; -export default {RequiredServer}; +export default {RequiredServer, RefreshConfigMillis}; diff --git a/app/products/calls/client/rest.ts b/app/products/calls/client/rest.ts index a2d02de67..164f6826f 100644 --- a/app/products/calls/client/rest.ts +++ b/app/products/calls/client/rest.ts @@ -1,11 +1,12 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import type {ServerChannelState} from '@mmproducts/calls/store/types/calls'; +import type {ServerChannelState, ServerConfig} from '@mmproducts/calls/store/types/calls'; export interface ClientCallsMix { getEnabled: () => Promise; getCalls: () => Promise; + getCallsConfig: () => Promise; enableChannelCalls: (channelId: string) => Promise; disableChannelCalls: (channelId: string) => Promise; } @@ -34,7 +35,7 @@ const ClientCalls = (superclass: any) => class extends superclass { return this.doFetch( `${this.getCallsRoute()}/config`, {method: 'get'}, - ); + ) as ServerConfig; }; enableChannelCalls = async (channelId: string) => { diff --git a/app/products/calls/components/__snapshots__/enable_disable_calls.test.js.snap b/app/products/calls/components/__snapshots__/enable_disable_calls.test.js.snap deleted file mode 100644 index 204cdcaa3..000000000 --- a/app/products/calls/components/__snapshots__/enable_disable_calls.test.js.snap +++ /dev/null @@ -1,149 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`EnableDisableCalls should match snapshot if calls are disabled 1`] = ` - - - - -`; - -exports[`EnableDisableCalls should match snapshot if calls are enabled 1`] = ` - - - - -`; diff --git a/app/products/calls/components/channel_info/calls_channel_info.tsx b/app/products/calls/components/channel_info/calls_channel_info.tsx new file mode 100644 index 000000000..57e2f36be --- /dev/null +++ b/app/products/calls/components/channel_info/calls_channel_info.tsx @@ -0,0 +1,39 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import {Theme} from '@mm-redux/types/theme'; +import EnableDisableCalls from '@mmproducts/calls/components/channel_info/enable_disable_calls'; +import StartCall from '@mmproducts/calls/components/channel_info/start_call'; +import {useCallsChannelSettings} from '@mmproducts/calls/hooks'; + +type Props = { + theme: Theme; + joinCall: (channelId: string) => void; +} + +const CallsChannelInfo = ({theme, joinCall}: Props) => { + const [enabled, canEnableDisable] = useCallsChannelSettings(); + + return ( + <> + {enabled && + + } + {canEnableDisable && + + } + + ); +}; + +export default CallsChannelInfo; diff --git a/app/products/calls/components/enable_disable_calls.tsx b/app/products/calls/components/channel_info/enable_disable_calls.tsx similarity index 56% rename from app/products/calls/components/enable_disable_calls.tsx rename to app/products/calls/components/channel_info/enable_disable_calls.tsx index a17e7f79c..53d0f881c 100644 --- a/app/products/calls/components/enable_disable_calls.tsx +++ b/app/products/calls/components/channel_info/enable_disable_calls.tsx @@ -2,30 +2,35 @@ // See LICENSE.txt for license information. import React, {useCallback} from 'react'; +import {useSelector} from 'react-redux'; +import {getCurrentChannel} from '@mm-redux/selectors/entities/channels'; import {Theme} from '@mm-redux/types/theme'; import {useTryCallsFunction} from '@mmproducts/calls/hooks'; +import {disableChannelCalls, enableChannelCalls} from '@mmproducts/calls/store/actions/calls'; import ChannelInfoRow from '@screens/channel_info/channel_info_row'; import Separator from '@screens/channel_info/separator'; import {preventDoubleTap} from '@utils/tap'; type Props = { - testID?: string; + testID: string; theme: Theme; - onPress: (channelId: string) => void; - canEnableDisableCalls: boolean; enabled: boolean; } -const EnableDisableCalls = (props: Props) => { - const {testID, canEnableDisableCalls, theme, onPress, enabled} = props; +const EnableDisableCalls = ({testID, theme, enabled}: Props) => { + const currentChannel = useSelector(getCurrentChannel); - const [tryOnPress, msgPostfix] = useTryCallsFunction(onPress); - const handleEnableDisableCalls = useCallback(preventDoubleTap(tryOnPress), [tryOnPress]); + const toggleCalls = useCallback(() => { + if (enabled) { + disableChannelCalls(currentChannel.id); + } else { + enableChannelCalls(currentChannel.id); + } + }, [enabled, currentChannel.id]); - if (!canEnableDisableCalls) { - return null; - } + const [tryOnPress, msgPostfix] = useTryCallsFunction(toggleCalls); + const handleEnableDisableCalls = preventDoubleTap(tryOnPress); return ( <> diff --git a/app/products/calls/components/start_call/start_call.tsx b/app/products/calls/components/channel_info/start_call.tsx similarity index 52% rename from app/products/calls/components/start_call/start_call.tsx rename to app/products/calls/components/channel_info/start_call.tsx index 249bdd4da..b6fc5f127 100644 --- a/app/products/calls/components/start_call/start_call.tsx +++ b/app/products/calls/components/channel_info/start_call.tsx @@ -3,43 +3,42 @@ import React, {useCallback} from 'react'; import {injectIntl, IntlShape} from 'react-intl'; +import {useSelector} from 'react-redux'; +import {getChannel, getCurrentChannel} from '@mm-redux/selectors/entities/channels'; +import {GlobalState} from '@mm-redux/types/store'; import {Theme} from '@mm-redux/types/theme'; import leaveAndJoinWithAlert from '@mmproducts/calls/components/leave_and_join_alert'; import {useTryCallsFunction} from '@mmproducts/calls/hooks'; +import {getCalls, getCurrentCall} from '@mmproducts/calls/store/selectors/calls'; import ChannelInfoRow from '@screens/channel_info/channel_info_row'; import Separator from '@screens/channel_info/separator'; import {preventDoubleTap} from '@utils/tap'; type Props = { - actions: { - joinCall: (channelId: string) => any; - }; - testID?: string; + testID: string; theme: Theme; - currentChannelId: string; - currentChannelName: string; - callChannelName: string; - confirmToJoin: boolean; - alreadyInTheCall: boolean; - canStartCall: boolean; - ongoingCall: boolean; intl: typeof IntlShape; + joinCall: (channelId: string) => void; } -const StartCall = (props: Props) => { - const {testID, canStartCall, theme, actions, currentChannelId, callChannelName, currentChannelName, confirmToJoin, alreadyInTheCall, ongoingCall, intl} = props; +const StartCall = ({testID, theme, intl, joinCall}: Props) => { + const currentChannel = useSelector(getCurrentChannel); + const call = useSelector(getCalls)[currentChannel.id]; + const currentCall = useSelector(getCurrentCall); + const currentCallChannelId = currentCall?.channelId || ''; + const callChannelName = useSelector((state: GlobalState) => getChannel(state, currentCallChannelId)?.display_name) || ''; + + const confirmToJoin = Boolean(currentCall && currentCall.channelId !== currentChannel.id); + const alreadyInTheCall = Boolean(currentCall && call && currentCall.channelId === call.channelId); + const ongoingCall = Boolean(call); const leaveAndJoin = useCallback(() => { - leaveAndJoinWithAlert(intl, currentChannelId, callChannelName, currentChannelName, confirmToJoin, actions.joinCall); - }, [intl, currentChannelId, callChannelName, currentChannelName, confirmToJoin, actions.joinCall]); + leaveAndJoinWithAlert(intl, currentChannel.id, callChannelName, currentChannel.display_name, confirmToJoin, joinCall); + }, [intl, currentChannel.id, callChannelName, currentChannel.display_name, confirmToJoin, joinCall]); const [tryLeaveAndJoin, msgPostfix] = useTryCallsFunction(leaveAndJoin); const handleStartCall = useCallback(preventDoubleTap(tryLeaveAndJoin), [tryLeaveAndJoin]); - if (!canStartCall) { - return null; - } - if (alreadyInTheCall) { return null; } diff --git a/app/products/calls/components/enable_disable_calls.test.js b/app/products/calls/components/enable_disable_calls.test.js deleted file mode 100644 index 689cee34d..000000000 --- a/app/products/calls/components/enable_disable_calls.test.js +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {shallow} from 'enzyme'; -import React from 'react'; - -import Preferences from '@mm-redux/constants/preferences'; - -import EnableDisableCalls from './enable_disable_calls'; - -describe('EnableDisableCalls', () => { - const baseProps = { - testID: 'test-id', - theme: Preferences.THEMES.denim, - onPress: jest.fn(), - canEnableDisableCalls: true, - enabled: false, - }; - - test('should match snapshot if calls are disabled', () => { - const wrapper = shallow(); - - expect(wrapper.getElement()).toMatchSnapshot(); - }); - - test('should match snapshot if calls are enabled', () => { - const props = {...baseProps, enabled: true}; - const wrapper = shallow(); - - expect(wrapper.getElement()).toMatchSnapshot(); - }); - - test('should be null if you can not enable/disable calls', () => { - const props = {...baseProps, canEnableDisableCalls: false}; - const wrapper = shallow(); - - expect(wrapper.getElement()).toBeNull(); - }); -}); diff --git a/app/products/calls/components/start_call/__snapshots__/start_call.test.js.snap b/app/products/calls/components/start_call/__snapshots__/start_call.test.js.snap deleted file mode 100644 index 27bc4df6c..000000000 --- a/app/products/calls/components/start_call/__snapshots__/start_call.test.js.snap +++ /dev/null @@ -1,149 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`StartCall should match snapshot 1`] = ` - - - - -`; - -exports[`StartCall should match snapshot when there is already an ongoing call in the channel 1`] = ` - - - - -`; diff --git a/app/products/calls/components/start_call/index.ts b/app/products/calls/components/start_call/index.ts deleted file mode 100644 index ba67a14d2..000000000 --- a/app/products/calls/components/start_call/index.ts +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. -import {connect} from 'react-redux'; -import {bindActionCreators, Dispatch} from 'redux'; - -import {getChannel, getCurrentChannelId} from '@mm-redux/selectors/entities/channels'; -import {joinCall} from '@mmproducts/calls/store/actions/calls'; -import {getCalls, getCurrentCall} from '@mmproducts/calls/store/selectors/calls'; - -import StartCall from './start_call'; - -import type {GlobalState} from '@mm-redux/types/store'; - -function mapStateToProps(state: GlobalState) { - const currentChannelId = getCurrentChannelId(state); - const call = getCalls(state)[currentChannelId]; - const currentCall = getCurrentCall(state); - return { - confirmToJoin: Boolean(currentCall && currentCall.channelId !== currentChannelId), - alreadyInTheCall: Boolean(currentCall && call && currentCall.channelId === call.channelId), - callChannelName: currentCall ? getChannel(state, currentCall.channelId)?.display_name : '', - ongoingCall: Boolean(call), - }; -} - -function mapDispatchToProps(dispatch: Dispatch) { - return { - actions: bindActionCreators({ - joinCall, - }, dispatch), - }; -} - -export default connect(mapStateToProps, mapDispatchToProps)(StartCall); diff --git a/app/products/calls/components/start_call/start_call.test.js b/app/products/calls/components/start_call/start_call.test.js deleted file mode 100644 index fc9580015..000000000 --- a/app/products/calls/components/start_call/start_call.test.js +++ /dev/null @@ -1,134 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import nock from 'nock'; -import React from 'react'; -import {Alert, TouchableHighlight} from 'react-native'; - -import {Client4} from '@client/rest'; -import Preferences from '@mm-redux/constants/preferences'; -import ChannelInfoRow from '@screens/channel_info/channel_info_row'; -import {shallowWithIntl} from '@test/intl-test-helper'; -import TestHelper from '@test/test_helper'; - -import StartCall from './start_call'; - -describe('StartCall', () => { - beforeAll(async () => { - await TestHelper.initBasic(Client4); - }); - - afterAll(async () => { - await TestHelper.tearDown(); - }); - - const baseProps = { - actions: { - joinCall: jest.fn(), - }, - testID: 'test-id', - theme: Preferences.THEMES.denim, - currentChannelId: 'channel-id', - currentChannelName: 'Channel Name', - canStartCall: true, - callChannelName: 'Call channel name', - confirmToJoin: false, - alreadyInTheCall: false, - ongoingCall: false, - }; - - test('should match snapshot', () => { - const wrapper = shallowWithIntl().dive(); - - expect(wrapper.getElement()).toMatchSnapshot(); - }); - - test('should match snapshot when there is already an ongoing call in the channel', () => { - const props = {...baseProps, ongoingCall: true}; - const wrapper = shallowWithIntl().dive(); - - expect(wrapper.getElement()).toMatchSnapshot(); - }); - - test('should be null when you are already in the channel call', () => { - const props = {...baseProps, alreadyInTheCall: true}; - const wrapper = shallowWithIntl().dive(); - - expect(wrapper.getElement()).toBeNull(); - }); - - test('should be null if you can not start a call', () => { - const props = {...baseProps, canStartCall: false}; - const wrapper = shallowWithIntl().dive(); - - expect(wrapper.getElement()).toBeNull(); - }); - - test('should start on click when calls is enabled', async () => { - nock(Client4.getCallsRoute()). - get('/version'). - times(2). - reply(200, {version: 1, build: 2}); - const joinCall = jest.fn(); - const props = {...baseProps, actions: {joinCall}}; - const wrapper = shallowWithIntl().dive(); - wrapper.find(ChannelInfoRow).dive().find(TouchableHighlight).simulate('press'); - - // This is so that the awaited call within ClientCalls in products/calls/client/rest.ts has - // a chance to be completed by nock: - await Client4.doFetch( - `${Client4.getUrl()}/plugins/com.mattermost.calls/version`, - {method: 'get'}, - ); - - expect(Alert.alert).not.toHaveBeenCalled(); - expect(props.actions.joinCall).toHaveBeenCalled(); - }); - - test('should not start on click and should show alert when calls is not enabled', async () => { - nock(Client4.getCallsRoute()). - get('/version'). - times(2). - reply(404); - const joinCall = jest.fn(); - const props = {...baseProps, actions: {joinCall}}; - const wrapper = shallowWithIntl().dive(); - wrapper.find(ChannelInfoRow).dive().find(TouchableHighlight).simulate('press'); - - // This is so that the awaited call within ClientCalls in products/calls/client/rest.ts has - // a chance to be completed by nock: - try { - await Client4.doFetch( - `${Client4.getUrl()}/plugins/com.mattermost.calls/version`, - {method: 'get'}, - ); - } catch (e) { - // expected - } - - expect(Alert.alert).toHaveBeenCalled(); - expect(props.actions.joinCall).not.toHaveBeenCalled(); - }); - - test('should ask for confirmation on click', async () => { - nock(Client4.getCallsRoute()). - get('/version'). - times(2). - reply(200, {version: 1, build: 2}); - const joinCall = jest.fn(); - const props = {...baseProps, confirmToJoin: true, actions: {joinCall}}; - const wrapper = shallowWithIntl().dive(); - - wrapper.find(ChannelInfoRow).dive().find(TouchableHighlight).simulate('press'); - - // This is so that the awaited call within ClientCalls in products/calls/client/rest.ts has - // a chance to be completed by nock: - await Client4.doFetch( - `${Client4.getUrl()}/plugins/com.mattermost.calls/version`, - {method: 'get'}, - ); - - expect(Alert.alert).toHaveBeenCalled(); - expect(props.actions.joinCall).not.toHaveBeenCalled(); - }); -}); diff --git a/app/products/calls/hooks.ts b/app/products/calls/hooks.ts index 505fe7eb9..a1a70e04c 100644 --- a/app/products/calls/hooks.ts +++ b/app/products/calls/hooks.ts @@ -1,10 +1,17 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {useState} from 'react'; +import {useEffect, useState} from 'react'; import {Alert} from 'react-native'; +import {useDispatch, useSelector} from 'react-redux'; import {Client4} from '@client/rest'; +import {General} from '@mm-redux/constants'; +import {getCurrentChannel} from '@mm-redux/selectors/entities/channels'; +import {getCurrentUserRoles} from '@mm-redux/selectors/entities/users'; +import {isAdmin as checkIsAdmin, isChannelAdmin as checkIsChannelAdmin} from '@mm-redux/utils/user_utils'; +import {loadConfig} from '@mmproducts/calls/store/actions/calls'; +import {getConfig, isCallsExplicitlyDisabled, isCallsExplicitlyEnabled} from '@mmproducts/calls/store/selectors/calls'; // Check if calls is enabled. If it is, then run fn; if it isn't, show an alert and set // msgPostfix to ' (Not Available)'. @@ -33,3 +40,31 @@ export const useTryCallsFunction = (fn: (channelId: string) => void) => { return [tryFn, msgPostfix]; }; + +export const useCallsChannelSettings = () => { + const dispatch = useDispatch(); + const config = useSelector(getConfig); + const currentChannel = useSelector(getCurrentChannel); + const explicitlyDisabled = useSelector(isCallsExplicitlyDisabled); + const explicitlyEnabled = useSelector(isCallsExplicitlyEnabled); + const roles = useSelector(getCurrentUserRoles); + + useEffect(() => { + dispatch(loadConfig()); + }, []); + + const isDirectMessage = currentChannel.type === General.DM_CHANNEL; + const isGroupMessage = currentChannel.type === General.GM_CHANNEL; + const isAdmin = checkIsAdmin(roles); + const isChannelAdmin = isAdmin || checkIsChannelAdmin(roles); + + const enabled = (explicitlyEnabled || (!explicitlyDisabled && config.DefaultEnabled)); + let canEnableDisable; + if (config.AllowEnableCalls) { + canEnableDisable = isDirectMessage || isGroupMessage || isChannelAdmin; + } else { + canEnableDisable = isAdmin; + } + + return [enabled, canEnableDisable]; +}; diff --git a/app/products/calls/store/action_types/calls.ts b/app/products/calls/store/action_types/calls.ts index e9f5ce0db..b438fdde8 100644 --- a/app/products/calls/store/action_types/calls.ts +++ b/app/products/calls/store/action_types/calls.ts @@ -23,4 +23,5 @@ export default keyMirror({ RECEIVED_UNRAISE_HAND: null, SET_SCREENSHARE_URL: null, SET_SPEAKERPHONE: null, + RECEIVED_CONFIG: null, }); diff --git a/app/products/calls/store/actions/calls.test.js b/app/products/calls/store/actions/calls.test.js index dcabda889..cc621b679 100644 --- a/app/products/calls/store/actions/calls.test.js +++ b/app/products/calls/store/actions/calls.test.js @@ -31,6 +31,12 @@ jest.mock('@client/rest', () => ({ enabled: true, }, ]), + getCallsConfig: jest.fn(() => ({ + ICEServers: ['mattermost.com'], + AllowEnableCalls: true, + DefaultEnabled: true, + last_retrieved_at: 1234, + })), enableChannelCalls: jest.fn(() => null), disableChannelCalls: jest.fn(() => null), }, @@ -72,6 +78,7 @@ describe('Actions.Calls', () => { newClient.mockClear(); Client4.setUrl.mockClear(); Client4.getCalls.mockClear(); + Client4.getCallsConfig.mockClear(); Client4.enableChannelCalls.mockClear(); Client4.disableChannelCalls.mockClear(); store = await configureStore(); @@ -123,12 +130,33 @@ describe('Actions.Calls', () => { }); it('loadCalls', async () => { - await store.dispatch(CallsActions.loadCalls()); + await store.dispatch(await store.dispatch(CallsActions.loadCalls())); expect(Client4.getCalls).toBeCalledWith(); assert.equal(store.getState().entities.calls.calls['channel-1'].channelId, 'channel-1'); assert.equal(store.getState().entities.calls.enabled['channel-1'], true); }); + it('loadConfig', async () => { + await store.dispatch(await store.dispatch(CallsActions.loadConfig())); + expect(Client4.getCallsConfig).toBeCalledWith(); + assert.equal(store.getState().entities.calls.config.DefaultEnabled, true); + assert.equal(store.getState().entities.calls.config.AllowEnableCalls, true); + }); + + it('batchLoadConfig', async () => { + await store.dispatch(CallsActions.batchLoadCalls()); + expect(Client4.getCallsConfig).toBeCalledWith(); + expect(Client4.getCalls).toBeCalledWith(); + + // For some reason the above await is not working. This helps us: + await store.dispatch(CallsActions.enableChannelCalls('channel-1')); + + assert.equal(store.getState().entities.calls.config.DefaultEnabled, true); + assert.equal(store.getState().entities.calls.config.AllowEnableCalls, true); + assert.equal(store.getState().entities.calls.calls['channel-1'].channelId, 'channel-1'); + assert.equal(store.getState().entities.calls.enabled['channel-1'], true); + }); + it('enableChannelCalls', async () => { assert.equal(store.getState().entities.calls.enabled['channel-1'], undefined); await store.dispatch(CallsActions.enableChannelCalls('channel-1')); diff --git a/app/products/calls/store/actions/calls.ts b/app/products/calls/store/actions/calls.ts index 098b5cb44..f5603f1ca 100644 --- a/app/products/calls/store/actions/calls.ts +++ b/app/products/calls/store/actions/calls.ts @@ -4,26 +4,56 @@ import InCallManager from 'react-native-incall-manager'; import {Client4} from '@client/rest'; +import Calls from '@constants/calls'; import {logError} from '@mm-redux/actions/errors'; import {forceLogoutIfNecessary} from '@mm-redux/actions/helpers'; -import {GenericAction, ActionFunc, DispatchFunc, GetStateFunc} from '@mm-redux/types/actions'; +import {GenericAction, ActionFunc, DispatchFunc, GetStateFunc, batchActions} from '@mm-redux/types/actions'; import {Dictionary} from '@mm-redux/types/utilities'; import {newClient} from '@mmproducts/calls/connection'; import CallsTypes from '@mmproducts/calls/store/action_types/calls'; - -import type {Call, CallParticipant} from '@mmproducts/calls/store/types/calls'; +import {getConfig} from '@mmproducts/calls/store/selectors/calls'; +import {Call, CallParticipant, DefaultServerConfig} from '@mmproducts/calls/store/types/calls'; export let ws: any = null; +export function loadConfig(force = false): ActionFunc { + return async (dispatch: DispatchFunc, getState: GetStateFunc): Promise => { + if (!force) { + if ((Date.now() - getConfig(getState()).last_retrieved_at) < Calls.RefreshConfigMillis) { + return {} as GenericAction; + } + } + + let data; + try { + data = await Client4.getCallsConfig(); + } catch (error) { + forceLogoutIfNecessary(error, dispatch, getState); + dispatch(logError(error)); + + // Reset the config to the default (off) since it looks like Calls is not enabled. + return { + type: CallsTypes.RECEIVED_CONFIG, + data: {...DefaultServerConfig, last_retrieved_at: Date.now()}, + }; + } + + return { + type: CallsTypes.RECEIVED_CONFIG, + data: {...data, last_retrieved_at: Date.now()}, + }; + }; +} + export function loadCalls(): ActionFunc { - return async (dispatch: DispatchFunc, getState: GetStateFunc) => { + return async (dispatch: DispatchFunc, getState: GetStateFunc): Promise => { let resp = []; try { resp = await Client4.getCalls(); } catch (error) { forceLogoutIfNecessary(error, dispatch, getState); dispatch(logError(error)); - return {error}; + return {} as GenericAction; } const callsResults: Dictionary = {}; @@ -54,9 +84,18 @@ export function loadCalls(): ActionFunc { enabled: enabledChannels, }; - dispatch({type: CallsTypes.RECEIVED_CALLS, data}); + return {type: CallsTypes.RECEIVED_CALLS, data}; + }; +} - return {data}; +export function batchLoadCalls(forceConfig = false): ActionFunc { + return async (dispatch: DispatchFunc) => { + const promises = [dispatch(loadConfig(forceConfig)), dispatch(loadCalls())]; + Promise.all(promises).then((actions: Array>) => { + dispatch(batchActions(actions, 'BATCH_LOAD_CALLS')); + }); + + return {}; }; } diff --git a/app/products/calls/store/reducers/calls.test.js b/app/products/calls/store/reducers/calls.test.js index 2ae9e02d2..e7cf06eac 100644 --- a/app/products/calls/store/reducers/calls.test.js +++ b/app/products/calls/store/reducers/calls.test.js @@ -4,6 +4,7 @@ import assert from 'assert'; import CallsTypes from '@mmproducts/calls/store/action_types/calls'; +import {DefaultServerConfig} from '@mmproducts/calls/store/types/calls'; import callsReducer from './calls'; @@ -390,3 +391,20 @@ describe('Reducers.calls.screenShareURL', () => { assert.deepEqual(state.screenShareURL, 'new-url'); }); }); + +describe('Reducers.calls.config', () => { + it('RECEIVED_CONFIG', async () => { + const initialState = {config: DefaultServerConfig}; + const testAction = { + type: CallsTypes.RECEIVED_CONFIG, + data: { + ICEServers: ['google.com'], + AllowEnableCalls: true, + DefaultEnabled: true, + last_retrieved_at: 123, + }, + }; + const state = callsReducer(initialState, testAction); + assert.deepEqual(state.config, testAction.data); + }); +}); diff --git a/app/products/calls/store/reducers/calls.ts b/app/products/calls/store/reducers/calls.ts index 762ac6390..0ead8e7ea 100644 --- a/app/products/calls/store/reducers/calls.ts +++ b/app/products/calls/store/reducers/calls.ts @@ -5,7 +5,7 @@ import {combineReducers} from 'redux'; import {GenericAction} from '@mm-redux/types/actions'; import {Dictionary} from '@mm-redux/types/utilities'; import CallsTypes from '@mmproducts/calls/store/action_types/calls'; -import {Call} from '@mmproducts/calls/store/types/calls'; +import {Call, DefaultServerConfig} from '@mmproducts/calls/store/types/calls'; function calls(state: Dictionary = {}, action: GenericAction) { switch (action.type) { @@ -147,6 +147,16 @@ function calls(state: Dictionary = {}, action: GenericAction) { } } +function config(state = DefaultServerConfig, action: GenericAction) { + switch (action.type) { + case CallsTypes.RECEIVED_CONFIG: { + return action.data; + } + default: + return state; + } +} + function joined(state = '', action: GenericAction) { switch (action.type) { case CallsTypes.RECEIVED_MYSELF_JOINED_CALL: { @@ -212,4 +222,5 @@ export default combineReducers({ joined, screenShareURL, speakerphoneOn, + config, }); diff --git a/app/products/calls/store/selectors/calls.test.js b/app/products/calls/store/selectors/calls.test.js index dcfa3030d..a1f5271af 100644 --- a/app/products/calls/store/selectors/calls.test.js +++ b/app/products/calls/store/selectors/calls.test.js @@ -55,7 +55,7 @@ describe('Selectors.Calls', () => { }); it('isCallsEnabled', () => { - assert.equal(Selectors.isCallsEnabled(testState), true); + assert.equal(Selectors.isCallsExplicitlyEnabled(testState), true); let newState = { ...testState, entities: { @@ -63,7 +63,7 @@ describe('Selectors.Calls', () => { channels: {currentChannelId: 'channel-2'}, }, }; - assert.equal(Selectors.isCallsEnabled(newState), false); + assert.equal(Selectors.isCallsExplicitlyEnabled(newState), false); newState = { ...testState, entities: { @@ -71,7 +71,7 @@ describe('Selectors.Calls', () => { channels: {currentChannelId: 'not-valid-channel'}, }, }; - assert.equal(Selectors.isCallsEnabled(newState), false); + assert.equal(Selectors.isCallsExplicitlyEnabled(newState), false); }); it('getScreenShareURL', () => { diff --git a/app/products/calls/store/selectors/calls.ts b/app/products/calls/store/selectors/calls.ts index 9df97fcf3..92a975235 100644 --- a/app/products/calls/store/selectors/calls.ts +++ b/app/products/calls/store/selectors/calls.ts @@ -8,6 +8,10 @@ import {getServerVersion} from '@mm-redux/selectors/entities/general'; import {GlobalState} from '@mm-redux/types/store'; import {isMinimumServerVersion} from '@mm-redux/utils/helpers'; +export function getConfig(state: GlobalState) { + return state.entities.calls.config; +} + export function getCalls(state: GlobalState) { return state.entities.calls.calls; } @@ -20,8 +24,20 @@ export function getCurrentCall(state: GlobalState) { return state.entities.calls.calls[currentCall]; } -export function isCallsEnabled(state: GlobalState) { - return Boolean(state.entities.calls.enabled[getCurrentChannelId(state)]); +// isCallsExplicitlyEnabled returns true if and only if calls has been explicitly enabled in the current channel +// Both this and isCallsExplicitlyDisabled can be false if the channel has never had a call in it. +export function isCallsExplicitlyEnabled(state: GlobalState) { + const currentChannelId = getCurrentChannelId(state); + const enabledDict = state.entities.calls.enabled; + return enabledDict.hasOwnProperty(currentChannelId) && enabledDict[currentChannelId]; +} + +// isCallsExplicitlyEnabled returns true if and only if calls has been explicitly disabled in the current channel +// Both this and isCallsExplicitlyEnabled can be false if the channel has never had a call in it. +export function isCallsExplicitlyDisabled(state: GlobalState) { + const currentChannelId = getCurrentChannelId(state); + const enabledDict = state.entities.calls.enabled; + return enabledDict.hasOwnProperty(currentChannelId) && !enabledDict[currentChannelId]; } export function getScreenShareURL(state: GlobalState) { diff --git a/app/products/calls/store/types/calls.ts b/app/products/calls/store/types/calls.ts index 7c21509d8..9390de8f6 100644 --- a/app/products/calls/store/types/calls.ts +++ b/app/products/calls/store/types/calls.ts @@ -10,6 +10,7 @@ export type CallsState = { joined: string; screenShareURL: string; speakerphoneOn: boolean; + config: ServerConfig; } export type Call = { @@ -53,3 +54,17 @@ export type VoiceEventData = { channelId: string; userId: string; } + +export type ServerConfig = { + ICEServers: string[]; + AllowEnableCalls: boolean; + DefaultEnabled: boolean; + last_retrieved_at: number; +} + +export const DefaultServerConfig = { + ICEServers: [], + AllowEnableCalls: false, + DefaultEnabled: false, + last_retrieved_at: 0, +} as ServerConfig; diff --git a/app/screens/channel/channel_base.js b/app/screens/channel/channel_base.js index 5d19dfa4d..68b3c0bf1 100644 --- a/app/screens/channel/channel_base.js +++ b/app/screens/channel/channel_base.js @@ -24,7 +24,7 @@ export default class ChannelBase extends PureComponent { loadChannelsForTeam: PropTypes.func.isRequired, selectDefaultTeam: PropTypes.func.isRequired, selectInitialChannel: PropTypes.func.isRequired, - loadCalls: PropTypes.func.isRequired, + batchLoadCalls: PropTypes.func.isRequired, }).isRequired, componentId: PropTypes.string.isRequired, currentChannelId: PropTypes.string, @@ -106,7 +106,7 @@ export default class ChannelBase extends PureComponent { } if (this.props.isSupportedServerCalls) { - this.props.actions.loadCalls(); + this.props.actions.batchLoadCalls(); } } diff --git a/app/screens/channel/index.js b/app/screens/channel/index.js index 7acdb5c7e..3ad55fd2a 100644 --- a/app/screens/channel/index.js +++ b/app/screens/channel/index.js @@ -17,7 +17,7 @@ import {getCurrentTeam} from '@mm-redux/selectors/entities/teams'; import {getCurrentUserId, getCurrentUserRoles, shouldShowTermsOfService} from '@mm-redux/selectors/entities/users'; import {isMinimumServerVersion} from '@mm-redux/utils/helpers'; import {isSystemAdmin as checkIsSystemAdmin} from '@mm-redux/utils/user_utils'; -import {loadCalls} from '@mmproducts/calls/store/actions/calls'; +import {batchLoadCalls} from '@mmproducts/calls/store/actions/calls'; import {isSupportedServer as isSupportedServerForCalls} from '@mmproducts/calls/store/selectors/calls'; import {getViewingGlobalThreads} from '@selectors/threads'; @@ -68,7 +68,7 @@ function mapDispatchToProps(dispatch) { loadChannelsForTeam, selectDefaultTeam, selectInitialChannel, - loadCalls, + batchLoadCalls, }, dispatch), }; } diff --git a/app/screens/channel_info/__snapshots__/channel_info.test.js.snap b/app/screens/channel_info/__snapshots__/channel_info.test.js.snap index 2f5c7eb45..803839533 100644 --- a/app/screens/channel_info/__snapshots__/channel_info.test.js.snap +++ b/app/screens/channel_info/__snapshots__/channel_info.test.js.snap @@ -1117,79 +1117,38 @@ exports[`channelInfo should match snapshot on calls supported and calls disabled } } /> - - - - + } + /> - - - - + } + /> - - - - + } + /> { + joinCallHandler = (channelId) => { this.props.actions.joinCall(channelId); this.close(); }; - toggleCalls = () => { - if (this.props.isCallsEnabled) { - this.props.actions.disableChannelCalls(this.props.currentChannel.id); - } else { - this.props.actions.enableChannelCalls(this.props.currentChannel.id); - } - }; - permalinkBadTeam = () => { const {intl} = this.context; const message = { @@ -117,7 +105,7 @@ export default class ChannelInfo extends PureComponent { }; actionsRows = (channelIsArchived) => { - const {currentChannel, currentUserId, isDirectMessage, isGroupMessage, theme, isCallsEnabled, isSupportedServerCalls, isChannelAdmin} = this.props; + const {currentChannel, currentUserId, isDirectMessage, theme, isSupportedServerCalls} = this.props; if (channelIsArchived) { return ( @@ -179,23 +167,11 @@ export default class ChannelInfo extends PureComponent { theme={theme} /> {isSupportedServerCalls && - <> - - - } + + } diff --git a/app/screens/channel_info/index.js b/app/screens/channel_info/index.js index d8a6d74d7..7f93fc8c2 100644 --- a/app/screens/channel_info/index.js +++ b/app/screens/channel_info/index.js @@ -10,11 +10,11 @@ import {getCustomEmojisInText} from '@mm-redux/actions/emojis'; import {General} from '@mm-redux/constants'; import {getCurrentChannel, getCurrentChannelStats} from '@mm-redux/selectors/entities/channels'; import {getTeammateNameDisplaySetting, getTheme} from '@mm-redux/selectors/entities/preferences'; -import {getCurrentUserRoles, getCurrentUserId, getUser} from '@mm-redux/selectors/entities/users'; +import {getCurrentUserId, getUser} from '@mm-redux/selectors/entities/users'; import {getUserIdFromChannelName} from '@mm-redux/utils/channel_utils'; -import {isAdmin as checkIsAdmin, isChannelAdmin as checkIsChannelAdmin, displayUsername} from '@mm-redux/utils/user_utils'; +import {displayUsername} from '@mm-redux/utils/user_utils'; import {joinCall, enableChannelCalls, disableChannelCalls} from '@mmproducts/calls/store/actions/calls'; -import {isCallsEnabled, isSupportedServer} from '@mmproducts/calls/store/selectors/calls'; +import {isSupportedServer} from '@mmproducts/calls/store/selectors/calls'; import {makeGetCustomStatus, isCustomStatusEnabled, isCustomStatusExpired, isCustomStatusExpirySupported} from '@selectors/custom_status'; import {isGuest} from '@utils/users'; @@ -38,8 +38,6 @@ function makeMapStateToProps() { let customStatus; let customStatusExpired = true; let customStatusExpirySupported = false; - const roles = getCurrentUserRoles(state) || ''; - const isChannelAdmin = checkIsAdmin(roles) || checkIsChannelAdmin(roles); const isDirectMessage = currentChannel.type === General.DM_CHANNEL; if (isDirectMessage) { @@ -70,16 +68,13 @@ function makeMapStateToProps() { currentUserId, isTeammateGuest, isDirectMessage, - isGroupMessage, teammateId, theme: getTheme(state), customStatus, isCustomStatusEnabled: customStatusEnabled, isCustomStatusExpired: customStatusExpired, isCustomStatusExpirySupported: customStatusExpirySupported, - isCallsEnabled: isCallsEnabled(state), isSupportedServerCalls, - isChannelAdmin, }; }; }