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
This commit is contained in:
Christopher Poile 2022-06-01 19:21:10 -04:00 committed by GitHub
parent 1d299d02f0
commit c74cd14713
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 339 additions and 209 deletions

View file

@ -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};

View file

@ -57,31 +57,40 @@ exports[`CallMessage should match snapshot 1`] = `
<TouchableOpacity
onPress={[Function]}
style={
Object {
"alignContent": "center",
"alignItems": "center",
"backgroundColor": "#339970",
"borderRadius": 8,
"flexDirection": "row",
"padding": 12,
}
Array [
Object {
"alignContent": "center",
"alignItems": "center",
"backgroundColor": "#339970",
"borderRadius": 8,
"flexDirection": "row",
"padding": 12,
},
undefined,
]
}
>
<CompassIcon
name="phone-outline"
size={16}
style={
Object {
"color": "white",
"marginRight": 5,
}
Array [
Object {
"color": "white",
"marginRight": 5,
},
undefined,
]
}
/>
<Text
style={
Object {
"color": "white",
}
Array [
Object {
"color": "white",
},
undefined,
]
}
>
Join call
@ -239,31 +248,40 @@ exports[`CallMessage should match snapshot for the call already in the current c
<TouchableOpacity
onPress={[Function]}
style={
Object {
"alignContent": "center",
"alignItems": "center",
"backgroundColor": "#339970",
"borderRadius": 8,
"flexDirection": "row",
"padding": 12,
}
Array [
Object {
"alignContent": "center",
"alignItems": "center",
"backgroundColor": "#339970",
"borderRadius": 8,
"flexDirection": "row",
"padding": 12,
},
undefined,
]
}
>
<CompassIcon
name="phone-outline"
size={16}
style={
Object {
"color": "white",
"marginRight": 5,
}
Array [
Object {
"color": "white",
"marginRight": 5,
},
undefined,
]
}
/>
<Text
style={
Object {
"color": "white",
}
Array [
Object {
"color": "white",
},
undefined,
]
}
>
Current call

View file

@ -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 (
<View style={style.messageStyle}>
<CompassIcon
@ -161,22 +187,17 @@ const CallMessage = ({post, user, teammateNameDisplay, confirmToJoin, alreadyInT
</View>
<TouchableOpacity
style={style.joinCallButton}
style={[style.joinCallButton, isCloudLimitRestricted && style.joinCallButtonRestricted]}
onPress={joinHandler}
>
<CompassIcon
name='phone-outline'
size={16}
style={style.joinCallButtonIcon}
style={[style.joinCallButtonIcon, isCloudLimitRestricted && style.joinCallButtonIconRestricted]}
/>
{alreadyInTheCall &&
<Text
style={style.joinCallButtonText}
>{'Current call'}</Text>}
{!alreadyInTheCall &&
<Text
style={style.joinCallButtonText}
>{'Join call'}</Text>}
<Text style={[style.joinCallButtonText, isCloudLimitRestricted && style.joinCallButtonTextRestricted]}>
{joinCallButtonText}
</Text>
</TouchableOpacity>
</View>
);

View file

@ -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),
};
}

View file

@ -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;
}

View file

