From c74cd14713d04ee79d2993a409bf556235a7f9ab Mon Sep 17 00:00:00 2001 From: Christopher Poile Date: Wed, 1 Jun 2022 19:21:10 -0400 Subject: [PATCH] MM-44546 -- Calls: Cloud freemium limits (#6318) * remove API call to config/pass iceServers; leave call on ws error * cloud limits * fix makeStyleSheetFromTheme * revert podfile & package-lock diffs * update snapshots * edge case of cloud server on calls 0.5.3 --- app/constants/calls.ts | 6 +- .../__snapshots__/call_message.test.js.snap | 78 +++++---- .../components/call_message/call_message.tsx | 51 ++++-- .../calls/components/call_message/index.ts | 3 +- .../components/channel_info/start_call.tsx | 5 +- .../__snapshots__/join_call.test.js.snap | 157 +++++++++-------- .../calls/components/join_call/index.ts | 3 +- .../components/join_call/join_call.test.js | 5 +- .../calls/components/join_call/join_call.tsx | 159 ++++++++++-------- app/products/calls/connection.ts | 12 +- .../calls/store/actions/calls.test.js | 5 +- app/products/calls/store/actions/calls.ts | 9 +- app/products/calls/store/selectors/calls.ts | 51 +++++- app/products/calls/store/types/calls.ts | 4 + 14 files changed, 339 insertions(+), 209 deletions(-) diff --git a/app/constants/calls.ts b/app/constants/calls.ts index ec17fde4d..76d5d528f 100644 --- a/app/constants/calls.ts +++ b/app/constants/calls.ts @@ -12,4 +12,8 @@ const RequiredServer = { const PluginId = 'com.mattermost.calls'; -export default {RequiredServer, RefreshConfigMillis, PluginId}; +// Used for case when cloud server is using Calls v0.5.3. +// This can be removed when v0.5.4 is prepackaged in cloud. +const DefaultCloudMaxParticipants = 8; + +export default {RequiredServer, RefreshConfigMillis, PluginId, DefaultCloudMaxParticipants}; diff --git a/app/products/calls/components/call_message/__snapshots__/call_message.test.js.snap b/app/products/calls/components/call_message/__snapshots__/call_message.test.js.snap index cbc7fd9a3..10be80fdd 100644 --- a/app/products/calls/components/call_message/__snapshots__/call_message.test.js.snap +++ b/app/products/calls/components/call_message/__snapshots__/call_message.test.js.snap @@ -57,31 +57,40 @@ exports[`CallMessage should match snapshot 1`] = ` Join call @@ -239,31 +248,40 @@ exports[`CallMessage should match snapshot for the call already in the current c Current call diff --git a/app/products/calls/components/call_message/call_message.tsx b/app/products/calls/components/call_message/call_message.tsx index 841366e46..54426cfd4 100644 --- a/app/products/calls/components/call_message/call_message.tsx +++ b/app/products/calls/components/call_message/call_message.tsx @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import moment from 'moment-timezone'; -import React, {useCallback} from 'react'; +import React from 'react'; import {injectIntl, intlShape, IntlShape} from 'react-intl'; import {View, TouchableOpacity, Text} from 'react-native'; @@ -32,6 +32,7 @@ type CallMessageProps = { currentChannelName: string; callChannelName: string; intl: typeof IntlShape; + isCloudLimitRestricted: boolean; } const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { @@ -66,10 +67,16 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { joinCallButtonText: { color: 'white', }, + joinCallButtonTextRestricted: { + color: changeOpacity(theme.centerChannelColor, 0.32), + }, joinCallButtonIcon: { color: 'white', marginRight: 5, }, + joinCallButtonIconRestricted: { + color: changeOpacity(theme.centerChannelColor, 0.32), + }, startedText: { color: theme.centerChannelColor, fontWeight: 'bold', @@ -82,6 +89,9 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { alignItems: 'center', alignContent: 'center', }, + joinCallButtonRestricted: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.08), + }, timeText: { color: theme.centerChannelColor, }, @@ -98,14 +108,28 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { }; }); -const CallMessage = ({post, user, teammateNameDisplay, confirmToJoin, alreadyInTheCall, theme, actions, userTimezone, isMilitaryTime, currentChannelName, callChannelName, intl}: CallMessageProps) => { +const CallMessage = ({ + post, + user, + teammateNameDisplay, + confirmToJoin, + alreadyInTheCall, + theme, + actions, + userTimezone, + isMilitaryTime, + currentChannelName, + callChannelName, + intl, + isCloudLimitRestricted, +}: CallMessageProps) => { const style = getStyleSheet(theme); - const joinHandler = useCallback(() => { - if (alreadyInTheCall) { + const joinHandler = () => { + if (alreadyInTheCall || isCloudLimitRestricted) { return; } leaveAndJoinWithAlert(intl, post.channel_id, callChannelName, currentChannelName, confirmToJoin, actions.joinCall); - }, [post.channel_id, callChannelName, currentChannelName, confirmToJoin, actions.joinCall]); + }; if (post.props.end_at) { return ( @@ -142,6 +166,8 @@ const CallMessage = ({post, user, teammateNameDisplay, confirmToJoin, alreadyInT ); } + const joinCallButtonText = alreadyInTheCall ? 'Current call' : 'Join call'; + return ( - {alreadyInTheCall && - {'Current call'}} - {!alreadyInTheCall && - {'Join call'}} + + {joinCallButtonText} + ); diff --git a/app/products/calls/components/call_message/index.ts b/app/products/calls/components/call_message/index.ts index 1ade01316..e82eca774 100644 --- a/app/products/calls/components/call_message/index.ts +++ b/app/products/calls/components/call_message/index.ts @@ -11,7 +11,7 @@ import {isTimezoneEnabled} from '@mm-redux/selectors/entities/timezone'; import {getUser, getCurrentUser} from '@mm-redux/selectors/entities/users'; import {getUserCurrentTimezone} from '@mm-redux/utils/timezone_utils'; import {joinCall} from '@mmproducts/calls/store/actions/calls'; -import {getCalls, getCurrentCall} from '@mmproducts/calls/store/selectors/calls'; +import {getCalls, getCurrentCall, isCloudLimitRestricted} from '@mmproducts/calls/store/selectors/calls'; import CallMessage from './call_message'; @@ -41,6 +41,7 @@ function mapStateToProps(state: GlobalState, ownProps: OwnProps) { userTimezone: enableTimezone ? getUserCurrentTimezone(currentUser.timezone) : undefined, currentChannelName: getChannel(state, post.channel_id)?.display_name, callChannelName: currentCall ? getChannel(state, currentCall.channelId)?.display_name : '', + isCloudLimitRestricted: isCloudLimitRestricted(state, post.channel_id), }; } diff --git a/app/products/calls/components/channel_info/start_call.tsx b/app/products/calls/components/channel_info/start_call.tsx index abc88a10b..e1091c89c 100644 --- a/app/products/calls/components/channel_info/start_call.tsx +++ b/app/products/calls/components/channel_info/start_call.tsx @@ -10,7 +10,7 @@ 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 {getCalls, getCurrentCall, isCloudLimitRestricted} 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'; @@ -28,6 +28,7 @@ const StartCall = ({testID, theme, intl, joinCall}: Props) => { const currentCall = useSelector(getCurrentCall); const currentCallChannelId = currentCall?.channelId || ''; const callChannelName = useSelector((state: GlobalState) => getChannel(state, currentCallChannelId)?.display_name) || ''; + const cloudLimitRestricted = useSelector(isCloudLimitRestricted); const confirmToJoin = Boolean(currentCall && currentCall.channelId !== currentChannel.id); const alreadyInTheCall = Boolean(currentCall && call && currentCall.channelId === call.channelId); @@ -39,7 +40,7 @@ const StartCall = ({testID, theme, intl, joinCall}: Props) => { const [tryLeaveAndJoin, msgPostfix] = useTryCallsFunction(leaveAndJoin); const handleStartCall = useCallback(preventDoubleTap(tryLeaveAndJoin), [tryLeaveAndJoin]); - if (alreadyInTheCall) { + if (alreadyInTheCall || cloudLimitRestricted) { return null; } diff --git a/app/products/calls/components/join_call/__snapshots__/join_call.test.js.snap b/app/products/calls/components/join_call/__snapshots__/join_call.test.js.snap index c41928764..4df265679 100644 --- a/app/products/calls/components/join_call/__snapshots__/join_call.test.js.snap +++ b/app/products/calls/components/join_call/__snapshots__/join_call.test.js.snap @@ -1,89 +1,100 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`JoinCall should match snapshot 1`] = ` - - - - Join Call - - - - - - + Join Call + + + + + + - Call participants - - } - userIds={ - Array [ - "user-1-id", - "user-2-id", - ] - } - /> - - + > + Call participants + + } + userIds={ + Array [ + "user-1-id", + "user-2-id", + ] + } + /> + + + `; diff --git a/app/products/calls/components/join_call/index.ts b/app/products/calls/components/join_call/index.ts index 9b8d6f5ba..10c8d8999 100644 --- a/app/products/calls/components/join_call/index.ts +++ b/app/products/calls/components/join_call/index.ts @@ -6,7 +6,7 @@ import {bindActionCreators, Dispatch} from 'redux'; import {getChannel, getCurrentChannelId} from '@mm-redux/selectors/entities/channels'; import {getTheme} from '@mm-redux/selectors/entities/preferences'; import {joinCall} from '@mmproducts/calls/store/actions/calls'; -import {getCalls, getCurrentCall} from '@mmproducts/calls/store/selectors/calls'; +import {getCalls, getCurrentCall, isCloudLimitRestricted} from '@mmproducts/calls/store/selectors/calls'; import JoinCall from './join_call'; @@ -23,6 +23,7 @@ function mapStateToProps(state: GlobalState) { alreadyInTheCall: Boolean(currentCall && call && currentCall.channelId === call.channelId), currentChannelName: getChannel(state, currentChannelId)?.display_name, callChannelName: currentCall ? getChannel(state, currentCall.channelId)?.display_name : '', + isCloudLimitRestricted: isCloudLimitRestricted(state), }; } diff --git a/app/products/calls/components/join_call/join_call.test.js b/app/products/calls/components/join_call/join_call.test.js index 12ecf19d4..535175c8d 100644 --- a/app/products/calls/components/join_call/join_call.test.js +++ b/app/products/calls/components/join_call/join_call.test.js @@ -38,6 +38,7 @@ describe('JoinCall', () => { alreadyInTheCall: false, currentChannelName: 'Current Channel', callChannelName: 'Call Channel', + isCloudLimitRestricted: false, }; test('should match snapshot', () => { @@ -56,7 +57,7 @@ describe('JoinCall', () => { test('should join on click', () => { const joinCall = jest.fn(); const props = {...baseProps, actions: {joinCall}}; - const wrapper = shallowWithIntl().dive(); + const wrapper = shallowWithIntl().dive().childAt(0); wrapper.simulate('press'); expect(Alert.alert).not.toHaveBeenCalled(); @@ -66,7 +67,7 @@ describe('JoinCall', () => { test('should ask for confirmation on click', () => { const joinCall = jest.fn(); const props = {...baseProps, confirmToJoin: true, actions: {joinCall}}; - const wrapper = shallowWithIntl().dive(); + const wrapper = shallowWithIntl().dive().childAt(0); wrapper.simulate('press'); expect(Alert.alert).toHaveBeenCalled(); diff --git a/app/products/calls/components/join_call/join_call.tsx b/app/products/calls/components/join_call/join_call.tsx index c3540512c..102c2a191 100644 --- a/app/products/calls/components/join_call/join_call.tsx +++ b/app/products/calls/components/join_call/join_call.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback, useEffect, useMemo} from 'react'; +import React, {useEffect, useMemo} from 'react'; import {injectIntl, IntlShape} from 'react-intl'; import {View, Text, Pressable} from 'react-native'; @@ -26,49 +26,62 @@ type Props = { alreadyInTheCall: boolean; currentChannelName: string; callChannelName: string; + isCloudLimitRestricted: boolean; intl: typeof IntlShape; } -const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { - return { - container: { - flexDirection: 'row', - backgroundColor: '#3DB887', - width: '100%', - padding: 5, - justifyContent: 'center', - alignItems: 'center', - height: JOIN_CALL_BAR_HEIGHT, - }, - joinCallIcon: { - color: theme.sidebarText, - marginLeft: 10, - marginRight: 5, - }, - joinCall: { - color: theme.sidebarText, - fontWeight: 'bold', - fontSize: 16, - }, - started: { - flex: 1, - color: theme.sidebarText, - fontWeight: '400', - marginLeft: 10, - }, - avatars: { - marginRight: 5, - }, - headerText: { - color: changeOpacity(theme.centerChannelColor, 0.56), - fontSize: 12, - fontWeight: '600', - paddingHorizontal: 16, - paddingVertical: 0, - top: 16, - }, - }; -}); +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + outerContainer: { + backgroundColor: theme.centerChannelBg, + }, + innerContainer: { + flexDirection: 'row', + backgroundColor: '#3DB887', + width: '100%', + padding: 5, + justifyContent: 'center', + alignItems: 'center', + height: JOIN_CALL_BAR_HEIGHT, + }, + innerContainerRestricted: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.48), + }, + joinCallIcon: { + color: theme.sidebarText, + marginLeft: 10, + marginRight: 5, + }, + joinCall: { + color: theme.sidebarText, + fontWeight: 'bold', + fontSize: 16, + }, + started: { + flex: 1, + color: theme.sidebarText, + fontWeight: '400', + marginLeft: 10, + }, + limitReached: { + flex: 1, + display: 'flex', + textAlign: 'right', + marginRight: 10, + color: '#FFFFFFD6', + fontWeight: '400', + }, + avatars: { + marginRight: 5, + }, + headerText: { + color: changeOpacity(theme.centerChannelColor, 0.56), + fontSize: 12, + fontWeight: '600', + paddingHorizontal: 16, + paddingVertical: 0, + top: 16, + }, +})); const JoinCall = (props: Props) => { if (!props.call) { @@ -82,9 +95,12 @@ const JoinCall = (props: Props) => { }; }, [props.call, props.alreadyInTheCall]); - const joinHandler = useCallback(() => { + const joinHandler = () => { + if (props.isCloudLimitRestricted) { + return; + } leaveAndJoinWithAlert(props.intl, props.call.channelId, props.callChannelName, props.currentChannelName, props.confirmToJoin, props.actions.joinCall); - }, [props.call.channelId, props.callChannelName, props.currentChannelName, props.confirmToJoin, props.actions.joinCall]); + }; if (props.alreadyInTheCall) { return null; @@ -96,32 +112,39 @@ const JoinCall = (props: Props) => { }, [props.call.participants]); return ( - - - {'Join Call'} - - + + - - - {'Call participants'} - } - /> - - + {'Join Call'} + {props.isCloudLimitRestricted ? + + {'Participant limit reached'} + : + + + + } + + {'Call participants'} + } + /> + + + ); }; export default injectIntl(JoinCall); diff --git a/app/products/calls/connection.ts b/app/products/calls/connection.ts index 1c70e3900..3c681f966 100644 --- a/app/products/calls/connection.ts +++ b/app/products/calls/connection.ts @@ -20,7 +20,7 @@ export let client: any = null; const websocketConnectTimeout = 3000; -export async function newClient(channelID: string, closeCb: () => void, setScreenShareURL: (url: string) => void) { +export async function newClient(channelID: string, iceServers: string[], closeCb: () => void, setScreenShareURL: (url: string) => void) { let peer: Peer | null = null; let stream: MediaStream; let voiceTrackAdded = false; @@ -119,16 +119,8 @@ export async function newClient(channelID: string, closeCb: () => void, setScree }); ws.on('join', async () => { - let config; - try { - config = await Client4.getCallsConfig(); - } catch (err) { - console.log('ERROR FETCHING CALLS CONFIG:', err); // eslint-disable-line no-console - return; - } - InCallManager.start({media: 'audio'}); - peer = new Peer(null, config.ICEServers); + peer = new Peer(null, iceServers); peer.on('signal', (data: any) => { if (data.type === 'offer' || data.type === 'answer') { ws.send('sdp', { diff --git a/app/products/calls/store/actions/calls.test.js b/app/products/calls/store/actions/calls.test.js index 884027706..d09dfdc99 100644 --- a/app/products/calls/store/actions/calls.test.js +++ b/app/products/calls/store/actions/calls.test.js @@ -117,11 +117,14 @@ describe('Actions.Calls', () => { expect(CallsActions.ws.disconnect).not.toBeCalled(); const disconnectMock = CallsActions.ws.disconnect; await store.dispatch(CallsActions.leaveCall()); + + // ws.disconnect calls the callback, which is what sends the CallsTypes.RECEIVED_MYSELF_LEFT_CALL action. expect(disconnectMock).toBeCalled(); + await store.dispatch({type: CallsTypes.RECEIVED_MYSELF_LEFT_CALL}); expect(CallsActions.ws).toBe(null); result = store.getState().entities.calls.joined; - assert.equal('', result); + assert.equal(result, ''); }); it('muteMyself', async () => { diff --git a/app/products/calls/store/actions/calls.ts b/app/products/calls/store/actions/calls.ts index bdd3ab597..3f79d772b 100644 --- a/app/products/calls/store/actions/calls.ts +++ b/app/products/calls/store/actions/calls.ts @@ -189,7 +189,10 @@ export function joinCall(channelId: string, intl: typeof intlShape): ActionFunc dispatch(setSpeakerphoneOn(false)); try { - ws = await newClient(channelId, () => null, setScreenShareURL); + ws = await newClient(channelId, getConfig(getState()).ICEServers, () => { + dispatch(setSpeakerphoneOn(false)); + dispatch({type: CallsTypes.RECEIVED_MYSELF_LEFT_CALL}); + }, setScreenShareURL); } catch (error) { forceLogoutIfNecessary(error, dispatch, getState); dispatch(logError(error)); @@ -212,13 +215,11 @@ export function joinCall(channelId: string, intl: typeof intlShape): ActionFunc } export function leaveCall(): ActionFunc { - return async (dispatch: DispatchFunc) => { + return async () => { if (ws) { ws.disconnect(); ws = null; } - dispatch(setSpeakerphoneOn(false)); - dispatch({type: CallsTypes.RECEIVED_MYSELF_LEFT_CALL}); return {}; }; } diff --git a/app/products/calls/store/selectors/calls.ts b/app/products/calls/store/selectors/calls.ts index 88de09335..12f7f288d 100644 --- a/app/products/calls/store/selectors/calls.ts +++ b/app/products/calls/store/selectors/calls.ts @@ -1,12 +1,15 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {createSelector} from 'reselect'; + import {Client4} from '@client/rest'; import Calls from '@constants/calls'; import {getCurrentChannelId} from '@mm-redux/selectors/entities/common'; -import {getServerVersion} from '@mm-redux/selectors/entities/general'; +import {getLicense, getServerVersion} from '@mm-redux/selectors/entities/general'; import {GlobalState} from '@mm-redux/types/store'; import {isMinimumServerVersion} from '@mm-redux/utils/helpers'; +import {Call} from '@mmproducts/calls/store/types/calls'; export function getConfig(state: GlobalState) { return state.entities.calls.config; @@ -65,3 +68,49 @@ export function isSupportedServer(state: GlobalState) { export function isCallsPluginEnabled(state: GlobalState) { return state.entities.calls.pluginEnabled; } + +const isCloud: (state: GlobalState) => boolean = createSelector( + getLicense, + (license) => license?.Cloud === 'true', +); + +// NOTE: Calls v0.5.3 will not return sku_short_name in config, so this will fail +const isCloudProfessionalOrEnterprise: (state: GlobalState) => boolean = createSelector( + isCloud, + getLicense, + getConfig, + (cloud, license, config) => { + return cloud && (config.sku_short_name === 'professional' || config.sku_short_name === 'enterprise'); + }, +); + +const getCallInCurrentChannel: (state: GlobalState) => Call | undefined = createSelector( + getCurrentChannelId, + getCalls, + (currentChannelId, calls) => calls[currentChannelId], +); + +export const isCloudLimitRestricted: (state: GlobalState, channelId?: string) => boolean = createSelector( + isCloud, + isCloudProfessionalOrEnterprise, + (state: GlobalState, channelId: string) => (channelId ? getCalls(state)[channelId] : getCallInCurrentChannel(state)), + getConfig, + (cloud, isCloudPaid, call, config) => { + if (!call || !cloud) { + return false; + } + + // TODO: The next block is used for case when cloud server is using Calls v0.5.3. This can be removed + // when v0.5.4 is prepackaged in cloud. Then replace the max in the return statement with + // config.cloud_max_participants, and replace cloudPaid with isCloudPaid + let max = config.cloud_max_participants; + let cloudPaid = isCloudPaid; + if (cloud && !max) { + // We're not sure if we're in cloud paid because this could be v0.5.3, so assume we are for now (the server will prevent calls) + cloudPaid = true; + max = Calls.DefaultCloudMaxParticipants; + } + + return cloudPaid && Object.keys(call.participants || {}).length >= max; + }, +); diff --git a/app/products/calls/store/types/calls.ts b/app/products/calls/store/types/calls.ts index 1f0a0591e..2f609c86d 100644 --- a/app/products/calls/store/types/calls.ts +++ b/app/products/calls/store/types/calls.ts @@ -60,6 +60,8 @@ export type ServerConfig = { ICEServers: string[]; AllowEnableCalls: boolean; DefaultEnabled: boolean; + sku_short_name: string; + cloud_max_participants: number; last_retrieved_at: number; } @@ -67,5 +69,7 @@ export const DefaultServerConfig = { ICEServers: [], AllowEnableCalls: false, DefaultEnabled: false, + sku_short_name: '', + cloud_max_participants: 0, last_retrieved_at: 0, } as ServerConfig;