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",