From 8374d7e87f7a591487d6ceb48f182071fb089c0f Mon Sep 17 00:00:00 2001 From: Christopher Poile Date: Tue, 8 Nov 2022 10:52:52 -0500 Subject: [PATCH] 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 --- app/constants/view.ts | 1 + app/products/calls/actions/calls.test.ts | 8 +- app/products/calls/actions/calls.ts | 10 +- app/products/calls/actions/permissions.ts | 48 +------ app/products/calls/alerts.ts | 13 +- .../current_call_bar/current_call_bar.tsx | 89 ++++++------ .../components/current_call_bar/index.ts | 7 +- .../calls/components/permission_error_bar.tsx | 104 ++++++++++++++ .../components/unavailable_icon_wrapper.tsx | 68 ++++++++++ app/products/calls/connection/connection.ts | 41 ++++-- app/products/calls/hooks.ts | 32 ++++- .../calls/screens/call_screen/call_screen.tsx | 27 +++- .../calls/screens/call_screen/index.ts | 7 +- app/products/calls/state/actions.test.ts | 127 ++++++++++++++---- app/products/calls/state/actions.ts | 26 ++++ .../calls/state/global_calls_state.ts | 38 ++++++ app/products/calls/state/index.ts | 1 + app/products/calls/types/calls.ts | 32 ++++- assets/base/i18n/en.json | 7 +- test/setup.ts | 4 + 20 files changed, 533 insertions(+), 157 deletions(-) create mode 100644 app/products/calls/components/permission_error_bar.tsx create mode 100644 app/products/calls/components/unavailable_icon_wrapper.tsx create mode 100644 app/products/calls/state/global_calls_state.ts diff --git a/app/constants/view.ts b/app/constants/view.ts index e4a3a9f98..c1ef40d8a 100644 --- a/app/constants/view.ts +++ b/app/constants/view.ts @@ -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; diff --git a/app/products/calls/actions/calls.test.ts b/app/products/calls/actions/calls.test.ts index 0222f9b1c..9352ccb75 100644 --- a/app/products/calls/actions/calls.test.ts +++ b/app/products/calls/actions/calls.test.ts @@ -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'); diff --git a/app/products/calls/actions/calls.ts b/app/products/calls/actions/calls.ts index 29080d781..8f3972cb6 100644 --- a/app/products/calls/actions/calls.ts +++ b/app/products/calls/actions/calls.ts @@ -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(); diff --git a/app/products/calls/actions/permissions.ts b/app/products/calls/actions/permissions.ts index 9789d0071..a4ac3709d 100644 --- a/app/products/calls/actions/permissions.ts +++ b/app/products/calls/actions/permissions.ts @@ -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; diff --git a/app/products/calls/alerts.ts b/app/products/calls/alerts.ts index 2d1ebd23c..37b4bb10f 100644 --- a/app/products/calls/alerts.ts +++ b/app/products/calls/alerts.ts @@ -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); diff --git a/app/products/calls/components/current_call_bar/current_call_bar.tsx b/app/products/calls/components/current_call_bar/current_call_bar.tsx index 1594a0ba5..3a6bb1f78 100644 --- a/app/products/calls/components/current_call_bar/current_call_bar.tsx +++ b/app/products/calls/components/current_call_bar/current_call_bar.tsx @@ -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; 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 ( - - - - - {talkingMessage} - {`~${displayName}`} + <> + + + + + {talkingMessage} + {`~${displayName}`} + + + + + + + - - - - - - - + {micPermissionsError && } + ); }; + export default CurrentCallBar; diff --git a/app/products/calls/components/current_call_bar/index.ts b/app/products/calls/components/current_call_bar/index.ts index 1ddce1122..925110f5a 100644 --- a/app/products/calls/components/current_call_bar/index.ts +++ b/app/products/calls/components/current_call_bar/index.ts @@ -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, }; }); diff --git a/app/products/calls/components/permission_error_bar.tsx b/app/products/calls/components/permission_error_bar.tsx new file mode 100644 index 000000000..66a0ba567 --- /dev/null +++ b/app/products/calls/components/permission_error_bar.tsx @@ -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 ( + + + + + [ + style.pressable, + style.errorIconContainer, + pressed && style.pressedErrorIconContainer, + ]} + > + {({pressed}) => ( + + )} + + + + ); +}; + +export default PermissionErrorBar; diff --git a/app/products/calls/components/unavailable_icon_wrapper.tsx b/app/products/calls/components/unavailable_icon_wrapper.tsx new file mode 100644 index 000000000..077dcf03f --- /dev/null +++ b/app/products/calls/components/unavailable_icon_wrapper.tsx @@ -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; + 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 ( + + + {unavailable && + + + + } + + ); +}; + +export default UnavailableIconWrapper; diff --git a/app/products/calls/connection/connection.ts b/app/products/calls/connection/connection.ts index 692a1809a..fec4a6dbc 100644 --- a/app/products/calls/connection/connection.ts +++ b/app/products/calls/connection/connection.ts @@ -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; diff --git a/app/products/calls/hooks.ts b/app/products/calls/hooks.ts index 99f6e9749..cc2b88f7f 100644 --- a/app/products/calls/hooks.ts +++ b/app/products/calls/hooks.ts @@ -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, 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]); +}; diff --git a/app/products/calls/screens/call_screen/call_screen.tsx b/app/products/calls/screens/call_screen/call_screen.tsx index ec0a1421b..b89fce9a6 100644 --- a/app/products/calls/screens/call_screen/call_screen.tsx +++ b/app/products/calls/screens/call_screen/call_screen.tsx @@ -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; + 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 ); return ( @@ -487,6 +503,7 @@ const CallScreen = ({componentId, currentCall, participantsDict, teammateNameDis {usersList} {screenShareView} + {micPermissionsError && } @@ -495,10 +512,12 @@ const CallScreen = ({componentId, currentCall, participantsDict, teammateNameDis testID='mute-unmute' style={[style.mute, myParticipant.muted && style.muteMuted]} onPress={muteUnmuteHandler} + disabled={!micPermissionsGranted} > - {myParticipant.muted ? UnmuteText : MuteText} diff --git a/app/products/calls/screens/call_screen/index.ts b/app/products/calls/screens/call_screen/index.ts index efcbf5dce..ed6845fd2 100644 --- a/app/products/calls/screens/call_screen/index.ts +++ b/app/products/calls/screens/call_screen/index.ts @@ -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))), )), ); + 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, }; }); diff --git a/app/products/calls/state/actions.test.ts b/app/products/calls/state/actions.test.ts index cef8fdc12..6bb7755cc 100644 --- a/app/products/calls/state/actions.test.ts +++ b/app/products/calls/state/actions.test.ts @@ -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 diff --git a/app/products/calls/state/actions.ts b/app/products/calls/state/actions.ts index 32c8b8261..d0b2462b3 100644 --- a/app/products/calls/state/actions.ts +++ b/app/products/calls/state/actions.ts @@ -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); +}; diff --git a/app/products/calls/state/global_calls_state.ts b/app/products/calls/state/global_calls_state.ts new file mode 100644 index 000000000..94ae637d5 --- /dev/null +++ b/app/products/calls/state/global_calls_state.ts @@ -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(DefaultGlobalCallsState); + + useEffect(() => { + const subscription = globalStateSubject.subscribe((globalState) => { + setState(globalState); + }); + + return () => { + subscription?.unsubscribe(); + }; + }, []); + + return state; +}; + diff --git a/app/products/calls/state/index.ts b/app/products/calls/state/index.ts index b44d4f8eb..ae83ecc61 100644 --- a/app/products/calls/state/index.ts +++ b/app/products/calls/state/index.ts @@ -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'; diff --git a/app/products/calls/types/calls.ts b/app/products/calls/types/calls.ts index 7a7a6dce7..8bd41ac5e 100644 --- a/app/products/calls/types/calls.ts +++ b/app/products/calls/types/calls.ts @@ -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; } -export const DefaultCallsState = { +export const DefaultCallsState: CallsState = { serverUrl: '', myUserId: '', calls: {} as Dictionary, enabled: {} as Dictionary, -} as CallsState; +}; export type Call = { participants: Dictionary; @@ -46,8 +54,23 @@ export type CurrentCall = { screenShareURL: string; speakerphoneOn: boolean; voiceOn: Dictionary; + 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; 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; diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index de610b01a..a9499ae31 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -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", diff --git a/test/setup.ts b/test/setup.ts index f19657af8..0e3e55bdc 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -128,6 +128,10 @@ jest.doMock('react-native', () => { }, }), }, + WebRTCModule: { + senderGetCapabilities: jest.fn().mockReturnValue(null), + receiverGetCapabilities: jest.fn().mockReturnValue(null), + }, }; const Linking = {