@ -1,89 +1,100 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`JoinCall should match snapshot 1`] = `
<Pressable
onPress={[Function]}
<View
style={
Object {
"alignItems": "center",
"backgroundColor": "#3DB887",
"flexDirection": "row",
"height": 38,
"justifyContent": "center",
"padding": 5,
"width": "100%",
"backgroundColor": "#ffffff",
}
}
>
<CompassIcon
name="phone-in-talk"
size={16}
<Pressable
onPress={[Function]}
style={
Object {
"color": "#ffffff",
"marginLeft": 10,
"marginRight": 5,
}
}
/>
<Text
style={
Object {
"color": "#ffffff",
"fontSize": 16,
"fontWeight": "bold",
}
Array [
Object {
"alignItems": "center",
"backgroundColor": "#3DB887",
"flexDirection": "row",
"height": 38,
"justifyContent": "center",
"padding": 5,
"width": "100%",
},
false,
]
}
>
Join Call
</Text>
<Text
style={
Object {
"color": "#ffffff",
"flex": 1,
"fontWeight": "400",
"marginLeft": 10,
<CompassIcon
name="phone-in-talk"
size={16}
style={
Object {
"color": "#ffffff",
"marginLeft": 10,
"marginRight": 5,
}
}
}
>
<FormattedRelativeTime
updateIntervalInSeconds={1}
value={100}
/>
</Text>
<View
style={
Object {
"marginRight": 5,
<Text
style={
Object {
"color": "#ffffff",
"fontSize": 16,
"fontWeight": "bold",
}
}
}
>
<Connect(Avatars)
breakAt={1}
listTitle={
<Text
style={
Object {
"color": "rgba(63,67,80,0.56)",
"fontSize": 12,
"fontWeight": "600",
"paddingHorizontal": 16,
"paddingVertical": 0,
"top": 16,
>
Join Call
</Text>
<Text
style={
Object {
"color": "#ffffff",
"flex": 1,
"fontWeight": "400",
"marginLeft": 10,
}
}
>
<FormattedRelativeTime
updateIntervalInSeconds={1}
value={100}
/>
</Text>
<View
style={
Object {
"marginRight": 5,
}
}
>
<Connect(Avatars)
breakAt={1}
listTitle={
<Text
style={
Object {
"color": "rgba(63,67,80,0.56)",
"fontSize": 12,
"fontWeight": "600",
"paddingHorizontal": 16,
"paddingVertical": 0,
"top": 16,
}
}
}
>
Call participants
</Text>
}
userIds={
Array [
"user-1-id",
"user-2-id",
]
}
/>
</View>
</Pressable>
>
Call participants
</Text>
}
userIds={
Array [
"user-1-id",
"user-2-id",
]
}
/>
</View>
</Pressable>
</View>
`;

View file

@ -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),
};
}

View file

@ -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(<JoinCall {...props}/>).dive();
const wrapper = shallowWithIntl(<JoinCall {...props}/>).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(<JoinCall {...props}/>).dive();
const wrapper = shallowWithIntl(<JoinCall {...props}/>).dive().childAt(0);
wrapper.simulate('press');
expect(Alert.alert).toHaveBeenCalled();

View file

@ -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 (
<Pressable
style={style.container}
onPress={joinHandler}
>
<CompassIcon
name='phone-in-talk'
size={16}
style={style.joinCallIcon}
/>
<Text style={style.joinCall}>{'Join Call'}</Text>
<Text style={style.started}>
<FormattedRelativeTime
value={props.call.startTime}
updateIntervalInSeconds={1}
<View style={style.outerContainer}>
<Pressable
style={[style.innerContainer, props.isCloudLimitRestricted && style.innerContainerRestricted]}
onPress={joinHandler}
>
<CompassIcon
name='phone-in-talk'
size={16}
style={style.joinCallIcon}
/>
</Text>
<View style={style.avatars}>
<Avatars
userIds={userIds}
breakAt={1}
listTitle={
<Text style={style.headerText}>{'Call participants'}</Text>
}
/>
</View>
</Pressable>
<Text style={style.joinCall}>{'Join Call'}</Text>
{props.isCloudLimitRestricted ?
<Text style={style.limitReached}>
{'Participant limit reached'}
</Text> :
<Text style={style.started}>
<FormattedRelativeTime
value={props.call.startTime}
updateIntervalInSeconds={1}
/>
</Text>
}
<View style={style.avatars}>
<Avatars
userIds={userIds}
breakAt={1}
listTitle={
<Text style={style.headerText}>{'Call participants'}</Text>
}
/>
</View>
</Pressable>
</View>
);
};
export default injectIntl(JoinCall);

View file

@ -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', {

View file

@ -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 () => {

View file

@ -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 {};
};
}

View file

@ -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;
},
);

View file

@ -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;