Compare commits

...

4 Commits

Author SHA1 Message Date
Mattermost Build
4b42271960 Bump version to 1.53.0 Build 404 (#6345) (#6347)
* Bump app version number to  1.53.0

* Bump app build number to  404

(cherry picked from commit cb1773bda5)

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
2022-06-03 11:33:45 -04:00
Mattermost Build
1e4e59b537 [MM-44651] Implement MaxCallParticipants config setting (#6334) (#6339)
* Implement MaxCallParticipants config setting

* Add test

(cherry picked from commit bb655c8c60)

Co-authored-by: Claudio Costa <cstcld91@gmail.com>
2022-06-03 09:05:57 +02:00
Mattermost Build
fe29459906 [MM-44155] Handle call_end event (#6316) (#6332)
* Handle call_end event

* exit call screen on call end; /call end for mobile

* handle permissions before sending cmd to server; handle error

Co-authored-by: Christopher Poile <cpoile@gmail.com>
(cherry picked from commit 23509cbb83)

Co-authored-by: Claudio Costa <cstcld91@gmail.com>
2022-06-02 13:50:07 +02:00
Mattermost Build
8985791f40 MM-44546 -- Calls: Cloud freemium limits (#6318) (#6331)
* 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

(cherry picked from commit c74cd14713)

Co-authored-by: Christopher Poile <cpoile@gmail.com>
2022-06-01 19:32:16 -04:00
34 changed files with 626 additions and 228 deletions

View File

@@ -131,8 +131,8 @@ android {
applicationId "com.mattermost.rnbeta"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 398
versionName "1.52.0"
versionCode 404
versionName "1.53.0"
multiDexEnabled = true
testBuildType System.getProperty('testBuildType', 'debug')
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'

View File

@@ -27,6 +27,7 @@ import {removeUserFromList} from '@mm-redux/utils/user_utils';
import {batchLoadCalls} from '@mmproducts/calls/store/actions/calls';
import {
handleCallStarted,
handleCallEnded,
handleCallUserConnected,
handleCallUserDisconnected,
handleCallUserMuted,
@@ -473,6 +474,8 @@ function handleEvent(msg: WebSocketMessage) {
break;
case WebsocketEvents.CALLS_CALL_START:
return dispatch(handleCallStarted(msg));
case WebsocketEvents.CALLS_CALL_END:
return dispatch(handleCallEnded(msg));
case WebsocketEvents.CALLS_SCREEN_ON:
return dispatch(handleCallScreenOn(msg));
case WebsocketEvents.CALLS_SCREEN_OFF:

View File

@@ -63,6 +63,7 @@ export default class DraftInput extends PureComponent {
channelMemberCountsByGroup: PropTypes.object,
groupsWithAllowReference: PropTypes.object,
addRecentUsedEmojisInMessage: PropTypes.func.isRequired,
endCallAlert: PropTypes.func.isRequired,
};
static defaultProps = {
@@ -296,6 +297,12 @@ export default class DraftInput extends PureComponent {
const {intl} = this.context;
const {channelId, executeCommand, rootId, userIsOutOfOffice, theme} = this.props;
if (msg.trim() === '/call end') {
this.props.endCallAlert(channelId);
// NOTE: fallthrough because the server may want to handle the command as well
}
const status = DraftUtils.getStatusFromSlashCommand(msg);
if (userIsOutOfOffice && DraftUtils.isStatusSlashCommand(status)) {
confirmOutOfOfficeDisabled(intl, status, this.updateStatus);

View File

@@ -18,6 +18,7 @@ import {getAssociatedGroupsForReferenceMap} from '@mm-redux/selectors/entities/g
import {getTheme} from '@mm-redux/selectors/entities/preferences';
import {haveIChannelPermission} from '@mm-redux/selectors/entities/roles';
import {getCurrentUserId, getStatusForUserId} from '@mm-redux/selectors/entities/users';
import {endCallAlert} from '@mmproducts/calls/store/actions/calls';
import {isLandscape} from '@selectors/device';
import {getCurrentChannelDraft, getThreadDraft} from '@selectors/views';
@@ -103,6 +104,7 @@ const mapDispatchToProps = {
setStatus,
getChannelMemberCountsByGroup,
addRecentUsedEmojisInMessage,
endCallAlert,
};
export default connect(mapStateToProps, mapDispatchToProps, null, {forwardRef: true})(PostDraft);

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

@@ -65,6 +65,7 @@ const WebsocketEvents = {
CALLS_USER_VOICE_ON: `custom_${Calls.PluginId}_user_voice_on`,
CALLS_USER_VOICE_OFF: `custom_${Calls.PluginId}_user_voice_off`,
CALLS_CALL_START: `custom_${Calls.PluginId}_call_start`,
CALLS_CALL_END: `custom_${Calls.PluginId}_call_end`,
CALLS_SCREEN_ON: `custom_${Calls.PluginId}_user_screen_on`,
CALLS_SCREEN_OFF: `custom_${Calls.PluginId}_user_screen_off`,
CALLS_USER_RAISE_HAND: `custom_${Calls.PluginId}_user_raise_hand`,

View File

@@ -9,6 +9,7 @@ export interface ClientCallsMix {
getCallsConfig: () => Promise<ServerConfig>;
enableChannelCalls: (channelId: string) => Promise<ServerChannelState>;
disableChannelCalls: (channelId: string) => Promise<ServerChannelState>;
endCall: (channelId: string) => Promise<any>;
}
const ClientCalls = (superclass: any) => class extends superclass {
@@ -51,6 +52,13 @@ const ClientCalls = (superclass: any) => class extends superclass {
{method: 'post', body: JSON.stringify({enabled: false})},
);
};
endCall = async (channelId: string) => {
return this.doFetch(
`${this.getCallsRoute()}/calls/${channelId}/end`,
{method: 'post'},
);
};
};
export default ClientCalls;

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;
isLimitRestricted: 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,
isLimitRestricted,
}: CallMessageProps) => {
const style = getStyleSheet(theme);
const joinHandler = useCallback(() => {
if (alreadyInTheCall) {
const joinHandler = () => {
if (alreadyInTheCall || isLimitRestricted) {
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, isLimitRestricted && style.joinCallButtonRestricted]}
onPress={joinHandler}
>
<CompassIcon
name='phone-outline'
size={16}
style={style.joinCallButtonIcon}
style={[style.joinCallButtonIcon, isLimitRestricted && style.joinCallButtonIconRestricted]}
/>
{alreadyInTheCall &&
<Text
style={style.joinCallButtonText}
>{'Current call'}</Text>}
{!alreadyInTheCall &&
<Text
style={style.joinCallButtonText}
>{'Join call'}</Text>}
<Text style={[style.joinCallButtonText, isLimitRestricted && 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, isLimitRestricted} 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 : '',
isLimitRestricted: isLimitRestricted(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, isLimitRestricted} 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 limitRestricted = useSelector(isLimitRestricted);
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 || limitRestricted) {
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, isLimitRestricted} 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 : '',
isLimitRestricted: isLimitRestricted(state),
};
}

View File

@@ -38,6 +38,7 @@ describe('JoinCall', () => {
alreadyInTheCall: false,
currentChannelName: 'Current Channel',
callChannelName: 'Call Channel',
isLimitRestricted: 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;
isLimitRestricted: 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.isLimitRestricted) {
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.isLimitRestricted && 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.isLimitRestricted ?
<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

@@ -4,6 +4,7 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import {deflate} from 'pako/lib/deflate.js';
import {DeviceEventEmitter, EmitterSubscription} from 'react-native';
import InCallManager from 'react-native-incall-manager';
import {
MediaStream,
@@ -12,6 +13,7 @@ import {
} from 'react-native-webrtc';
import {Client4} from '@client/rest';
import {WebsocketEvents} from '@constants';
import Peer from './simple-peer';
import WebSocketClient from './websocket';
@@ -20,12 +22,13 @@ 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;
let voiceTrack: MediaStreamTrack | null = null;
let isClosed = false;
let onCallEnd: EmitterSubscription | null = null;
const streams: MediaStream[] = [];
try {
@@ -47,6 +50,11 @@ export async function newClient(channelID: string, closeCb: () => void, setScree
ws.close();
}
if (onCallEnd) {
onCallEnd.remove();
onCallEnd = null;
}
streams.forEach((s) => {
s.getTracks().forEach((track: MediaStreamTrack) => {
track.stop();
@@ -65,6 +73,12 @@ export async function newClient(channelID: string, closeCb: () => void, setScree
}
};
onCallEnd = DeviceEventEmitter.addListener(WebsocketEvents.CALLS_CALL_END, ({channelId}) => {
if (channelId === channelID) {
disconnect();
}
});
const mute = () => {
if (!peer) {
return;
@@ -119,16 +133,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

@@ -323,6 +323,16 @@ const CallScreen = (props: Props) => {
setShowControlsInLandscape(!showControlsInLandscape);
}, [showControlsInLandscape]);
useEffect(() => {
const listener = DeviceEventEmitter.addListener(WebsocketEvents.CALLS_CALL_END, ({channelId}) => {
if (channelId === props.call?.channelId) {
popTopScreen();
}
});
return () => listener.remove();
}, []);
if (!props.call) {
return null;
}

View File

@@ -6,6 +6,7 @@ import keyMirror from '@mm-redux/utils/key_mirror';
export default keyMirror({
RECEIVED_CALLS: null,
RECEIVED_CALL_STARTED: null,
RECEIVED_CALL_ENDED: null,
RECEIVED_CALL_FINISHED: null,
RECEIVED_CHANNEL_CALL_ENABLED: null,
RECEIVED_CHANNEL_CALL_DISABLED: null,

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

@@ -2,6 +2,7 @@
// See LICENSE.txt for license information.
import {intlShape} from 'react-intl';
import {Alert} from 'react-native';
import InCallManager from 'react-native-incall-manager';
import {batch} from 'react-redux';
@@ -9,6 +10,10 @@ 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 {General} from '@mm-redux/constants';
import {getCurrentChannel} from '@mm-redux/selectors/entities/channels';
import {getTeammateNameDisplaySetting} from '@mm-redux/selectors/entities/preferences';
import {getCurrentUserId, getCurrentUserRoles, getUser} from '@mm-redux/selectors/entities/users';
import {
GenericAction,
ActionFunc,
@@ -17,10 +22,16 @@ import {
ActionResult,
} from '@mm-redux/types/actions';
import {Dictionary} from '@mm-redux/types/utilities';
import {displayUsername, isAdmin as checkIsAdmin} from '@mm-redux/utils/user_utils';
import {newClient} from '@mmproducts/calls/connection';
import CallsTypes from '@mmproducts/calls/store/action_types/calls';
import {getConfig} from '@mmproducts/calls/store/selectors/calls';
import {
getCallInCurrentChannel,
getConfig,
getNumCurrentConnectedParticipants,
} from '@mmproducts/calls/store/selectors/calls';
import {Call, CallParticipant, DefaultServerConfig} from '@mmproducts/calls/store/types/calls';
import {getUserIdFromDM} from '@mmproducts/calls/utils';
import {hasMicrophonePermission} from '@utils/permission';
export let ws: any = null;
@@ -82,6 +93,7 @@ export function loadCalls(): ActionFunc {
speakers: [],
screenOn: channel.call.screen_sharing_id,
threadId: channel.call.thread_id,
creatorId: channel.call.creator_id,
};
}
enabledChannels[channel.channel_id] = channel.enabled;
@@ -189,7 +201,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 +227,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 {};
};
}
@@ -260,3 +273,51 @@ export function setSpeakerphoneOn(newState: boolean): GenericAction {
data: newState,
};
}
export function endCallAlert(channelId: string): ActionFunc {
return async (dispatch: DispatchFunc, getState: GetStateFunc) => {
const userId = getCurrentUserId(getState());
const numParticipants = getNumCurrentConnectedParticipants(getState());
const channel = getCurrentChannel(getState());
const currentCall = getCallInCurrentChannel(getState());
const roles = getCurrentUserRoles(getState());
const isAdmin = checkIsAdmin(roles);
if (!isAdmin && userId !== currentCall?.creatorId) {
Alert.alert('Error', 'You do not have permission to end the call. Please ask the call creator to end call.');
return {};
}
let msg = `Are you sure you want to end a call with ${numParticipants} participants in ${channel.display_name}?`;
if (channel.type === General.DM_CHANNEL) {
const otherID = getUserIdFromDM(channel.name, getCurrentUserId(getState()));
const otherUser = getUser(getState(), otherID);
const nameDisplay = getTeammateNameDisplaySetting(getState());
msg = `Are you sure you want to end a call with ${displayUsername(otherUser, nameDisplay)}?`;
}
Alert.alert(
'End call',
msg,
[
{
text: 'Cancel',
},
{
text: 'End call',
onPress: async () => {
try {
await Client4.endCall(channelId);
} catch (e) {
const err = e.message || 'unable to complete command, see server logs';
Alert.alert('Error', `Error: ${err}`);
}
},
style: 'cancel',
},
],
);
return {};
};
}

View File

@@ -50,7 +50,15 @@ export function handleCallUserVoiceOff(msg: WebSocketMessage) {
export function handleCallStarted(msg: WebSocketMessage): GenericAction {
return {
type: CallsTypes.RECEIVED_CALL_STARTED,
data: {channelId: msg.data.channelID, startTime: msg.data.start_at, threadId: msg.data.thread_id, participants: {}},
data: {channelId: msg.data.channelID, startTime: msg.data.start_at, threadId: msg.data.thread_id, participants: {}, creatorId: msg.data.creator_id},
};
}
export function handleCallEnded(msg: WebSocketMessage): GenericAction {
DeviceEventEmitter.emit(WebsocketEvents.CALLS_CALL_END, {channelId: msg.broadcast.channel_id});
return {
type: CallsTypes.RECEIVED_CALL_ENDED,
data: {channelId: msg.broadcast.channel_id},
};
}

View File

@@ -145,6 +145,16 @@ describe('Reducers.calls.calls', () => {
assert.deepEqual(state.calls, {'channel-2': call2});
});
it('RECEIVED_CALL_ENDED', async () => {
const initialState = {calls: {'channel-1': call1, 'channel-2': call2}};
const testAction = {
type: CallsTypes.RECEIVED_CALL_FINISHED,
data: {channelId: 'channel-1'},
};
const state = callsReducer(initialState, testAction);
assert.deepEqual(state.calls, {'channel-2': call2});
});
it('RECEIVED_MUTE_USER_CALL', async () => {
const initialState = {calls: {'channel-1': call1, 'channel-2': call2}};
const testAction = {

View File

@@ -53,6 +53,11 @@ function calls(state: Dictionary<Call> = {}, action: GenericAction) {
nextState[newCall.channelId] = newCall;
return nextState;
}
case CallsTypes.RECEIVED_CALL_ENDED: {
const nextState = {...state};
delete nextState[action.data.channelId];
return nextState;
}
case CallsTypes.RECEIVED_CALL_FINISHED: {
const newCall = action.data;
const nextState = {...state};
@@ -162,6 +167,12 @@ function joined(state = '', action: GenericAction) {
case CallsTypes.RECEIVED_MYSELF_JOINED_CALL: {
return action.data;
}
case CallsTypes.RECEIVED_CALL_ENDED: {
if (action.data.channelId === state) {
return '';
}
return state;
}
case CallsTypes.RECEIVED_MYSELF_LEFT_CALL: {
return '';
}

View File

@@ -4,11 +4,12 @@
import assert from 'assert';
import deepFreezeAndThrowOnMutation from '@mm-redux/utils/deep_freeze';
import {DefaultServerConfig} from '@mmproducts/calls/store/types/calls';
import * as Selectors from './calls';
describe('Selectors.Calls', () => {
const call1 = {id: 'call1'};
const call1 = {id: 'call1', participants: [{id: 'me'}]};
const call2 = {id: 'call2'};
const testState = deepFreezeAndThrowOnMutation({
entities: {
@@ -20,6 +21,10 @@ describe('Selectors.Calls', () => {
joined: 'call1',
enabled: {'channel-1': true, 'channel-2': false},
screenShareURL: 'screenshare-url',
config: DefaultServerConfig,
},
general: {
license: {},
},
},
});
@@ -77,4 +82,119 @@ describe('Selectors.Calls', () => {
it('getScreenShareURL', () => {
assert.equal(Selectors.getScreenShareURL(testState), 'screenshare-url');
});
it('isLimitRestricted', () => {
// Default, no limit
assert.equal(Selectors.isLimitRestricted(testState, 'call1'), false);
let newState = {
...testState,
entities: {
...testState.entities,
calls: {
...testState.entities.calls,
config: {
...testState.entities.calls.config,
MaxCallParticipants: 1,
},
},
},
};
// Limit to 1 and one participant already in call.
assert.equal(Selectors.isLimitRestricted(newState, 'call1'), true);
// Limit to 1 but no call ongoing.
assert.equal(Selectors.isLimitRestricted(newState), false);
newState = {
...testState,
entities: {
...testState.entities,
general: {
license: {Cloud: 'true'},
},
},
};
// On cloud, no limit.
assert.equal(Selectors.isLimitRestricted(newState, 'call1'), false);
newState = {
...testState,
entities: {
...testState.entities,
calls: {
...testState.entities.calls,
config: {
...testState.entities.calls.config,
MaxCallParticipants: 1,
},
},
general: {
license: {Cloud: 'true'},
},
},
};
// On cloud, with limit.
assert.equal(Selectors.isLimitRestricted(newState, 'call1'), true);
const call = {id: 'call1',
participants: [
{},
{},
{},
{},
{},
{},
{},
]};
newState = {
...testState,
entities: {
...testState.entities,
calls: {
...testState.entities.calls,
calls: {call1: call},
},
general: {
license: {Cloud: 'true'},
},
},
};
delete newState.entities.calls.config.MaxCallParticipants;
// On cloud, MaxCallParticipants missing, default should be used.
assert.equal(Selectors.isLimitRestricted(newState, 'call1'), false);
const newCall = {id: 'call1',
participants: [
{},
{},
{},
{},
{},
{},
{},
{},
]};
newState = {
...testState,
entities: {
...testState.entities,
calls: {
...testState.entities.calls,
calls: {call1: newCall},
},
general: {
license: {Cloud: 'true'},
},
},
};
delete newState.entities.calls.config.MaxCallParticipants;
// On cloud, MaxCallParticipants missing, default should be used.
assert.equal(Selectors.isLimitRestricted(newState, 'call1'), true);
});
});

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;
}
export const getCallInCurrentChannel: (state: GlobalState) => Call | undefined = createSelector(
getCurrentChannelId,
getCalls,
(currentChannelId, calls) => calls[currentChannelId],
);
export const getNumCurrentConnectedParticipants: (state: GlobalState) => number = createSelector(
getCurrentChannelId,
getCalls,
(currentChannelId, calls) => {
const participants = calls[currentChannelId]?.participants;
if (!participants) {
return 0;
}
return Object.keys(participants).length || 0;
},
);
const isCloud: (state: GlobalState) => boolean = createSelector(
getLicense,
(license) => license?.Cloud === 'true',
);
export const isLimitRestricted: (state: GlobalState, channelId?: string) => boolean = createSelector(
isCloud,
(state: GlobalState, channelId: string) => (channelId ? getCalls(state)[channelId] : getCallInCurrentChannel(state)),
getConfig,
(cloud, call, config) => {
if (!call) {
return false;
}
const numParticipants = Object.keys(call.participants || {}).length;
// 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.MaxCallParticipants.
let max = config.MaxCallParticipants;
if (cloud && !max) {
max = Calls.DefaultCloudMaxParticipants;
}
return max !== 0 && numParticipants >= max;
},
);

View File

@@ -15,12 +15,13 @@ export type CallsState = {
}
export type Call = {
participants: Dictionary<CallParticipant>;
participants: Dictionary<CallParticipant>;
channelId: string;
startTime: number;
speakers: string[];
screenOn: string;
threadId: string;
creatorId: string;
}
export type CallParticipant = {
@@ -49,6 +50,7 @@ export type ServerCallState = {
states: ServerUserState[];
thread_id: string;
screen_sharing_id: string;
creator_id: string;
}
export type VoiceEventData = {
@@ -60,6 +62,8 @@ export type ServerConfig = {
ICEServers: string[];
AllowEnableCalls: boolean;
DefaultEnabled: boolean;
MaxCallParticipants: number;
sku_short_name: string;
last_retrieved_at: number;
}
@@ -67,5 +71,7 @@ export const DefaultServerConfig = {
ICEServers: [],
AllowEnableCalls: false,
DefaultEnabled: false,
MaxCallParticipants: 0,
sku_short_name: '',
last_retrieved_at: 0,
} as ServerConfig;

View File

@@ -48,3 +48,14 @@ const sortByState = (presenterID?: string) => {
return 0;
};
};
export function getUserIdFromDM(dmName: string, currentUserId: string) {
const ids = dmName.split('__');
let otherUserId = '';
if (ids[0] === currentUserId) {
otherUserId = ids[1];
} else {
otherUserId = ids[0];
}
return otherUserId;
}

View File

@@ -911,7 +911,7 @@
CODE_SIGN_ENTITLEMENTS = Mattermost/Mattermost.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CURRENT_PROJECT_VERSION = 398;
CURRENT_PROJECT_VERSION = 404;
DEAD_CODE_STRIPPING = NO;
DEVELOPMENT_TEAM = UQ8HT4Q2XM;
ENABLE_BITCODE = NO;
@@ -953,7 +953,7 @@
CODE_SIGN_ENTITLEMENTS = Mattermost/Mattermost.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CURRENT_PROJECT_VERSION = 398;
CURRENT_PROJECT_VERSION = 404;
DEAD_CODE_STRIPPING = NO;
DEVELOPMENT_TEAM = UQ8HT4Q2XM;
ENABLE_BITCODE = NO;

View File

@@ -21,7 +21,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.52.0</string>
<string>1.53.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
@@ -37,7 +37,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>398</string>
<string>404</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key>

View File

@@ -19,9 +19,9 @@
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>1.52.0</string>
<string>1.53.0</string>
<key>CFBundleVersion</key>
<string>398</string>
<string>404</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>

View File

@@ -19,9 +19,9 @@
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>1.52.0</string>
<string>1.53.0</string>
<key>CFBundleVersion</key>
<string>398</string>
<string>404</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>

View File

@@ -733,7 +733,7 @@ SPEC CHECKSUMS:
FBLazyVector: 244195e30d63d7f564c55da4410b9a24e8fbceaa
FBReactNativeSpec: c94002c1d93da3658f4d5119c6994d19961e3d52
fmt: ff9d55029c625d3757ed641535fd4a75fedc7ce9
glog: 5337263514dd6f09803962437687240c5dc39aa4
glog: 85ecdd10ee8d8ec362ef519a6a45ff9aa27b2e85
HMSegmentedControl: 34c1f54d822d8308e7b24f5d901ec674dfa31352
iosMath: f7a6cbadf9d836d2149c2a84c435b1effc244cba
jail-monkey: 07b83767601a373db876e939b8dbf3f5eb15f073
@@ -746,7 +746,7 @@ SPEC CHECKSUMS:
Permission-Notifications: bb420c3d28328df24de1b476b41ed8249ccf2537
Permission-PhotoLibrary: 7bec836dcdd04a0bfb200c314f1aae06d4476357
Permission-PhotoLibraryAddOnly: 06fb0cdb1d35683b235ad8c464ef0ecc88859ea3
RCT-Folly: a21c126816d8025b547704b777a2ba552f3d9fa9
RCT-Folly: 803a9cfd78114b2ec0f140cfa6fa2a6bafb2d685
RCTRequired: cd47794163052d2b8318c891a7a14fcfaccc75ab
RCTTypeSafety: 393bb40b3e357b224cde53d3fec26813c52428b1
RCTYouTube: a8bb45705622a6fc9decf64be04128d3658ed411
@@ -824,4 +824,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: 8214414d5676358401d8ad51dff19e7fd8c71b5c
COCOAPODS: 1.11.2
COCOAPODS: 1.11.3

2
package-lock.json generated
View File

@@ -1,6 +1,6 @@
{
"name": "mattermost-mobile",
"version": "1.52.0",
"version": "1.53.0",
"lockfileVersion": 2,
"requires": true,
"packages": {

View File

@@ -1,6 +1,6 @@
{
"name": "mattermost-mobile",
"version": "1.52.0",
"version": "1.53.0",
"description": "Mattermost Mobile with React Native",
"repository": "git@github.com:mattermost/mattermost-mobile.git",
"author": "Mattermost, Inc.",