MM-49970 - Calls: Call quality status banner (#7304)

* use mean opinion score detection; add call quality alert bar; tests

* rename MessageBar's file to message_bar

* i18n

* use mosThreshold from calls-common; upgrade calls-common

* use callsBg for text and icon
This commit is contained in:
Christopher Poile
2023-05-04 15:28:15 -04:00
committed by GitHub
parent f8f6839945
commit c90ace706c
11 changed files with 374 additions and 130 deletions

View File

@@ -14,7 +14,21 @@ const RequiredServer = {
const PluginId = 'com.mattermost.calls';
export const REACTION_TIMEOUT = 10000;
export const REACTION_LIMIT = 20;
const REACTION_TIMEOUT = 10000;
const REACTION_LIMIT = 20;
const CALL_QUALITY_RESET_MS = toMilliseconds({minutes: 1});
export default {RequiredServer, RefreshConfigMillis, PluginId, REACTION_TIMEOUT};
export enum MessageBarType {
Microphone,
CallQuality,
}
export default {
RefreshConfigMillis,
RequiredServer,
PluginId,
REACTION_TIMEOUT,
REACTION_LIMIT,
MessageBarType,
CALL_QUALITY_RESET_MS,
};

View File

@@ -9,12 +9,13 @@ import {leaveCall, muteMyself, unmuteMyself} from '@calls/actions';
import {recordingAlert, recordingWillBePostedAlert, recordingErrorAlert} from '@calls/alerts';
import CallAvatar from '@calls/components/call_avatar';
import CallDuration from '@calls/components/call_duration';
import PermissionErrorBar from '@calls/components/permission_error_bar';
import MessageBar from '@calls/components/message_bar';
import UnavailableIconWrapper from '@calls/components/unavailable_icon_wrapper';
import {usePermissionsChecker} from '@calls/hooks';
import {setCallQualityAlertDismissed, setMicPermissionsErrorDismissed} from '@calls/state';
import {makeCallsTheme} from '@calls/utils';
import CompassIcon from '@components/compass_icon';
import {Screens} from '@constants';
import {Calls, Screens} from '@constants';
import {CURRENT_CALL_BAR_HEIGHT} from '@constants/view';
import {useTheme} from '@context/theme';
import {allOrientations, dismissAllModalsAndPopToScreen} from '@screens/navigation';
@@ -273,7 +274,18 @@ const CurrentCallBar = ({
</View>
</Pressable>
</View>
{micPermissionsError && <PermissionErrorBar/>}
{micPermissionsError &&
<MessageBar
type={Calls.MessageBarType.Microphone}
onPress={setMicPermissionsErrorDismissed}
/>
}
{currentCall?.callQualityAlert &&
<MessageBar
type={Calls.MessageBarType.CallQuality}
onPress={setCallQualityAlertDismissed}
/>
}
</>
);
};

View File

@@ -0,0 +1,152 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import {useIntl} from 'react-intl';
import {Pressable, Text, View} from 'react-native';
import Permissions from 'react-native-permissions';
import {makeCallsTheme} from '@calls/utils';
import CompassIcon from '@components/compass_icon';
import {Calls} from '@constants';
import {CALL_ERROR_BAR_HEIGHT} from '@constants/view';
import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import type {MessageBarType} from '@app/constants/calls';
import type {CallsTheme} from '@calls/types/calls';
type Props = {
type: MessageBarType;
onPress: () => void;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: CallsTheme) => (
{
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',
},
warningBar: {
backgroundColor: theme.awayIndicator,
},
errorText: {
flex: 1,
...typography('Body', 100, 'SemiBold'),
color: theme.buttonColor,
},
warningText: {
color: theme.callsBg,
},
iconContainer: {
width: 42,
height: 42,
justifyContent: 'center',
alignItems: 'center',
borderRadius: 4,
margin: 0,
padding: 9,
},
pressedIconContainer: {
backgroundColor: theme.buttonColor,
},
errorIcon: {
color: theme.buttonColor,
fontSize: 18,
},
warningIcon: {
color: theme.callsBg,
},
pressedErrorIcon: {
color: theme.dndIndicator,
},
pressedWarningIcon: {
color: theme.awayIndicator,
},
paddingRight: {
paddingRight: 9,
},
}
));
const MessageBar = ({type, onPress}: Props) => {
const intl = useIntl();
const theme = useTheme();
const callsTheme = useMemo(() => makeCallsTheme(theme), [theme]);
const style = getStyleSheet(callsTheme);
const warning = type === Calls.MessageBarType.CallQuality;
let message = '';
let icon = <></>;
switch (type) {
case Calls.MessageBarType.Microphone:
message = intl.formatMessage({
id: 'mobile.calls_mic_error',
defaultMessage: 'To participate, open Settings to grant Mattermost access to your microphone.',
});
icon = (
<CompassIcon
name='microphone-off'
style={[style.errorIcon, style.paddingRight]}
/>);
break;
case Calls.MessageBarType.CallQuality:
message = intl.formatMessage({
id: 'mobile.calls_quality_warning',
defaultMessage: 'Call quality may be degraded due to unstable network conditions.',
});
icon = (
<CompassIcon
name='alert-outline'
style={[style.errorIcon, style.warningIcon, style.paddingRight]}
/>);
break;
}
return (
<View style={style.errorWrapper}>
<Pressable
onPress={Permissions.openSettings}
style={[style.errorBar, warning && style.warningBar]}
>
{icon}
<Text style={[style.errorText, warning && style.warningText]}>{message}</Text>
<Pressable
onPress={onPress}
hitSlop={5}
style={({pressed}) => [
style.pressable,
style.iconContainer,
pressed && style.pressedIconContainer,
]}
>
{({pressed}) => (
<CompassIcon
name='close'
style={[style.errorIcon,
warning && style.warningIcon,
pressed && style.pressedErrorIcon,
pressed && warning && style.pressedWarningIcon,
]}
/>
)}
</Pressable>
</Pressable>
</View>
);
};
export default MessageBar;

