MM-47763 - Calls: "who is speaking" state (#6721)
* move voiceOn to the currentCall state * simplify * prefer no inline fns
This commit is contained in:
parent
e38e547ce2
commit
7f5dc3c718
7 changed files with 131 additions and 96 deletions
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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]) {
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ export type CurrentCall = {
|
|||
threadId: string;
|
||||
screenShareURL: string;
|
||||
speakerphoneOn: boolean;
|
||||
voiceOn: Dictionary<boolean>;
|
||||
}
|
||||
|
||||
export type CallParticipant = {
|
||||
|
|
|
|||
Loading…
Reference in a new issue