MM-47763 - Calls: "who is speaking" state (#6721)

* move voiceOn to the currentCall state

* simplify

* prefer no inline fns
This commit is contained in:
Christopher Poile
2022-11-07 17:11:40 -05:00
committed by GitHub
parent e38e547ce2
commit 7f5dc3c718
7 changed files with 131 additions and 96 deletions

View File

@@ -31,5 +31,4 @@ export default keyMirror({
SEND_TO_POST_DRAFT: null,
CRT_TOGGLED: null,
JOIN_CALL_BAR_VISIBLE: null,
CURRENT_CALL_BAR_VISIBLE: null,
});

View File

@@ -1,16 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useState} from 'react';
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {View, Text, TouchableOpacity, Pressable, Platform, DeviceEventEmitter} from 'react-native';
import {View, Text, TouchableOpacity, Pressable, Platform} from 'react-native';
import {Options} from 'react-native-navigation';
import {muteMyself, unmuteMyself} from '@calls/actions';
import CallAvatar from '@calls/components/call_avatar';
import {CurrentCall, VoiceEventData} from '@calls/types/calls';
import {CurrentCall} from '@calls/types/calls';
import CompassIcon from '@components/compass_icon';
import {Events, Screens, WebsocketEvents} from '@constants';
import {Screens} from '@constants';
import {CURRENT_CALL_BAR_HEIGHT} from '@constants/view';
import {useTheme} from '@context/theme';
import {dismissAllModalsAndPopToScreen} from '@screens/navigation';
@@ -90,45 +90,6 @@ const CurrentCallBar = ({
}: Props) => {
const theme = useTheme();
const {formatMessage} = useIntl();
const [speaker, setSpeaker] = useState<string | null>(null);
const [talkingMessage, setTalkingMessage] = useState('');
const isCurrentCall = Boolean(currentCall);
const handleVoiceOn = (data: VoiceEventData) => {
if (data.channelId === currentCall?.channelId) {
setSpeaker(data.userId);
}
};
const handleVoiceOff = (data: VoiceEventData) => {
if (data.channelId === currentCall?.channelId && ((speaker === data.userId) || !speaker)) {
setSpeaker(null);
}
};
useEffect(() => {
const onVoiceOn = DeviceEventEmitter.addListener(WebsocketEvents.CALLS_USER_VOICE_ON, handleVoiceOn);
const onVoiceOff = DeviceEventEmitter.addListener(WebsocketEvents.CALLS_USER_VOICE_OFF, handleVoiceOff);
DeviceEventEmitter.emit(Events.CURRENT_CALL_BAR_VISIBLE, isCurrentCall);
return () => {
DeviceEventEmitter.emit(Events.CURRENT_CALL_BAR_VISIBLE, Boolean(false));
onVoiceOn.remove();
onVoiceOff.remove();
};
}, [isCurrentCall]);
useEffect(() => {
if (speaker) {
setTalkingMessage(formatMessage({
id: 'mobile.calls_name_is_talking',
defaultMessage: '{name} is talking',
}, {name: displayUsername(userModelsDict[speaker], teammateNameDisplay)}));
} else {
setTalkingMessage(formatMessage({
id: 'mobile.calls_noone_talking',
defaultMessage: 'No one is talking',
}));
}
}, [speaker, setTalkingMessage]);
const goToCallScreen = useCallback(async () => {
const options: Options = {
@@ -150,6 +111,21 @@ const CurrentCallBar = ({
const myParticipant = currentCall?.participants[currentCall.myUserId];
// Since we can only see one user talking, it doesn't really matter who we show here (e.g., we can't
// tell who is speaking louder).
const talkingUsers = Object.keys(currentCall?.voiceOn || {});
const speaker = talkingUsers.length > 0 ? talkingUsers[0] : '';
let talkingMessage = formatMessage({
id: 'mobile.calls_noone_talking',
defaultMessage: 'No one is talking',
});
if (speaker) {
talkingMessage = formatMessage({
id: 'mobile.calls_name_is_talking',
defaultMessage: '{name} is talking',
}, {name: displayUsername(userModelsDict[speaker], teammateNameDisplay)});
}
const muteUnmute = () => {
if (myParticipant?.muted) {
unmuteMyself();

View File

@@ -12,6 +12,7 @@ import {
setChannelEnabled,
setRaisedHand,
setUserMuted,
setUserVoiceOn,
userJoinedCall,
userLeftCall,
} from '@calls/state';
@@ -38,17 +39,11 @@ export const handleCallUserUnmuted = (serverUrl: string, msg: WebSocketMessage)
};
export const handleCallUserVoiceOn = (msg: WebSocketMessage) => {
DeviceEventEmitter.emit(WebsocketEvents.CALLS_USER_VOICE_ON, {
channelId: msg.broadcast.channel_id,
userId: msg.data.userID,
});
setUserVoiceOn(msg.broadcast.channel_id, msg.data.userID, true);
};
export const handleCallUserVoiceOff = (msg: WebSocketMessage) => {
DeviceEventEmitter.emit(WebsocketEvents.CALLS_USER_VOICE_OFF, {
channelId: msg.broadcast.channel_id,
userId: msg.data.userID,
});
setUserVoiceOn(msg.broadcast.channel_id, msg.data.userID, false);
};
export const handleCallStarted = (serverUrl: string, msg: WebSocketMessage) => {

View File

@@ -30,7 +30,7 @@ import CallAvatar from '@calls/components/call_avatar';
import CallDuration from '@calls/components/call_duration';
import RaisedHandIcon from '@calls/icons/raised_hand_icon';
import UnraisedHandIcon from '@calls/icons/unraised_hand_icon';
import {CallParticipant, CurrentCall, VoiceEventData} from '@calls/types/calls';
import {CallParticipant, CurrentCall} from '@calls/types/calls';
import {sortParticipants} from '@calls/utils';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
@@ -260,7 +260,6 @@ const CallScreen = ({componentId, currentCall, participantsDict, teammateNameDis
const insets = useSafeAreaInsets();
const {width, height} = useWindowDimensions();
const [showControlsInLandscape, setShowControlsInLandscape] = useState(false);
const [speakers, setSpeakers] = useState<Dictionary<boolean>>({});
const style = getStyleSheet(theme);
const isLandscape = width > height;
@@ -279,30 +278,6 @@ const CallScreen = ({componentId, currentCall, participantsDict, teammateNameDis
});
}, []);
useEffect(() => {
const handleVoiceOn = (data: VoiceEventData) => {
if (data.channelId === currentCall?.channelId) {
setSpeakers((prev) => ({...prev, [data.userId]: true}));
}
};
const handleVoiceOff = (data: VoiceEventData) => {
if (data.channelId === currentCall?.channelId && speakers.hasOwnProperty(data.userId)) {
setSpeakers((prev) => {
const next = {...prev};
delete next[data.userId];
return next;
});
}
};
const onVoiceOn = DeviceEventEmitter.addListener(WebsocketEvents.CALLS_USER_VOICE_ON, handleVoiceOn);
const onVoiceOff = DeviceEventEmitter.addListener(WebsocketEvents.CALLS_USER_VOICE_OFF, handleVoiceOff);
return () => {
onVoiceOn.remove();
onVoiceOff.remove();
};
}, [speakers, currentCall?.channelId]);
const leaveCallHandler = useCallback(() => {
popTopScreen();
leaveCall();
@@ -325,6 +300,10 @@ const CallScreen = ({componentId, currentCall, participantsDict, teammateNameDis
}
}, [myParticipant?.raisedHand]);
const toggleSpeakerPhone = useCallback(() => {
setSpeakerphoneOn(!currentCall?.speakerphoneOn);
}, [currentCall?.speakerphoneOn]);
const toggleControlsInLandscape = useCallback(() => {
setShowControlsInLandscape(!showControlsInLandscape);
}, [showControlsInLandscape]);
@@ -424,8 +403,8 @@ const CallScreen = ({componentId, currentCall, participantsDict, teammateNameDis
usersList = (
<ScrollView
alwaysBounceVertical={false}
horizontal={currentCall?.screenOn !== ''}
contentContainerStyle={[isLandscape && currentCall?.screenOn && style.usersScrollLandscapeScreenOn]}
horizontal={currentCall.screenOn !== ''}
contentContainerStyle={[isLandscape && currentCall.screenOn && style.usersScrollLandscapeScreenOn]}
>
<Pressable
testID='users-list'
@@ -435,12 +414,12 @@ const CallScreen = ({componentId, currentCall, participantsDict, teammateNameDis
{participants.map((user) => {
return (
<View
style={[style.user, currentCall?.screenOn && style.userScreenOn]}
style={[style.user, currentCall.screenOn && style.userScreenOn]}
key={user.id}
>
<CallAvatar
userModel={user.userModel}
volume={speakers[user.id] ? 1 : 0}
volume={currentCall.voiceOn[user.id] ? 1 : 0}
muted={user.muted}
sharingScreen={user.id === currentCall.screenOn}
raisedHand={Boolean(user.raisedHand)}
@@ -544,12 +523,12 @@ const CallScreen = ({componentId, currentCall, participantsDict, teammateNameDis
<Pressable
testID={'toggle-speakerphone'}
style={style.button}
onPress={() => setSpeakerphoneOn(!currentCall?.speakerphoneOn)}
onPress={toggleSpeakerPhone}
>
<CompassIcon
name={'volume-high'}
size={24}
style={[style.buttonIcon, style.speakerphoneIcon, currentCall?.speakerphoneOn && style.speakerphoneIconOn]}
style={[style.buttonIcon, style.speakerphoneIcon, currentCall.speakerphoneOn && style.speakerphoneIconOn]}
/>
<FormattedText
id={'mobile.calls_speaker'}

View File

@@ -30,6 +30,7 @@ import {
setSpeakerPhone,
setConfig,
setPluginEnabled,
setUserVoiceOn,
} from '@calls/state/actions';
import {License} from '@constants';
@@ -113,6 +114,7 @@ describe('useCallsState', () => {
...call1,
screenShareURL: '',
speakerphoneOn: false,
voiceOn: {},
};
const testNewCall1 = {
...call1,
@@ -179,13 +181,14 @@ describe('useCallsState', () => {
const initialChannelsWithCallsState = {
'channel-1': true,
};
const initialCurrentCallState = {
const initialCurrentCallState: CurrentCall = {
serverUrl: 'server1',
myUserId: 'myUserId',
...call1,
screenShareURL: '',
speakerphoneOn: false,
} as CurrentCall;
voiceOn: {},
};
const expectedCallsState = {
'channel-1': {
participants: {
@@ -238,13 +241,14 @@ describe('useCallsState', () => {
const initialChannelsWithCallsState = {
'channel-1': true,
};
const initialCurrentCallState = {
const initialCurrentCallState: CurrentCall = {
serverUrl: 'server1',
myUserId: 'myUserId',
...call1,
screenShareURL: '',
speakerphoneOn: false,
} as CurrentCall;
voiceOn: {},
};
const expectedCallsState = {
'channel-1': {
participants: {
@@ -340,13 +344,14 @@ describe('useCallsState', () => {
calls: {'channel-1': call1, 'channel-2': call2},
};
const initialChannelsWithCallsState = {'channel-1': true, 'channel-2': true};
const initialCurrentCallState = {
const initialCurrentCallState: CurrentCall = {
serverUrl: 'server1',
myUserId: 'myUserId',
...call1,
screenShareURL: '',
speakerphoneOn: false,
} as CurrentCall;
voiceOn: {},
};
// setup
const {result} = renderHook(() => {
@@ -387,13 +392,14 @@ describe('useCallsState', () => {
calls: {'channel-1': call1, 'channel-2': call2},
};
const initialChannelsWithCallsState = {'channel-1': true, 'channel-2': true};
const initialCurrentCallState = {
const initialCurrentCallState: CurrentCall = {
serverUrl: 'server1',
myUserId: 'myUserId',
...call1,
screenShareURL: '',
speakerphoneOn: false,
} as CurrentCall;
voiceOn: {},
};
// setup
const {result} = renderHook(() => {
@@ -445,13 +451,14 @@ describe('useCallsState', () => {
ownerId: 'user-1',
},
};
const initialCurrentCallState = {
const initialCurrentCallState: CurrentCall = {
serverUrl: 'server1',
myUserId: 'myUserId',
...call1,
screenShareURL: '',
speakerphoneOn: false,
} as CurrentCall;
voiceOn: {},
};
const expectedCurrentCallState = {
...initialCurrentCallState,
...expectedCalls['channel-1'],
@@ -503,13 +510,14 @@ describe('useCallsState', () => {
'channel-1': newCall1,
},
};
const expectedCurrentCallState = {
const expectedCurrentCallState: CurrentCall = {
serverUrl: 'server1',
myUserId: 'myUserId',
screenShareURL: '',
speakerphoneOn: false,
...newCall1,
} as CurrentCall;
voiceOn: {},
};
// setup
const {result} = renderHook(() => {
@@ -649,6 +657,50 @@ describe('useCallsState', () => {
assert.deepEqual(result.current[1], null);
});
it('voiceOn and Off', () => {
const initialCallsState = {
...DefaultCallsState,
serverUrl: 'server1',
myUserId: 'myUserId',
calls: {'channel-1': call1, 'channel-2': call2},
};
const initialCurrentCallState: CurrentCall = {
serverUrl: 'server1',
myUserId: 'myUserId',
...call1,
screenShareURL: '',
speakerphoneOn: false,
voiceOn: {},
};
// setup
const {result} = renderHook(() => {
return [useCallsState('server1'), useCurrentCall()];
});
act(() => {
setCallsState('server1', initialCallsState);
setCurrentCall(initialCurrentCallState);
});
assert.deepEqual(result.current[0], initialCallsState);
assert.deepEqual(result.current[1], initialCurrentCallState);
// test
act(() => setUserVoiceOn('channel-1', 'user-1', true));
assert.deepEqual(result.current[1], {...initialCurrentCallState, voiceOn: {'user-1': true}});
assert.deepEqual(result.current[0], initialCallsState);
act(() => setUserVoiceOn('channel-1', 'user-2', true));
assert.deepEqual(result.current[1], {...initialCurrentCallState, voiceOn: {'user-1': true, 'user-2': true}});
assert.deepEqual(result.current[0], initialCallsState);
act(() => setUserVoiceOn('channel-1', 'user-1', false));
assert.deepEqual(result.current[1], {...initialCurrentCallState, voiceOn: {'user-2': true}});
assert.deepEqual(result.current[0], initialCallsState);
// test that voice state is cleared on reconnect
act(() => setCalls('server1', 'myUserId', initialCallsState.calls, {}));
assert.deepEqual(result.current[1], initialCurrentCallState);
assert.deepEqual(result.current[0], initialCallsState);
});
it('config', () => {
const newConfig = {
ICEServers: [],

View File

@@ -29,9 +29,12 @@ export const setCalls = (serverUrl: string, myUserId: string, calls: Dictionary<
return;
}
// Edge case: if the app went into the background and lost the main ws connection, we don't know who is currently
// talking. Instead of guessing, erase voiceOn state (same state as when joining an ongoing call).
const nextCall = {
...currentCall,
...calls[currentCall.channelId],
voiceOn: {},
};
setCurrentCall(nextCall);
};
@@ -92,9 +95,13 @@ export const userJoinedCall = (serverUrl: string, channelId: string, userId: str
// Did the user join the current call? If so, update that too.
const currentCall = getCurrentCall();
if (currentCall && currentCall.channelId === channelId) {
const voiceOn = {...currentCall.voiceOn};
delete voiceOn[userId];
const nextCurrentCall = {
...currentCall,
participants: {...currentCall.participants, [userId]: nextCall.participants[userId]},
voiceOn,
};
setCurrentCall(nextCurrentCall);
}
@@ -108,6 +115,7 @@ export const userJoinedCall = (serverUrl: string, channelId: string, userId: str
myUserId: userId,
screenShareURL: '',
speakerphoneOn: false,
voiceOn: {},
});
}
};
@@ -149,9 +157,14 @@ export const userLeftCall = (serverUrl: string, channelId: string, userId: strin
return;
}
// Clear them from the voice list
const voiceOn = {...currentCall.voiceOn};
delete voiceOn[userId];
const nextCurrentCall = {
...currentCall,
participants: {...currentCall.participants},
voiceOn,
};
delete nextCurrentCall.participants[userId];
setCurrentCall(nextCurrentCall);
@@ -220,6 +233,26 @@ export const setUserMuted = (serverUrl: string, channelId: string, userId: strin
setCurrentCall(nextCurrentCall);
};
export const setUserVoiceOn = (channelId: string, userId: string, voiceOn: boolean) => {
const currentCall = getCurrentCall();
if (!currentCall || currentCall.channelId !== channelId) {
return;
}
const nextVoiceOn = {...currentCall.voiceOn};
if (voiceOn) {
nextVoiceOn[userId] = true;
} else {
delete nextVoiceOn[userId];
}
const nextCurrentCall = {
...currentCall,
voiceOn: nextVoiceOn,
};
setCurrentCall(nextCurrentCall);
};
export const setRaisedHand = (serverUrl: string, channelId: string, userId: string, timestamp: number) => {
const callsState = getCallsState(serverUrl);
if (!callsState.calls[channelId] || !callsState.calls[channelId].participants[userId]) {

View File

@@ -45,6 +45,7 @@ export type CurrentCall = {
threadId: string;
screenShareURL: string;
speakerphoneOn: boolean;
voiceOn: Dictionary<boolean>;
}
export type CallParticipant = {