diff --git a/app/constants/events.ts b/app/constants/events.ts index 4a994433a..aece21528 100644 --- a/app/constants/events.ts +++ b/app/constants/events.ts @@ -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, }); 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 5c296f03f..1594a0ba5 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 @@ -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(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(); diff --git a/app/products/calls/connection/websocket_event_handlers.ts b/app/products/calls/connection/websocket_event_handlers.ts index eef5ff4f3..a74d57d08 100644 --- a/app/products/calls/connection/websocket_event_handlers.ts +++ b/app/products/calls/connection/websocket_event_handlers.ts @@ -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) => { diff --git a/app/products/calls/screens/call_screen/call_screen.tsx b/app/products/calls/screens/call_screen/call_screen.tsx index 3f983b189..ec0a1421b 100644 --- a/app/products/calls/screens/call_screen/call_screen.tsx +++ b/app/products/calls/screens/call_screen/call_screen.tsx @@ -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>({}); 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 = ( { return ( setSpeakerphoneOn(!currentCall?.speakerphoneOn)} + onPress={toggleSpeakerPhone} > { ...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: [], diff --git a/app/products/calls/state/actions.ts b/app/products/calls/state/actions.ts index 74cfed3fd..32c8b8261 100644 --- a/app/products/calls/state/actions.ts +++ b/app/products/calls/state/actions.ts @@ -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]) { diff --git a/app/products/calls/types/calls.ts b/app/products/calls/types/calls.ts index 760683a52..7a7a6dce7 100644 --- a/app/products/calls/types/calls.ts +++ b/app/products/calls/types/calls.ts @@ -45,6 +45,7 @@ export type CurrentCall = { threadId: string; screenShareURL: string; speakerphoneOn: boolean; + voiceOn: Dictionary; } export type CallParticipant = {