View File

@@ -1,104 +0,0 @@
// 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

@@ -1,14 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {RTCPeer} from '@mattermost/calls/lib';
import {RTCMonitor, RTCPeer} from '@mattermost/calls/lib';
import {deflate} from 'pako';
import {DeviceEventEmitter, type EmitterSubscription, Platform} from 'react-native';
import InCallManager from 'react-native-incall-manager';
import {mediaDevices, MediaStream, MediaStreamTrack, RTCPeerConnection} from 'react-native-webrtc';
import {setPreferredAudioRoute, setSpeakerphoneOn} from '@calls/actions/calls';
import {setAudioDeviceInfo} from '@calls/state';
import {processMeanOpinionScore, setAudioDeviceInfo} from '@calls/state';
import {AudioDevice, type AudioDeviceInfo, type AudioDeviceInfoRaw, type CallsConnection} from '@calls/types/calls';
import {getICEServersConfigs} from '@calls/utils';
import {WebsocketEvents} from '@constants';
@@ -22,6 +22,7 @@ import {WebSocketClient, wsReconnectionTimeoutErr} from './websocket_client';
import type {EmojiData} from '@mattermost/calls/lib/types';
const peerConnectTimeout = 5000;
const rtcMonitorInterval = 4000;
export async function newConnection(
serverUrl: string,
@@ -39,6 +40,13 @@ export async function newConnection(
let onCallEnd: EmitterSubscription | null = null;
let audioDeviceChanged: EmitterSubscription | null = null;
const streams: MediaStream[] = [];
let rtcMonitor: RTCMonitor | null = null;
const logger = {
logDebug,
logErr: logError,
logWarn: logWarning,
logInfo,
};
const initializeVoiceTrack = async () => {
if (voiceTrack) {
@@ -79,6 +87,7 @@ export async function newConnection(
ws.send('leave');
ws.close();
rtcMonitor?.stop();
if (onCallEnd) {
onCallEnd.remove();
@@ -133,6 +142,14 @@ export async function newConnection(
return;
}
// NOTE: we purposely clear the monitor's stats cache upon unmuting
// in order to skip some calculations since upon muting we actually
// stop sending packets which would result in stats to be skewed as
// soon as we resume sending.
// This is not perfect but it avoids having to constantly send
// silence frames when muted.
rtcMonitor?.clearCache();
try {
if (voiceTrackAdded) {
peer.replaceTrack(voiceTrack.id, voiceTrack);
@@ -233,18 +250,20 @@ export async function newConnection(
peer = new RTCPeer({
iceServers: iceConfigs || [],
logger: {
logDebug,
logErr: logError,
logWarn: logWarning,
logInfo,
},
logger,
webrtc: {
MediaStream,
RTCPeerConnection,
},
});
rtcMonitor = new RTCMonitor({
peer,
logger,
monitorInterval: rtcMonitorInterval,
});
rtcMonitor.on('mos', processMeanOpinionScore);
peer.on('offer', (sdp) => {
logDebug(`local offer, sending: ${JSON.stringify(sdp)}`);
ws.send('sdp', {
@@ -323,6 +342,7 @@ export async function newConnection(
}
setTimeout(() => {
if (peer?.connected) {
rtcMonitor?.start();
callback();
} else {
waitForReadyImpl(callback, fail, timeout - 200);

View File

@@ -30,17 +30,17 @@ import CallAvatar from '@calls/components/call_avatar';
import CallDuration from '@calls/components/call_duration';
import CallsBadge, {CallsBadgeType} from '@calls/components/calls_badge';
import EmojiList from '@calls/components/emoji_list';
import PermissionErrorBar from '@calls/components/permission_error_bar';
import MessageBar from '@calls/components/message_bar';
import ReactionBar from '@calls/components/reaction_bar';
import UnavailableIconWrapper from '@calls/components/unavailable_icon_wrapper';
import {usePermissionsChecker} from '@calls/hooks';
import {RaisedHandBanner} from '@calls/screens/call_screen/raised_hand_banner';
import {useCallsConfig} from '@calls/state';
import {setCallQualityAlertDismissed, setMicPermissionsErrorDismissed, useCallsConfig} from '@calls/state';
import {getHandsRaised, makeCallsTheme, sortParticipants} from '@calls/utils';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import SlideUpPanelItem, {ITEM_HEIGHT} from '@components/slide_up_panel_item';
import {Preferences, Screens, WebsocketEvents} from '@constants';
import {Calls, Preferences, Screens, WebsocketEvents} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import DatabaseManager from '@database/manager';
@@ -694,7 +694,18 @@ const CallScreen = ({
{!isLandscape && currentCall.reactionStream.length > 0 &&
<EmojiList reactionStream={currentCall.reactionStream}/>
}
{micPermissionsError && <PermissionErrorBar/>}
{micPermissionsError &&
<MessageBar
type={Calls.MessageBarType.Microphone}
onPress={setMicPermissionsErrorDismissed}
/>
}
{currentCall.callQualityAlert &&
<MessageBar
type={Calls.MessageBarType.CallQuality}
onPress={setCallQualityAlertDismissed}
/>
}
<View style={[style.buttonsContainer]}>
<View
style={[

View File

@@ -7,7 +7,10 @@ import {act, renderHook} from '@testing-library/react-hooks';
import {needsRecordingAlert} from '@calls/alerts';
import {
newCurrentCall, setAudioDeviceInfo,
newCurrentCall,
processMeanOpinionScore,
setAudioDeviceInfo,
setCallQualityAlertDismissed,
setCallsState,
setChannelsWithCalls,
setCurrentCall,
@@ -58,6 +61,10 @@ import type {CallRecordingState} from '@mattermost/calls/lib/types';
jest.mock('@calls/alerts');
jest.mock('@constants/calls', () => ({
CALL_QUALITY_RESET_MS: 100,
}));
jest.mock('@actions/remote/thread', () => ({
updateThreadFollowing: jest.fn(() => Promise.resolve({})),
}));
@@ -897,6 +904,83 @@ describe('useCallsState', () => {
assert.deepEqual(result.current[1], null);
});
it('CallQuality', async () => {
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 currentCallNoAlertNoDismissed: CurrentCall = {
...DefaultCurrentCall,
serverUrl: 'server1',
myUserId: 'myUserId',
connected: true,
...newCall1,
};
// setup
const {result} = renderHook(() => {
return [useCallsState('server1'), useCurrentCall()];
});
act(() => setCallsState('server1', initialCallsState));
assert.deepEqual(result.current[0], initialCallsState);
assert.deepEqual(result.current[1], null);
// join call
act(() => {
newCurrentCall('server1', 'channel-1', 'myUserId');
userJoinedCall('server1', 'channel-1', 'myUserId');
});
assert.deepEqual(result.current[0], expectedCallsState);
assert.deepEqual(result.current[1], currentCallNoAlertNoDismissed);
// call quality goes bad
act(() => processMeanOpinionScore(3.4999));
assert.deepEqual((result.current[1] as CurrentCall).callQualityAlert, true);
assert.equal((result.current[1] as CurrentCall).callQualityAlertDismissed, 0);
// call quality goes good
act(() => processMeanOpinionScore(4));
assert.deepEqual(result.current[1], currentCallNoAlertNoDismissed);
// call quality goes bad
act(() => processMeanOpinionScore(3.499));
assert.deepEqual((result.current[1] as CurrentCall).callQualityAlert, true);
assert.equal((result.current[1] as CurrentCall).callQualityAlertDismissed, 0);
// dismiss call quality alert
const timeNow = Date.now();
act(() => setCallQualityAlertDismissed());
assert.deepEqual((result.current[1] as CurrentCall).callQualityAlert, false);
assert.equal((result.current[1] as CurrentCall).callQualityAlertDismissed >= timeNow &&
(result.current[1] as CurrentCall).callQualityAlertDismissed <= Date.now(), true);
// call quality goes bad, but we're not past the dismissed limit
act(() => processMeanOpinionScore(3.4999));
assert.deepEqual((result.current[1] as CurrentCall).callQualityAlert, false);
// test that the dismiss expired
await act(async () => {
await new Promise((r) => setTimeout(r, 101));
processMeanOpinionScore(3.499);
});
assert.deepEqual((result.current[1] as CurrentCall).callQualityAlert, true);
});
it('voiceOn and Off', () => {
const initialCallsState = {
...DefaultCallsState,

View File

@@ -1,6 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {mosThreshold} from '@mattermost/calls/lib/rtc_monitor';
import {updateThreadFollowing} from '@actions/remote/thread';
import {needsRecordingAlert} from '@calls/alerts';
import {
@@ -25,7 +27,7 @@ import {
DefaultCurrentCall,
type ReactionStreamEmoji,
} from '@calls/types/calls';
import {REACTION_LIMIT, REACTION_TIMEOUT} from '@constants/calls';
import {Calls} from '@constants';
import DatabaseManager from '@database/manager';
import {getChannelById} from '@queries/servers/channel';
import {getThreadById} from '@queries/servers/thread';
@@ -489,7 +491,7 @@ export const userReacted = (serverUrl: string, channelId: string, reaction: User
};
newReactionStream.splice(0, 0, newReaction);
}
if (newReactionStream.length > REACTION_LIMIT) {
if (newReactionStream.length > Calls.REACTION_LIMIT) {
newReactionStream.pop();
}
@@ -509,7 +511,7 @@ export const userReacted = (serverUrl: string, channelId: string, reaction: User
setTimeout(() => {
userReactionTimeout(serverUrl, channelId, reaction);
}, REACTION_TIMEOUT);
}, Calls.REACTION_TIMEOUT);
};
const userReactionTimeout = (serverUrl: string, channelId: string, reaction: UserReactionData) => {
@@ -591,3 +593,54 @@ export const setHost = (serverUrl: string, channelId: string, hostId: string) =>
};
setCurrentCall(nextCurrentCall);
};
export const processMeanOpinionScore = (mos: number) => {
const currentCall = getCurrentCall();
if (!currentCall) {
return;
}
if (mos < mosThreshold) {
setCallQualityAlert(true);
} else {
setCallQualityAlert(false);
}
};
export const setCallQualityAlert = (setAlert: boolean) => {
const currentCall = getCurrentCall();
if (!currentCall) {
return;
}
// Alert is already active, or alert was dismissed and the timeout hasn't passed
if ((setAlert && currentCall.callQualityAlert) ||
(setAlert && currentCall.callQualityAlertDismissed + Calls.CALL_QUALITY_RESET_MS > Date.now())) {
return;
}
// Alert is already inactive
if ((!setAlert && !currentCall.callQualityAlert)) {
return;
}
const nextCurrentCall: CurrentCall = {
...currentCall,
callQualityAlert: setAlert,
};
setCurrentCall(nextCurrentCall);
};
export const setCallQualityAlertDismissed = () => {
const currentCall = getCurrentCall();
if (!currentCall) {
return;
}
const nextCurrentCall: CurrentCall = {
...currentCall,
callQualityAlert: false,
callQualityAlertDismissed: Date.now(),
};
setCurrentCall(nextCurrentCall);
};

View File

@@ -63,7 +63,8 @@ export type CurrentCall = Call & {
voiceOn: Dictionary<boolean>;
micPermissionsErrorDismissed: boolean;
reactionStream: ReactionStreamEmoji[];
recAcknowledged: boolean;
callQualityAlert: boolean;
callQualityAlertDismissed: number;
}
export const DefaultCurrentCall: CurrentCall = {
@@ -77,7 +78,8 @@ export const DefaultCurrentCall: CurrentCall = {
voiceOn: {},
micPermissionsErrorDismissed: false,
reactionStream: [],
recAcknowledged: false,
callQualityAlert: false,
callQualityAlertDismissed: 0,
};
export type CallParticipant = {

View File

@@ -4,8 +4,7 @@
import {makeCallsBaseAndBadgeRGB, rgbToCSS} from '@mattermost/calls/lib/utils';
import {Alert} from 'react-native';
import {Post} from '@constants';
import Calls from '@constants/calls';
import {Calls, Post} from '@constants';
import {isMinimumServerVersion} from '@utils/helpers';
import {displayUsername} from '@utils/user';

View File

@@ -466,6 +466,7 @@
"mobile.calls_participant_rec": "The host has started recording this meeting. By staying in the meeting you give consent to being recorded.",
"mobile.calls_participant_rec_title": "Recording is in progress",
"mobile.calls_phone": "Phone",
"mobile.calls_quality_warning": "Call quality may be degraded due to unstable network conditions.",
"mobile.calls_raise_hand": "Raise hand",
"mobile.calls_raised_hand": "<bold>{name} {num, plural, =0 {} other {+# more }}</bold>raised a hand",
"mobile.calls_react": "React",