MM-47004 - Calls: Client-side errors for microphone permissions (#6669)

* call error bar for microphone permissions; global permissions state

* i18n

* refactor permissionErrorBar component, PR comments

* add module dependency's mocks for tests

* fix error bar height

* change permissions error text

* working on 46999 redo audio handling -- will revert

* Revert "working on 46999 redo audio handling -- will revert"

This reverts commit 87bafc452c6ad6e1d7ae79ce78a0f2b461c2f150.

* only get voice track when we have mic permissions

* Android: enable mic when permissions are granted
This commit is contained in:
Christopher Poile 2022-11-08 10:52:52 -05:00 committed by GitHub
parent cc5331f2ba
commit 8374d7e87f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 533 additions and 157 deletions

View file

@ -23,6 +23,7 @@ export const SEARCH_INPUT_MARGIN = 5;
export const JOIN_CALL_BAR_HEIGHT = 38;
export const CURRENT_CALL_BAR_HEIGHT = 74;
export const CALL_ERROR_BAR_HEIGHT = 62;
export const QUICK_OPTIONS_HEIGHT = 270;

View file

@ -139,7 +139,7 @@ describe('Actions.Calls', () => {
let response: { data?: string };
await act(async () => {
response = await CallsActions.joinCall('server1', 'channel-id');
response = await CallsActions.joinCall('server1', 'channel-id', true);
userJoinedCall('server1', 'channel-id', 'myUserId');
});
@ -163,7 +163,7 @@ describe('Actions.Calls', () => {
let response: { data?: string };
await act(async () => {
response = await CallsActions.joinCall('server1', 'channel-id');
response = await CallsActions.joinCall('server1', 'channel-id', true);
userJoinedCall('server1', 'channel-id', 'myUserId');
});
assert.equal(response!.data, 'channel-id');
@ -191,7 +191,7 @@ describe('Actions.Calls', () => {
let response: { data?: string };
await act(async () => {
response = await CallsActions.joinCall('server1', 'channel-id');
response = await CallsActions.joinCall('server1', 'channel-id', true);
userJoinedCall('server1', 'channel-id', 'myUserId');
});
assert.equal(response!.data, 'channel-id');
@ -218,7 +218,7 @@ describe('Actions.Calls', () => {
let response: { data?: string };
await act(async () => {
response = await CallsActions.joinCall('server1', 'channel-id');
response = await CallsActions.joinCall('server1', 'channel-id', true);
userJoinedCall('server1', 'channel-id', 'myUserId');
});
assert.equal(response!.data, 'channel-id');

View file

@ -218,7 +218,7 @@ export const enableChannelCalls = async (serverUrl: string, channelId: string, e
return {};
};
export const joinCall = async (serverUrl: string, channelId: string): Promise<{ error?: string | Error; data?: string }> => {
export const joinCall = async (serverUrl: string, channelId: string, hasMicPermission: boolean): Promise<{ error?: string | Error; data?: string }> => {
// Edge case: calls was disabled when app loaded, and then enabled, but app hasn't
// reconnected its websocket since then (i.e., hasn't called batchLoadCalls yet)
const {data: enabled} = await checkIsCallsPluginEnabled(serverUrl);
@ -233,7 +233,7 @@ export const joinCall = async (serverUrl: string, channelId: string): Promise<{
setSpeakerphoneOn(false);
try {
connection = await newConnection(serverUrl, channelId, () => null, setScreenShareURL);
connection = await newConnection(serverUrl, channelId, () => null, setScreenShareURL, hasMicPermission);
} catch (error: unknown) {
await forceLogoutIfNecessary(serverUrl, error as ClientError);
return {error: error as Error};
@ -270,6 +270,12 @@ export const unmuteMyself = () => {
}
};
export const initializeVoiceTrack = () => {
if (connection) {
connection.initializeVoiceTrack();
}
};
export const raiseHand = () => {
if (connection) {
connection.raiseHand();

View file

@ -1,28 +1,10 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Alert, Platform} from 'react-native';
import DeviceInfo from 'react-native-device-info';
import {Platform} from 'react-native';
import Permissions from 'react-native-permissions';
import type {IntlShape} from 'react-intl';
const getMicrophonePermissionDeniedMessage = (intl: IntlShape) => {
const {formatMessage} = intl;
const applicationName = DeviceInfo.getApplicationName();
return {
title: formatMessage({
id: 'mobile.microphone_permission_denied_title',
defaultMessage: '{applicationName} would like to access your microphone',
}, {applicationName}),
text: formatMessage({
id: 'mobile.microphone_permission_denied_description',
defaultMessage: 'To participate in this call, open Settings to grant Mattermost access to your microphone.',
}),
};
};
export const hasMicrophonePermission = async (intl: IntlShape) => {
export const hasMicrophonePermission = async () => {
const targetSource = Platform.select({
ios: Permissions.PERMISSIONS.IOS.MICROPHONE,
default: Permissions.PERMISSIONS.ANDROID.RECORD_AUDIO,
@ -36,32 +18,8 @@ export const hasMicrophonePermission = async (intl: IntlShape) => {
return permissionRequest === Permissions.RESULTS.GRANTED;
}
case Permissions.RESULTS.BLOCKED: {
const grantOption = {
text: intl.formatMessage({
id: 'mobile.permission_denied_retry',
defaultMessage: 'Settings',
}),
onPress: () => Permissions.openSettings(),
};
const {title, text} = getMicrophonePermissionDeniedMessage(intl);
Alert.alert(
title,
text,
[
grantOption,
{
text: intl.formatMessage({
id: 'mobile.permission_denied_dismiss',
defaultMessage: 'Don\'t Allow',
}),
},
],
);
case Permissions.RESULTS.BLOCKED:
return false;
}
}
return true;

View file

@ -4,6 +4,7 @@
import {Alert} from 'react-native';
import {hasMicrophonePermission, joinCall, unmuteMyself} from '@calls/actions';
import {setMicPermissionsGranted} from '@calls/state';
import {errorAlert} from '@calls/utils';
import type {IntlShape} from 'react-intl';
@ -89,16 +90,10 @@ export const leaveAndJoinWithAlert = (
const doJoinCall = async (serverUrl: string, channelId: string, isDMorGM: boolean, intl: IntlShape) => {
const {formatMessage} = intl;
const hasPermission = await hasMicrophonePermission(intl);
if (!hasPermission) {
errorAlert(formatMessage({
id: 'mobile.calls_error_permissions',
defaultMessage: 'No permissions to microphone, unable to start call',
}), intl);
return;
}
const hasPermission = await hasMicrophonePermission();
setMicPermissionsGranted(hasPermission);
const res = await joinCall(serverUrl, channelId);
const res = await joinCall(serverUrl, channelId, hasPermission);
if (res.error) {
const seeLogs = formatMessage({id: 'mobile.calls_see_logs', defaultMessage: 'See server logs'});
errorAlert(res.error?.toString() || seeLogs, intl);

View file

@ -8,6 +8,9 @@ import {Options} from 'react-native-navigation';
import {muteMyself, unmuteMyself} from '@calls/actions';
import CallAvatar from '@calls/components/call_avatar';
import PermissionErrorBar from '@calls/components/permission_error_bar';
import UnavailableIconWrapper from '@calls/components/unavailable_icon_wrapper';
import {usePermissionsChecker} from '@calls/hooks';
import {CurrentCall} from '@calls/types/calls';
import CompassIcon from '@components/compass_icon';
import {Screens} from '@constants';
@ -24,6 +27,7 @@ type Props = {
currentCall: CurrentCall | null;
userModelsDict: Dictionary<UserModel>;
teammateNameDisplay: string;
micPermissionsGranted: boolean;
threadScreen?: boolean;
}
@ -57,18 +61,18 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
color: theme.sidebarText,
opacity: 0.64,
},
micIcon: {
color: theme.sidebarText,
micIconContainer: {
width: 42,
height: 42,
textAlign: 'center',
textAlignVertical: 'center',
justifyContent: 'center',
backgroundColor: '#3DB887',
alignItems: 'center',
backgroundColor: theme.onlineIndicator,
borderRadius: 4,
margin: 4,
padding: 9,
overflow: 'hidden',
},
micIcon: {
color: theme.sidebarText,
},
muted: {
backgroundColor: 'transparent',
@ -86,10 +90,13 @@ const CurrentCallBar = ({
currentCall,
userModelsDict,
teammateNameDisplay,
micPermissionsGranted,
threadScreen,
}: Props) => {
const theme = useTheme();
const style = getStyleSheet(theme);
const {formatMessage} = useIntl();
usePermissionsChecker(micPermissionsGranted);
const goToCallScreen = useCallback(async () => {
const options: Options = {
@ -134,42 +141,48 @@ const CurrentCallBar = ({
}
};
const style = getStyleSheet(theme);
const micPermissionsError = !micPermissionsGranted && !currentCall?.micPermissionsErrorDismissed;
return (
<View style={style.wrapper}>
<View style={style.container}>
<CallAvatar
userModel={userModelsDict[speaker || '']}
volume={speaker ? 0.5 : 0}
serverUrl={currentCall?.serverUrl || ''}
/>
<View style={style.userInfo}>
<Text style={style.speakingUser}>{talkingMessage}</Text>
<Text style={style.currentChannel}>{`~${displayName}`}</Text>
<>
<View style={style.wrapper}>
<View style={style.container}>
<CallAvatar
userModel={userModelsDict[speaker || '']}
volume={speaker ? 0.5 : 0}
serverUrl={currentCall?.serverUrl || ''}
/>
<View style={style.userInfo}>
<Text style={style.speakingUser}>{talkingMessage}</Text>
<Text style={style.currentChannel}>{`~${displayName}`}</Text>
</View>
<Pressable
onPressIn={goToCallScreen}
style={style.pressable}
>
<CompassIcon
name='arrow-expand'
size={24}
style={style.expandIcon}
/>
</Pressable>
<TouchableOpacity
onPress={muteUnmute}
style={[style.pressable, style.micIconContainer, myParticipant?.muted && style.muted]}
disabled={!micPermissionsGranted}
>
<UnavailableIconWrapper
name={myParticipant?.muted ? 'microphone-off' : 'microphone'}
size={24}
unavailable={!micPermissionsGranted}
style={[style.micIcon]}
/>
</TouchableOpacity>
</View>
<Pressable
onPressIn={goToCallScreen}
style={style.pressable}
>
<CompassIcon
name='arrow-expand'
size={24}
style={style.expandIcon}
/>
</Pressable>
<TouchableOpacity
onPress={muteUnmute}
style={style.pressable}
>
<CompassIcon
name={myParticipant?.muted ? 'microphone-off' : 'microphone'}
size={24}
style={[style.micIcon, myParticipant?.muted ? style.muted : undefined]}
/>
</TouchableOpacity>
</View>
</View>
{micPermissionsError && <PermissionErrorBar/>}
</>
);
};
export default CurrentCallBar;

View file

@ -5,7 +5,7 @@ import withObservables from '@nozbe/with-observables';
import {combineLatest, of as of$} from 'rxjs';
import {distinctUntilChanged, switchMap} from 'rxjs/operators';
import {observeCurrentCall} from '@calls/state';
import {observeCurrentCall, observeGlobalCallsState} from '@calls/state';
import {idsAreEqual} from '@calls/utils';
import DatabaseManager from '@database/manager';
import {observeChannel} from '@queries/servers/channel';
@ -45,12 +45,17 @@ const enhanced = withObservables([], () => {
const teammateNameDisplay = database.pipe(
switchMap((db) => (db ? observeTeammateNameDisplay(db) : of$(''))),
);
const micPermissionsGranted = observeGlobalCallsState().pipe(
switchMap((gs) => of$(gs.micPermissionsGranted)),
distinctUntilChanged(),
);
return {
displayName,
currentCall,
userModelsDict,
teammateNameDisplay,
micPermissionsGranted,
};
});

View file

@ -0,0 +1,104 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Pressable, View} from 'react-native';
import Permissions from 'react-native-permissions';
import {setMicPermissionsErrorDismissed} from '@calls/state';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import {CALL_ERROR_BAR_HEIGHT} from '@constants/view';
import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => (
{
pressable: {
zIndex: 10,
},
errorWrapper: {
padding: 10,
paddingTop: 0,
},
errorBar: {
flexDirection: 'row',
backgroundColor: theme.dndIndicator,
minHeight: CALL_ERROR_BAR_HEIGHT,
width: '100%',
borderRadius: 5,
padding: 10,
alignItems: 'center',
},
errorText: {
flex: 1,
...typography('Body', 100, 'SemiBold'),
color: theme.buttonColor,
},
errorIconContainer: {
width: 42,
height: 42,
justifyContent: 'center',
alignItems: 'center',
borderRadius: 4,
margin: 0,
padding: 9,
},
pressedErrorIconContainer: {
backgroundColor: theme.buttonColor,
},
errorIcon: {
color: theme.buttonColor,
fontSize: 18,
},
pressedErrorIcon: {
color: theme.dndIndicator,
},
paddingRight: {
paddingRight: 9,
},
}
));
const PermissionErrorBar = () => {
const theme = useTheme();
const style = getStyleSheet(theme);
return (
<View style={style.errorWrapper}>
<Pressable
onPress={Permissions.openSettings}
style={style.errorBar}
>
<CompassIcon
name='microphone-off'
style={[style.errorIcon, style.paddingRight]}
/>
<FormattedText
id={'mobile.calls_mic_error'}
defaultMessage={'To participate, open Settings to grant Mattermost access to your microphone.'}
style={style.errorText}
/>
<Pressable
onPress={setMicPermissionsErrorDismissed}
hitSlop={5}
style={({pressed}) => [
style.pressable,
style.errorIconContainer,
pressed && style.pressedErrorIconContainer,
]}
>
{({pressed}) => (
<CompassIcon
name='close'
style={[style.errorIcon, pressed && style.pressedErrorIcon]}
/>
)}
</Pressable>
</Pressable>
</View>
);
};
export default PermissionErrorBar;

View file

@ -0,0 +1,68 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {StyleProp, TextStyle, View} from 'react-native';
import CompassIcon from '@components/compass_icon';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
type Props = {
name: string;
size: number;
style: StyleProp<TextStyle>;
unavailable: boolean;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
position: 'relative',
},
unavailable: {
color: changeOpacity(theme.sidebarText, 0.32),
},
errorContainer: {
position: 'absolute',
right: 0,
backgroundColor: '#3F4350',
justifyContent: 'center',
alignItems: 'center',
borderWidth: 0.5,
borderColor: '#3F4350',
},
errorIcon: {
color: theme.dndIndicator,
},
};
});
const UnavailableIconWrapper = ({name, size, style: providedStyle, unavailable}: Props) => {
const theme = useTheme();
const style = getStyleSheet(theme);
const errorIconSize = size / 2;
return (
<View style={style.container}>
<CompassIcon
name={name}
size={size}
style={[providedStyle, unavailable && style.unavailable]}
/>
{unavailable &&
<View
style={[style.errorContainer, {borderRadius: errorIconSize / 2}]}
>
<CompassIcon
name={'close-circle'}
size={errorIconSize}
style={style.errorIcon}
/>
</View>
}
</View>
);
};
export default UnavailableIconWrapper;

View file

@ -25,7 +25,13 @@ import type {CallsConnection} from '@calls/types/calls';
const peerConnectTimeout = 5000;
export async function newConnection(serverUrl: string, channelID: string, closeCb: () => void, setScreenShareURL: (url: string) => void) {
export async function newConnection(
serverUrl: string,
channelID: string,
closeCb: () => void,
setScreenShareURL: (url: string) => void,
hasMicPermission: boolean,
) {
let peer: Peer | null = null;
let stream: MediaStream;
let voiceTrackAdded = false;
@ -34,17 +40,23 @@ export async function newConnection(serverUrl: string, channelID: string, closeC
let onCallEnd: EmitterSubscription | null = null;
const streams: MediaStream[] = [];
try {
stream = await mediaDevices.getUserMedia({
video: false,
audio: true,
}) as MediaStream;
voiceTrack = stream.getAudioTracks()[0];
voiceTrack.enabled = false;
streams.push(stream);
} catch (err) {
logError('Unable to get media device:', err);
}
const initializeVoiceTrack = async () => {
if (voiceTrack) {
return;
}
try {
stream = await mediaDevices.getUserMedia({
video: false,
audio: true,
}) as MediaStream;
voiceTrack = stream.getAudioTracks()[0];
voiceTrack.enabled = false;
streams.push(stream);
} catch (err) {
logError('Unable to get media device:', err);
}
};
// getClient can throw an error, which will be handled by the caller.
const client = NetworkManager.getClient(serverUrl);
@ -56,6 +68,10 @@ export async function newConnection(serverUrl: string, channelID: string, closeC
// Throws an error, to be caught by caller.
await ws.initialize();
if (hasMicPermission) {
initializeVoiceTrack();
}
const disconnect = () => {
if (isClosed) {
return;
@ -265,6 +281,7 @@ export async function newConnection(serverUrl: string, channelID: string, closeC
waitForPeerConnection,
raiseHand,
unraiseHand,
initializeVoiceTrack,
};
return connection;

View file

@ -3,14 +3,18 @@
// Check if calls is enabled. If it is, then run fn; if it isn't, show an alert and set
// msgPostfix to ' (Not Available)'.
import {useCallback, useState} from 'react';
import {useCallback, useEffect, useState} from 'react';
import {useIntl} from 'react-intl';
import {Alert} from 'react-native';
import {Alert, Platform} from 'react-native';
import Permissions from 'react-native-permissions';
import {initializeVoiceTrack} from '@calls/actions/calls';
import {setMicPermissionsGranted} from '@calls/state';
import {errorAlert} from '@calls/utils';
import {Client} from '@client/rest';
import ClientError from '@client/rest/error';
import {useServerUrl} from '@context/server';
import {useAppState} from '@hooks/device';
import NetworkManager from '@managers/network_manager';
export const useTryCallsFunction = (fn: () => void) => {
@ -71,3 +75,27 @@ export const useTryCallsFunction = (fn: () => void) => {
return [tryFn, msgPostfix] as [() => Promise<void>, string];
};
const micPermission = Platform.select({
ios: Permissions.PERMISSIONS.IOS.MICROPHONE,
default: Permissions.PERMISSIONS.ANDROID.RECORD_AUDIO,
});
export const usePermissionsChecker = (micPermissionsGranted: boolean) => {
const appState = useAppState();
useEffect(() => {
const asyncFn = async () => {
if (appState === 'active') {
const hasPermission = (await Permissions.check(micPermission)) === Permissions.RESULTS.GRANTED;
if (hasPermission) {
initializeVoiceTrack();
setMicPermissionsGranted(hasPermission);
}
}
};
if (!micPermissionsGranted) {
asyncFn();
}
}, [appState]);
};

View file

@ -28,6 +28,9 @@ import {
} from '@calls/actions';
import CallAvatar from '@calls/components/call_avatar';
import CallDuration from '@calls/components/call_duration';
import PermissionErrorBar from '@calls/components/permission_error_bar';
import UnavailableIconWrapper from '@calls/components/unavailable_icon_wrapper';
import {usePermissionsChecker} from '@calls/hooks';
import RaisedHandIcon from '@calls/icons/raised_hand_icon';
import UnraisedHandIcon from '@calls/icons/unraised_hand_icon';
import {CallParticipant, CurrentCall} from '@calls/types/calls';
@ -48,13 +51,14 @@ import {
import NavigationStore from '@store/navigation_store';
import {bottomSheetSnapPoint} from '@utils/helpers';
import {mergeNavigationOptions} from '@utils/navigation';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {displayUsername} from '@utils/user';
export type Props = {
componentId: string;
currentCall: CurrentCall | null;
participantsDict: Dictionary<CallParticipant>;
micPermissionsGranted: boolean;
teammateNameDisplay: string;
fromThreadScreen?: boolean;
}
@ -252,19 +256,31 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
color: 'white',
margin: 3,
},
unavailableText: {
color: changeOpacity(theme.sidebarText, 0.32),
},
}));
const CallScreen = ({componentId, currentCall, participantsDict, teammateNameDisplay, fromThreadScreen}: Props) => {
const CallScreen = ({
componentId,
currentCall,
participantsDict,
micPermissionsGranted,
teammateNameDisplay,
fromThreadScreen,
}: Props) => {
const intl = useIntl();
const theme = useTheme();
const insets = useSafeAreaInsets();
const {width, height} = useWindowDimensions();
usePermissionsChecker(micPermissionsGranted);
const [showControlsInLandscape, setShowControlsInLandscape] = useState(false);
const style = getStyleSheet(theme);
const isLandscape = width > height;
const showControls = !isLandscape || showControlsInLandscape;
const myParticipant = currentCall?.participants[currentCall.myUserId];
const micPermissionsError = !micPermissionsGranted && !currentCall?.micPermissionsErrorDismissed;
const chatThreadTitle = intl.formatMessage({id: 'mobile.calls_chat_thread', defaultMessage: 'Chat thread'});
useEffect(() => {
@ -463,7 +479,7 @@ const CallScreen = ({componentId, currentCall, participantsDict, teammateNameDis
<FormattedText
id={'mobile.calls_unmute'}
defaultMessage={'Unmute'}
style={style.buttonText}
style={[style.buttonText, !micPermissionsGranted && style.unavailableText]}
/>);
return (
@ -487,6 +503,7 @@ const CallScreen = ({componentId, currentCall, participantsDict, teammateNameDis
</View>
{usersList}
{screenShareView}
{micPermissionsError && <PermissionErrorBar/>}
<View
style={[style.buttons, isLandscape && style.buttonsLandscape, !showControls && style.buttonsLandscapeNoControls]}
>
@ -495,10 +512,12 @@ const CallScreen = ({componentId, currentCall, participantsDict, teammateNameDis
testID='mute-unmute'
style={[style.mute, myParticipant.muted && style.muteMuted]}
onPress={muteUnmuteHandler}
disabled={!micPermissionsGranted}
>
<CompassIcon
<UnavailableIconWrapper
name={myParticipant.muted ? 'microphone-off' : 'microphone'}
size={24}
unavailable={!micPermissionsGranted}
style={style.muteIcon}
/>
{myParticipant.muted ? UnmuteText : MuteText}

View file

@ -6,7 +6,7 @@ import {combineLatest, of as of$} from 'rxjs';
import {distinctUntilChanged, switchMap} from 'rxjs/operators';
import CallScreen from '@calls/screens/call_screen/call_screen';
import {observeCurrentCall} from '@calls/state';
import {observeCurrentCall, observeGlobalCallsState} from '@calls/state';
import {CallParticipant} from '@calls/types/calls';
import DatabaseManager from '@database/manager';
import {observeTeammateNameDisplay, queryUsersById} from '@queries/servers/user';
@ -34,6 +34,10 @@ const enhanced = withObservables([], () => {
}, {} as Dictionary<CallParticipant>))),
)),
);
const micPermissionsGranted = observeGlobalCallsState().pipe(
switchMap((gs) => of$(gs.micPermissionsGranted)),
distinctUntilChanged(),
);
const teammateNameDisplay = database.pipe(
switchMap((db) => (db ? observeTeammateNameDisplay(db) : of$(''))),
distinctUntilChanged(),
@ -42,6 +46,7 @@ const enhanced = withObservables([], () => {
return {
currentCall,
participantsDict,
micPermissionsGranted,
teammateNameDisplay,
};
});

View file

@ -9,10 +9,12 @@ import {
setCallsState,
setChannelsWithCalls,
setCurrentCall,
setMicPermissionsErrorDismissed,
setMicPermissionsGranted,
useCallsConfig,
useCallsState,
useChannelsWithCalls,
useCurrentCall,
useCurrentCall, useGlobalCallsState,
} from '@calls/state';
import {
setCalls,
@ -34,9 +36,18 @@ import {
} from '@calls/state/actions';
import {License} from '@constants';
import {CallsState, CurrentCall, DefaultCallsConfig, DefaultCallsState} from '../types/calls';
import {
Call,
CallsState,
CurrentCall,
DefaultCallsConfig,
DefaultCallsState,
DefaultCurrentCall,
DefaultGlobalCallsState,
GlobalCallsState,
} from '../types/calls';
const call1 = {
const call1: Call = {
participants: {
'user-1': {id: 'user-1', muted: false, raisedHand: 0},
'user-2': {id: 'user-2', muted: true, raisedHand: 0},
@ -47,7 +58,7 @@ const call1 = {
threadId: 'thread-1',
ownerId: 'user-1',
};
const call2 = {
const call2: Call = {
participants: {
'user-3': {id: 'user-3', muted: false, raisedHand: 0},
'user-4': {id: 'user-4', muted: true, raisedHand: 0},
@ -58,7 +69,7 @@ const call2 = {
threadId: 'thread-2',
ownerId: 'user-3',
};
const call3 = {
const call3: Call = {
participants: {
'user-5': {id: 'user-5', muted: false, raisedHand: 0},
'user-6': {id: 'user-6', muted: true, raisedHand: 0},
@ -109,12 +120,10 @@ describe('useCallsState', () => {
'channel-1': true,
};
const initialCurrentCallState: CurrentCall = {
...DefaultCurrentCall,
serverUrl: 'server1',
myUserId: 'myUserId',
...call1,
screenShareURL: '',
speakerphoneOn: false,
voiceOn: {},
};
const testNewCall1 = {
...call1,
@ -181,13 +190,12 @@ describe('useCallsState', () => {
const initialChannelsWithCallsState = {
'channel-1': true,
};
const initialCurrentCallState: CurrentCall = {
...DefaultCurrentCall,
serverUrl: 'server1',
myUserId: 'myUserId',
...call1,
screenShareURL: '',
speakerphoneOn: false,
voiceOn: {},
};
const expectedCallsState = {
'channel-1': {
@ -242,12 +250,10 @@ describe('useCallsState', () => {
'channel-1': true,
};
const initialCurrentCallState: CurrentCall = {
...DefaultCurrentCall,
serverUrl: 'server1',
myUserId: 'myUserId',
...call1,
screenShareURL: '',
speakerphoneOn: false,
voiceOn: {},
};
const expectedCallsState = {
'channel-1': {
@ -345,12 +351,10 @@ describe('useCallsState', () => {
};
const initialChannelsWithCallsState = {'channel-1': true, 'channel-2': true};
const initialCurrentCallState: CurrentCall = {
...DefaultCurrentCall,
serverUrl: 'server1',
myUserId: 'myUserId',
...call1,
screenShareURL: '',
speakerphoneOn: false,
voiceOn: {},
};
// setup
@ -393,12 +397,10 @@ describe('useCallsState', () => {
};
const initialChannelsWithCallsState = {'channel-1': true, 'channel-2': true};
const initialCurrentCallState: CurrentCall = {
...DefaultCurrentCall,
serverUrl: 'server1',
myUserId: 'myUserId',
...call1,
screenShareURL: '',
speakerphoneOn: false,
voiceOn: {},
};
// setup
@ -452,12 +454,10 @@ describe('useCallsState', () => {
},
};
const initialCurrentCallState: CurrentCall = {
...DefaultCurrentCall,
serverUrl: 'server1',
myUserId: 'myUserId',
...call1,
screenShareURL: '',
speakerphoneOn: false,
voiceOn: {},
};
const expectedCurrentCallState = {
...initialCurrentCallState,
@ -511,12 +511,10 @@ describe('useCallsState', () => {
},
};
const expectedCurrentCallState: CurrentCall = {
...DefaultCurrentCall,
serverUrl: 'server1',
myUserId: 'myUserId',
screenShareURL: '',
speakerphoneOn: false,
...newCall1,
voiceOn: {},
};
// setup
@ -657,6 +655,79 @@ describe('useCallsState', () => {
assert.deepEqual(result.current[1], null);
});
it('MicPermissions', () => {
const initialGlobalState = DefaultGlobalCallsState;
const initialCallsState: CallsState = {
...DefaultCallsState,
myUserId: 'myUserId',
calls: {'channel-1': call1, 'channel-2': call2},
};
const newCall1: Call = {
...call1,
participants: {
...call1.participants,
myUserId: {id: 'myUserId', muted: true, raisedHand: 0},
},
};
const expectedCallsState: CallsState = {
...initialCallsState,
calls: {
...initialCallsState.calls,
'channel-1': newCall1,
},
};
const expectedCurrentCallState: CurrentCall = {
...DefaultCurrentCall,
serverUrl: 'server1',
myUserId: 'myUserId',
...newCall1,
};
const secondExpectedCurrentCallState: CurrentCall = {
...expectedCurrentCallState,
micPermissionsErrorDismissed: true,
};
const expectedGlobalState: GlobalCallsState = {
micPermissionsGranted: true,
};
// setup
const {result} = renderHook(() => {
return [useCallsState('server1'), useCurrentCall(), useGlobalCallsState()];
});
act(() => setCallsState('server1', initialCallsState));
assert.deepEqual(result.current[0], initialCallsState);
assert.deepEqual(result.current[1], null);
assert.deepEqual(result.current[2], initialGlobalState);
// join call
act(() => {
setMicPermissionsGranted(false);
userJoinedCall('server1', 'channel-1', 'myUserId');
});
assert.deepEqual(result.current[0], expectedCallsState);
assert.deepEqual(result.current[1], expectedCurrentCallState);
assert.deepEqual(result.current[2], initialGlobalState);
// dismiss mic error
act(() => setMicPermissionsErrorDismissed());
assert.deepEqual(result.current[0], expectedCallsState);
assert.deepEqual(result.current[1], secondExpectedCurrentCallState);
assert.deepEqual(result.current[2], initialGlobalState);
// grant permissions
act(() => setMicPermissionsGranted(true));
assert.deepEqual(result.current[0], expectedCallsState);
assert.deepEqual(result.current[1], secondExpectedCurrentCallState);
assert.deepEqual(result.current[2], expectedGlobalState);
act(() => {
myselfLeftCall();
userLeftCall('server1', 'channel-1', 'myUserId');
});
assert.deepEqual(result.current[0], initialCallsState);
assert.deepEqual(result.current[1], null);
});
it('voiceOn and Off', () => {
const initialCallsState = {
...DefaultCallsState,
@ -665,12 +736,10 @@ describe('useCallsState', () => {
calls: {'channel-1': call1, 'channel-2': call2},
};
const initialCurrentCallState: CurrentCall = {
...DefaultCurrentCall,
serverUrl: 'server1',
myUserId: 'myUserId',
...call1,
screenShareURL: '',
speakerphoneOn: false,
voiceOn: {},
};
// setup

View file

@ -6,10 +6,12 @@ import {
getCallsState,
getChannelsWithCalls,
getCurrentCall,
getGlobalCallsState,
setCallsConfig,
setCallsState,
setChannelsWithCalls,
setCurrentCall,
setGlobalCallsState,
} from '@calls/state';
import {Call, CallsConfig, ChannelsWithCalls} from '@calls/types/calls';
@ -116,6 +118,7 @@ export const userJoinedCall = (serverUrl: string, channelId: string, userId: str
screenShareURL: '',
speakerphoneOn: false,
voiceOn: {},
micPermissionsErrorDismissed: false,
});
}
};
@ -363,3 +366,26 @@ export const setPluginEnabled = (serverUrl: string, pluginEnabled: boolean) => {
const callsConfig = getCallsConfig(serverUrl);
setCallsConfig(serverUrl, {...callsConfig, pluginEnabled});
};
export const setMicPermissionsGranted = (granted: boolean) => {
const globalState = getGlobalCallsState();
const nextGlobalState = {
...globalState,
micPermissionsGranted: granted,
};
setGlobalCallsState(nextGlobalState);
};
export const setMicPermissionsErrorDismissed = () => {
const currentCall = getCurrentCall();
if (!currentCall) {
return;
}
const nextCurrentCall = {
...currentCall,
micPermissionsErrorDismissed: true,
};
setCurrentCall(nextCurrentCall);
};

View file

@ -0,0 +1,38 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useEffect, useState} from 'react';
import {BehaviorSubject} from 'rxjs';
import {DefaultGlobalCallsState, GlobalCallsState} from '@calls/types/calls';
const globalStateSubject = new BehaviorSubject(DefaultGlobalCallsState);
export const getGlobalCallsState = () => {
return globalStateSubject.value;
};
export const setGlobalCallsState = (globalState: GlobalCallsState) => {
globalStateSubject.next(globalState);
};
export const observeGlobalCallsState = () => {
return globalStateSubject.asObservable();
};
export const useGlobalCallsState = () => {
const [state, setState] = useState<GlobalCallsState>(DefaultGlobalCallsState);
useEffect(() => {
const subscription = globalStateSubject.subscribe((globalState) => {
setState(globalState);
});
return () => {
subscription?.unsubscribe();
};
}, []);
return state;
};

View file

@ -6,3 +6,4 @@ export * from './calls_state';
export * from './calls_config';
export * from './current_call';
export * from './channels_with_calls';
export * from './global_calls_state';

View file

@ -4,6 +4,14 @@
import type UserModel from '@typings/database/models/servers/user';
import type {ConfigurationParamWithUrls, ConfigurationParamWithUrl} from 'react-native-webrtc';
export type GlobalCallsState = {
micPermissionsGranted: boolean;
}
export const DefaultGlobalCallsState: GlobalCallsState = {
micPermissionsGranted: false,
};
export type CallsState = {
serverUrl: string;
myUserId: string;
@ -11,12 +19,12 @@ export type CallsState = {
enabled: Dictionary<boolean>;
}
export const DefaultCallsState = {
export const DefaultCallsState: CallsState = {
serverUrl: '',
myUserId: '',
calls: {} as Dictionary<Call>,
enabled: {} as Dictionary<boolean>,
} as CallsState;
};
export type Call = {
participants: Dictionary<CallParticipant>;
@ -46,8 +54,23 @@ export type CurrentCall = {
screenShareURL: string;
speakerphoneOn: boolean;
voiceOn: Dictionary<boolean>;
micPermissionsErrorDismissed: boolean;
}
export const DefaultCurrentCall: CurrentCall = {
serverUrl: '',
myUserId: '',
participants: {},
channelId: '',
startTime: 0,
screenOn: '',
threadId: '',
screenShareURL: '',
speakerphoneOn: false,
voiceOn: {},
micPermissionsErrorDismissed: false,
};
export type CallParticipant = {
id: string;
muted: boolean;
@ -90,6 +113,7 @@ export type CallsConnection = {
waitForPeerConnection: () => Promise<void>;
raiseHand: () => void;
unraiseHand: () => void;
initializeVoiceTrack: () => void;
}
export type ServerCallsConfig = {
@ -107,7 +131,7 @@ export type CallsConfig = ServerCallsConfig & {
last_retrieved_at: number;
}
export const DefaultCallsConfig = {
export const DefaultCallsConfig: CallsConfig = {
pluginEnabled: false,
ICEServers: [], // deprecated
ICEServersConfigs: [],
@ -117,7 +141,7 @@ export const DefaultCallsConfig = {
last_retrieved_at: 0,
sku_short_name: '',
MaxCallParticipants: 0,
} as CallsConfig;
};
export type ICEServersConfigs = Array<ConfigurationParamWithUrls | ConfigurationParamWithUrl>;

View file

@ -368,7 +368,6 @@
"mobile.calls_end_permission_title": "Error",
"mobile.calls_ended_at": "Ended at",
"mobile.calls_error_message": "Error: {error}",
"mobile.calls_error_permissions": "No permissions to microphone, unable to start call",
"mobile.calls_error_title": "Error",
"mobile.calls_join_call": "Join call",
"mobile.calls_lasted": "Lasted {duration}",
@ -377,6 +376,7 @@
"mobile.calls_limit_msg": "The maximum number of participants per call is {maxParticipants}. Contact your System Admin to increase the limit.",
"mobile.calls_limit_reached": "Participant limit reached",
"mobile.calls_lower_hand": "Lower hand",
"mobile.calls_mic_error": "To participate, open Settings to grant Mattermost access to your microphone.",
"mobile.calls_more": "More",
"mobile.calls_mute": "Mute",
"mobile.calls_name_is_talking": "{name} is talking",
@ -482,8 +482,6 @@
"mobile.message_length.message": "Your current message is too long. Current character count: {count}/{max}",
"mobile.message_length.message_split_left": "Message exceeds the character limit",
"mobile.message_length.title": "Message Length",
"mobile.microphone_permission_denied_description": "To participate in this call, open Settings to grant Mattermost access to your microphone.",
"mobile.microphone_permission_denied_title": "{applicationName} would like to access your microphone",
"mobile.no_results_with_term": "No results for “{term}”",
"mobile.no_results_with_term.files": "No files matching “{term}”",
"mobile.no_results_with_term.messages": "No matches found for “{term}”",
@ -548,12 +546,9 @@
"mobile.screen.settings": "Settings",
"mobile.screen.your_profile": "Your Profile",
"mobile.search.jump": "Jump to recent messages",
"mobile.search.modifier.after": "after a date",
"mobile.search.modifier.before": "before a date",
"mobile.search.modifier.exclude": "exclude search terms",
"mobile.search.modifier.from": "a specific user",
"mobile.search.modifier.in": "a specific channel",
"mobile.search.modifier.on": "a specific date",
"mobile.search.modifier.phrases": "messages with phrases",
"mobile.search.show_less": "Show less",
"mobile.search.show_more": "Show more",

View file

@ -128,6 +128,10 @@ jest.doMock('react-native', () => {
},
}),
},
WebRTCModule: {
senderGetCapabilities: jest.fn().mockReturnValue(null),
receiverGetCapabilities: jest.fn().mockReturnValue(null),
},
};
const Linking = {