diff --git a/app/constants/calls.ts b/app/constants/calls.ts index a7238bd665..e9bc049fae 100644 --- a/app/constants/calls.ts +++ b/app/constants/calls.ts @@ -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, +}; 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 63c499dd34..4b063838d2 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 @@ -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 = ({ - {micPermissionsError && } + {micPermissionsError && + + } + {currentCall?.callQualityAlert && + + } ); }; diff --git a/app/products/calls/components/message_bar.tsx b/app/products/calls/components/message_bar.tsx new file mode 100644 index 0000000000..a63e82160d --- /dev/null +++ b/app/products/calls/components/message_bar.tsx @@ -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 = ( + ); + 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 = ( + ); + break; + } + + return ( + + + {icon} + {message} + [ + style.pressable, + style.iconContainer, + pressed && style.pressedIconContainer, + ]} + > + {({pressed}) => ( + + )} + + + + ); +}; + +export default MessageBar; diff --git a/app/products/calls/components/permission_error_bar.tsx b/app/products/calls/components/permission_error_bar.tsx deleted file mode 100644 index 66a0ba5672..0000000000 --- a/app/products/calls/components/permission_error_bar.tsx +++ /dev/null @@ -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 ( - - - - - [ - style.pressable, - style.errorIconContainer, - pressed && style.pressedErrorIconContainer, - ]} - > - {({pressed}) => ( - - )} - - - - ); -}; - -export default PermissionErrorBar; diff --git a/app/products/calls/connection/connection.ts b/app/products/calls/connection/connection.ts index f061da612d..99a6b6ff65 100644 --- a/app/products/calls/connection/connection.ts +++ b/app/products/calls/connection/connection.ts @@ -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); diff --git a/app/products/calls/screens/call_screen/call_screen.tsx b/app/products/calls/screens/call_screen/call_screen.tsx index 9f6b0e7d3c..53b7f4577f 100644 --- a/app/products/calls/screens/call_screen/call_screen.tsx +++ b/app/products/calls/screens/call_screen/call_screen.tsx @@ -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 && } - {micPermissionsError && } + {micPermissionsError && + + } + {currentCall.callQualityAlert && + + } ({ + 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, diff --git a/app/products/calls/state/actions.ts b/app/products/calls/state/actions.ts index e958fc00fd..8e7b711c36 100644 --- a/app/products/calls/state/actions.ts +++ b/app/products/calls/state/actions.ts @@ -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); +}; diff --git a/app/products/calls/types/calls.ts b/app/products/calls/types/calls.ts index 6225b55799..70d35f1ecb 100644 --- a/app/products/calls/types/calls.ts +++ b/app/products/calls/types/calls.ts @@ -63,7 +63,8 @@ export type CurrentCall = Call & { voiceOn: Dictionary; 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 = { diff --git a/app/products/calls/utils.ts b/app/products/calls/utils.ts index c002ab8202..f6733ee004 100644 --- a/app/products/calls/utils.ts +++ b/app/products/calls/utils.ts @@ -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'; diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 75b2b97239..5424b34eb8 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -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": "{name} {num, plural, =0 {} other {+# more }}raised a hand", "mobile.calls_react": "React",