diff --git a/NOTICE.txt b/NOTICE.txt index 3b7747d05..bd897b68b 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -2795,14 +2795,12 @@ SOFTWARE. --- -## react-native-webrtc2 +## react-native-webrtc -This product contains a modified version of 'react-native-webrtc2' by juncocoa. - -React Native 的 WebRTC 模块 +This product contains a modified version of 'react-native-webrtc'. * HOMEPAGE: - * https://github.com/juncocoa/react-native-webrtc2 + * https://github.com/react-native-webrtc/react-native-webrtc * LICENSE: MIT diff --git a/app/actions/websocket/index.ts b/app/actions/websocket/index.ts index 5e3891edb..5b994f6ce 100644 --- a/app/actions/websocket/index.ts +++ b/app/actions/websocket/index.ts @@ -446,7 +446,8 @@ function handleEvent(msg: WebSocketMessage) { case WebsocketEvents.CALLS_CHANNEL_DISABLED: return dispatch(handleCallChannelDisabled(msg)); case WebsocketEvents.CALLS_USER_CONNECTED: - return dispatch(handleCallUserConnected(msg)); + handleCallUserConnected(dispatch, getState, msg); + break; case WebsocketEvents.CALLS_USER_DISCONNECTED: return dispatch(handleCallUserDisconnected(msg)); case WebsocketEvents.CALLS_USER_MUTED: @@ -454,9 +455,11 @@ function handleEvent(msg: WebSocketMessage) { case WebsocketEvents.CALLS_USER_UNMUTED: return dispatch(handleCallUserUnmuted(msg)); case WebsocketEvents.CALLS_USER_VOICE_ON: - return dispatch(handleCallUserVoiceOn(msg)); + handleCallUserVoiceOn(msg); + break; case WebsocketEvents.CALLS_USER_VOICE_OFF: - return dispatch(handleCallUserVoiceOff(msg)); + handleCallUserVoiceOff(msg); + break; case WebsocketEvents.CALLS_CALL_START: return dispatch(handleCallStarted(msg)); case WebsocketEvents.CALLS_SCREEN_ON: diff --git a/app/products/calls/client/rest.ts b/app/products/calls/client/rest.ts index c9cd0ead0..cbecb6054 100644 --- a/app/products/calls/client/rest.ts +++ b/app/products/calls/client/rest.ts @@ -17,6 +17,13 @@ const ClientCalls = (superclass: any) => class extends superclass { ); }; + getCallsConfig = async () => { + return this.doFetch( + `${this.getCallsRoute()}/config`, + {method: 'get'}, + ); + }; + enableChannelCalls = async (channelId: string) => { return this.doFetch( `${this.getCallsRoute()}/${channelId}`, diff --git a/app/products/calls/components/__snapshots__/enable_disable_calls.test.js.snap b/app/products/calls/components/__snapshots__/enable_disable_calls.test.js.snap index b6deb6db0..204cdcaa3 100644 --- a/app/products/calls/components/__snapshots__/enable_disable_calls.test.js.snap +++ b/app/products/calls/components/__snapshots__/enable_disable_calls.test.js.snap @@ -40,7 +40,6 @@ exports[`EnableDisableCalls should match snapshot if calls are disabled 1`] = ` rightArrow={false} shouldRender={true} testID="test-id" - textId="mobile.channel_info.enable_calls" theme={ Object { "awayIndicator": "#ffbc1f", @@ -115,7 +114,6 @@ exports[`EnableDisableCalls should match snapshot if calls are enabled 1`] = ` rightArrow={false} shouldRender={true} testID="test-id" - textId="mobile.channel_info.disable_calls" theme={ Object { "awayIndicator": "#ffbc1f", diff --git a/app/products/calls/components/call_avatar.tsx b/app/products/calls/components/call_avatar.tsx index da9893efd..d87b99a3f 100644 --- a/app/products/calls/components/call_avatar.tsx +++ b/app/products/calls/components/call_avatar.tsx @@ -8,7 +8,7 @@ import CompassIcon from '@components/compass_icon'; import ProfilePicture from '@components/profile_picture'; type Props = { - userId: string; + userId?: string; volume: number; muted?: boolean; size?: 'm' | 'l'; @@ -63,11 +63,17 @@ const CallAvatar = (props: Props) => { - + {props.userId ? + : + + } {props.muted !== undefined && - + > + User 1 started a call + - + > + Join call + `; @@ -129,16 +124,16 @@ exports[`CallMessage should match snapshot for ended call 1`] = ` } } > - + > + Call ended + - , - } - } + > + Ended at + + - + > + Lasted a few seconds + @@ -230,21 +216,16 @@ exports[`CallMessage should match snapshot for the call already in the current c } } > - + > + User 1 started a call + - + > + Current call + `; diff --git a/app/products/calls/components/call_message/call_message.tsx b/app/products/calls/components/call_message/call_message.tsx index 1f0beb050..94ccd7aa2 100644 --- a/app/products/calls/components/call_message/call_message.tsx +++ b/app/products/calls/components/call_message/call_message.tsx @@ -8,7 +8,6 @@ import {View, TouchableOpacity, Text} from 'react-native'; import CompassIcon from '@components/compass_icon'; import FormattedRelativeTime from '@components/formatted_relative_time'; -import FormattedText from '@components/formatted_text'; import FormattedTime from '@components/formatted_time'; import {displayUsername} from '@mm-redux/utils/user_utils'; import leaveAndJoinWithAlert from '@mmproducts/calls/components/leave_and_join_alert'; @@ -117,37 +116,26 @@ const CallMessage = ({post, user, teammateNameDisplay, confirmToJoin, alreadyInT style={style.phoneHangupIcon} /> - + >{'Call ended'} - - ), - }} - /> + >{'Ended at '} + { + + } {'•'} - + >{`Lasted ${moment.duration(post.props.end_at - post.props.start_at).humanize(false)}`} @@ -162,12 +150,9 @@ const CallMessage = ({post, user, teammateNameDisplay, confirmToJoin, alreadyInT style={style.joinCallIcon} /> - + >{`${displayUsername(user, teammateNameDisplay)} started a call`} {alreadyInTheCall && - } + >{'Current call'}} {!alreadyInTheCall && - } + >{'Join call'}} ); diff --git a/app/products/calls/components/current_call/__snapshots__/current_call.test.js.snap b/app/products/calls/components/current_call/__snapshots__/current_call.test.js.snap index 4b9951509..abb43fd6e 100644 --- a/app/products/calls/components/current_call/__snapshots__/current_call.test.js.snap +++ b/app/products/calls/components/current_call/__snapshots__/current_call.test.js.snap @@ -22,8 +22,8 @@ exports[`CurrentCall should match snapshot muted 1`] = ` } > - + No one is talking - + ~Channel Name - + No one is talking - + ~Channel Name { }); const CurrentCall = (props: Props) => { + const [speaker, setSpeaker] = useState(null); + const handleVoiceOn = (data: VoiceEventData) => { + if (data.channelId === props.call?.channelId) { + setSpeaker(props.call.participants[data.userId].profile); + } + }; + const handleVoiceOff = (data: VoiceEventData) => { + if (data.channelId === props.call?.channelId && ((speaker?.id === 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); EventEmitter.emit(ViewTypes.CURRENT_CALL_BAR_VISIBLE, Boolean(props.call)); return () => { EventEmitter.emit(ViewTypes.CURRENT_CALL_BAR_VISIBLE, Boolean(false)); + onVoiceOn.remove(); + onVoiceOff.remove(); }; }, [props.call]); @@ -129,23 +143,16 @@ const CurrentCall = (props: Props) => { - + {speaker && `${displayUsername(speaker, props.teammateNameDisplay)} is talking`} + {!speaker && 'No one is talking'} - + {`~${props.channel.display_name}`} { action={handleEnableDisableCalls} defaultMessage={enabled ? 'Disable Calls' : 'Enable Calls'} icon='phone-outline' - textId={enabled ? t('mobile.channel_info.disable_calls') : t('mobile.channel_info.enable_calls')} theme={theme} rightArrow={false} /> diff --git a/app/products/calls/components/join_call/__snapshots__/join_call.test.js.snap b/app/products/calls/components/join_call/__snapshots__/join_call.test.js.snap index ffeab48d0..c41928764 100644 --- a/app/products/calls/components/join_call/__snapshots__/join_call.test.js.snap +++ b/app/products/calls/components/join_call/__snapshots__/join_call.test.js.snap @@ -62,9 +62,7 @@ exports[`JoinCall should match snapshot 1`] = ` + > + Call participants + } userIds={ Array [ diff --git a/app/products/calls/components/join_call/join_call.tsx b/app/products/calls/components/join_call/join_call.tsx index bcdf10c76..4c6092c60 100644 --- a/app/products/calls/components/join_call/join_call.tsx +++ b/app/products/calls/components/join_call/join_call.tsx @@ -8,7 +8,6 @@ import {View, Text, Pressable} from 'react-native'; import Avatars from '@components/avatars'; import CompassIcon from '@components/compass_icon'; import FormattedRelativeTime from '@components/formatted_relative_time'; -import FormattedText from '@components/formatted_text'; import ViewTypes, {JOIN_CALL_BAR_HEIGHT} from '@constants/view'; import EventEmitter from '@mm-redux/utils/event_emitter'; import leaveAndJoinWithAlert from '@mmproducts/calls/components/leave_and_join_alert'; @@ -118,11 +117,7 @@ const JoinCall = (props: Props) => { userIds={userIds} breakAt={1} listTitle={ - + {'Call participants'} } /> diff --git a/app/products/calls/components/leave_and_join_alert.tsx b/app/products/calls/components/leave_and_join_alert.tsx index 4c0de85c7..5c49a92aa 100644 --- a/app/products/calls/components/leave_and_join_alert.tsx +++ b/app/products/calls/components/leave_and_join_alert.tsx @@ -7,17 +7,14 @@ import {Alert} from 'react-native'; export default function leaveAndJoinWithAlert(intl: typeof IntlShape, channelId: string, callChannelName: string, currentChannelName: string, confirmToJoin: boolean, joinCall: (channelId: string) => void) { if (confirmToJoin) { Alert.alert( - intl.formatMessage({id: 'calls.confirm-to-join-title', defaultMessage: 'Are you sure you want to switch to a different call?'}), - intl.formatMessage({ - id: 'calls.confirm-to-join-description', - defaultMessage: 'You are already on a channel call in ~{callChannelName}. Do you want to leave your current call and join the call in ~{currentChannelName}?', - }, {callChannelName, currentChannelName}), + 'Are you sure you want to switch to a different call?', + `You are already on a channel call in ~${callChannelName}. Do you want to leave your current call and join the call in ~${currentChannelName}?`, [ { - text: intl.formatMessage({id: 'calls.confirm-to-join-cancel', defaultMessage: 'Cancel'}), + text: 'Cancel', }, { - text: intl.formatMessage({id: 'calls.confirm-to-join-leave-and-join', defaultMessage: 'Leave & Join'}), + text: 'Leave & Join', onPress: () => joinCall(channelId), style: 'cancel', }, diff --git a/app/products/calls/components/start_call/__snapshots__/start_call.test.js.snap b/app/products/calls/components/start_call/__snapshots__/start_call.test.js.snap index 056244493..27bc4df6c 100644 --- a/app/products/calls/components/start_call/__snapshots__/start_call.test.js.snap +++ b/app/products/calls/components/start_call/__snapshots__/start_call.test.js.snap @@ -40,7 +40,6 @@ exports[`StartCall should match snapshot 1`] = ` rightArrow={false} shouldRender={true} testID="test-id" - textId="mobile.channel_info.start_call" theme={ Object { "awayIndicator": "#ffbc1f", @@ -115,7 +114,6 @@ exports[`StartCall should match snapshot when there is already an ongoing call i rightArrow={false} shouldRender={true} testID="test-id" - textId="mobile.channel_info.join_ongoing_call" theme={ Object { "awayIndicator": "#ffbc1f", diff --git a/app/products/calls/components/start_call/start_call.tsx b/app/products/calls/components/start_call/start_call.tsx index 4b976f023..f84450310 100644 --- a/app/products/calls/components/start_call/start_call.tsx +++ b/app/products/calls/components/start_call/start_call.tsx @@ -8,7 +8,6 @@ import {Theme} from '@mm-redux/types/theme'; import leaveAndJoinWithAlert from '@mmproducts/calls/components/leave_and_join_alert'; import ChannelInfoRow from '@screens/channel_info/channel_info_row'; import Separator from '@screens/channel_info/separator'; -import {t} from '@utils/i18n'; import {preventDoubleTap} from '@utils/tap'; type Props = { @@ -50,7 +49,6 @@ const StartCall = (props: Props) => { action={handleStartCall} defaultMessage={ongoingCall ? 'Join Ongoing Call' : 'Start Call'} icon='phone-in-talk' - textId={ongoingCall ? t('mobile.channel_info.join_ongoing_call') : t('mobile.channel_info.start_call')} theme={theme} rightArrow={false} /> diff --git a/app/products/calls/connection.ts b/app/products/calls/connection.ts index a8b2a5c07..aa88b68e1 100644 --- a/app/products/calls/connection.ts +++ b/app/products/calls/connection.ts @@ -1,50 +1,51 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import {deflate} from 'pako/lib/deflate.js'; import InCallManager from 'react-native-incall-manager'; import { MediaStream, MediaStreamTrack, mediaDevices, -} from 'react-native-webrtc2'; +} from 'react-native-webrtc'; import {Client4} from '@client/rest'; import Peer from './simple-peer'; +import WebSocketClient from './websocket'; export let client: any = null; const websocketConnectTimeout = 3000; -function getWSConnectionURL(channelID: string): string { - let url = Client4.getAbsoluteUrl(`/plugins/com.mattermost.calls/${channelID}/ws`); - url = url.replace(/^https:/, 'wss:'); - url = url.replace(/^http:/, 'ws:'); - return url; -} - export async function newClient(channelID: string, closeCb: () => void, setScreenShareURL: (url: string) => void) { let peer: any = null; + let stream: MediaStream; + let voiceTrackAdded = false; + let voiceTrack: MediaStreamTrack | null = null; + let isClosed = false; const streams: MediaStream[] = []; - let stream: MediaStream; - let audioTrack: any; try { stream = await mediaDevices.getUserMedia({ video: false, audio: true, }) as MediaStream; - audioTrack = stream.getAudioTracks()[0]; - audioTrack.enabled = false; + voiceTrack = stream.getAudioTracks()[0]; + voiceTrack.enabled = false; streams.push(stream); } catch (err) { console.log('Unable to get media device:', err); // eslint-disable-line no-console } - const ws = new WebSocket(getWSConnectionURL(channelID)); + const ws = new WebSocketClient(Client4.getWebSocketUrl()); const disconnect = () => { - ws.close(); + if (!isClosed) { + ws.close(); + } streams.forEach((s) => { s.getTracks().forEach((track: MediaStreamTrack) => { @@ -63,43 +64,66 @@ export async function newClient(channelID: string, closeCb: () => void, setScree }; const mute = () => { - if (audioTrack) { - audioTrack.enabled = false; + if (!peer) { + return; + } + if (voiceTrackAdded) { + peer.replaceTrack(voiceTrack, null, stream); + } + if (voiceTrack) { + voiceTrack.enabled = false; } if (ws) { - ws.send(JSON.stringify({ - type: 'mute', - })); + ws.send('mute'); } }; const unmute = () => { - if (audioTrack) { - audioTrack.enabled = true; + if (!peer || !voiceTrack) { + return; } + if (voiceTrackAdded) { + peer.replaceTrack(voiceTrack, voiceTrack, stream); + } else { + peer.addStream(stream); + voiceTrackAdded = true; + } + voiceTrack.enabled = true; if (ws) { - ws.send(JSON.stringify({ - type: 'unmute', - })); + ws.send('unmute'); } }; - ws.onerror = (err) => console.log('WS ERROR', err); // eslint-disable-line no-console + ws.on('error', (err) => { + console.log('WS (CALLS) ERROR', err); // eslint-disable-line no-console + ws.close(); + }); + + ws.on('close', () => { + isClosed = true; + disconnect(); + }); + + ws.on('join', async () => { + let config; + try { + config = await Client4.getCallsConfig(); + } catch (err) { + console.log('ERROR FETCHING CALLS CONFIG:', err); // eslint-disable-line no-console + return; + } - ws.onopen = async () => { InCallManager.start({media: 'audio'}); - peer = new Peer(stream); + peer = new Peer(null, config.ICEServers); peer.on('signal', (data: any) => { if (data.type === 'offer' || data.type === 'answer') { - ws.send(JSON.stringify({ - type: 'signal', - data, - })); + ws.send('sdp', { + data: deflate(JSON.stringify(data)), + }, true); } else if (data.type === 'candidate') { - ws.send(JSON.stringify({ - type: 'ice', - data, - })); + ws.send('ice', { + data: JSON.stringify(data.candidate), + }); } }); @@ -110,15 +134,23 @@ export async function newClient(channelID: string, closeCb: () => void, setScree } }); - peer.on('error', (err: any) => console.log('PEER ERROR', err)); // eslint-disable-line no-console + peer.on('error', (err: any) => { + console.log('PEER ERROR', err); // eslint-disable-line no-console + }); + }); - ws.onmessage = ({data}) => { - const msg = JSON.parse(data); - if (msg.type === 'answer' || msg.type === 'offer') { - peer.signal(data); - } - }; - }; + ws.on('open', async () => { + ws.send('join', { + channelID, + }); + }); + + ws.on('message', ({data}) => { + const msg = JSON.parse(data); + if (msg.type === 'answer' || msg.type === 'offer') { + peer.signal(data); + } + }); const waitForReady = () => { const waitForReadyImpl = (callback: () => void, fail: () => void, timeout: number) => { @@ -127,7 +159,7 @@ export async function newClient(channelID: string, closeCb: () => void, setScree return; } setTimeout(() => { - if (ws.readyState === WebSocket.OPEN) { + if (ws.state() === WebSocket.OPEN) { callback(); } else { waitForReadyImpl(callback, fail, timeout - 10); diff --git a/app/products/calls/screens/call/__snapshots__/call_screen.test.js.snap b/app/products/calls/screens/call/__snapshots__/call_screen.test.js.snap index b0ffaa522..08ceff413 100644 --- a/app/products/calls/screens/call/__snapshots__/call_screen.test.js.snap +++ b/app/products/calls/screens/call/__snapshots__/call_screen.test.js.snap @@ -69,7 +69,7 @@ exports[`CallScreen Landscape should match snapshot 1`] = ` - + > + Leave + - + > + Chat thread + - + > + Settings + - + > + More + - + > + Unmute + @@ -445,21 +445,16 @@ exports[`CallScreen Landscape should match snapshot with screenshare 1`] = ` } } /> - + > + You are seeing User 2 screen + - + > + Leave + - + > + Chat thread + - + > + Settings + - + > + More + - + > + Unmute + @@ -736,7 +731,7 @@ exports[`CallScreen Portrait should match snapshot 1`] = ` - + > + Unmute + - + > + Leave + - + > + Chat thread + - + > + Settings + - + > + More + @@ -1147,7 +1142,7 @@ exports[`CallScreen Portrait should match snapshot with screenshare 1`] = ` muted={true} size="m" userId="user-2-id" - volume={1} + volume={0} /> - + > + You are seeing User 2 screen + - + > + Unmute + - + > + Leave + - + > + Chat thread + - + > + Settings + - + > + More + @@ -1495,21 +1485,16 @@ exports[`CallScreen should show controls in landscape view on click the screen s } } /> - + > + You are seeing User 2 screen + - + > + Leave + - + > + Chat thread + - + > + Settings + - + > + More + - + > + Unmute + @@ -1790,7 +1775,7 @@ exports[`CallScreen should show controls in landscape view on click the users li - + > + Leave + - + > + Chat thread + - + > + Settings + - + > + More + - + > + Unmute + diff --git a/app/products/calls/screens/call/call_screen.test.js b/app/products/calls/screens/call/call_screen.test.js index b2dbda5b2..f28578577 100644 --- a/app/products/calls/screens/call/call_screen.test.js +++ b/app/products/calls/screens/call/call_screen.test.js @@ -22,35 +22,38 @@ describe('CallScreen', () => { id: 'user-1-id', muted: false, isTalking: false, + profile: { + id: 'user-1-id', + username: 'user-1-username', + nickname: 'User 1', + }, }, 'user-2-id': { id: 'user-2-id', muted: true, isTalking: true, + profile: { + id: 'user-2-id', + username: 'user-2-username', + nickname: 'User 2', + }, }, }, channelId: 'channel-id', startTime: 100, speakers: 'user-2-id', - screenOn: false, + screenOn: '', threadId: false, }, - users: { - 'user-1-id': { - id: 'user-1-id', - username: 'user-1-username', - nickname: 'User 1', - }, - 'user-2-id': { - id: 'user-2-id', - username: 'user-2-username', - nickname: 'User 2', - }, - }, currentParticipant: { id: 'user-2-id', muted: true, isTalking: true, + profile: { + id: 'user-2-id', + username: 'user-2-username', + nickname: 'User 2', + }, }, teammateNameDisplay: Preferences.DISPLAY_PREFER_NICKNAME, screenShareURL: '', @@ -67,14 +70,14 @@ describe('CallScreen', () => { }); test('should show controls in landscape view on click the users list', () => { - const props = {...baseProps, call: {...baseProps.call, screenOn: false}}; + const props = {...baseProps, call: {...baseProps.call, screenOn: ''}}; const wrapper = shallow(); wrapper.find({testID: 'users-list'}).simulate('press'); expect(wrapper.getElement()).toMatchSnapshot(); }); test('should show controls in landscape view on click the screen share', () => { - const props = {...baseProps, call: {...baseProps.call, screenOn: true}, screenShareURL: 'screen-share-url'}; + const props = {...baseProps, call: {...baseProps.call, screenOn: 'user-2-id'}, screenShareURL: 'screen-share-url'}; const wrapper = shallow(); wrapper.find({testID: 'screen-share-container'}).simulate('press'); expect(wrapper.getElement()).toMatchSnapshot(); @@ -105,7 +108,7 @@ describe('CallScreen', () => { }); test('should match snapshot with screenshare', () => { - const props = {...baseProps, call: {...baseProps.call, screenOn: true}, screenShareURL: 'screen-share-url'}; + const props = {...baseProps, call: {...baseProps.call, screenOn: 'user-2-id'}, screenShareURL: 'screen-share-url'}; const wrapper = shallow(); expect(wrapper.getElement()).toMatchSnapshot(); diff --git a/app/products/calls/screens/call/call_screen.tsx b/app/products/calls/screens/call/call_screen.tsx index ed5fccdcb..c8ee7eeb4 100644 --- a/app/products/calls/screens/call/call_screen.tsx +++ b/app/products/calls/screens/call/call_screen.tsx @@ -2,12 +2,12 @@ // See LICENSE.txt for license information. import React, {useEffect, useCallback, useState} from 'react'; -import {Keyboard, View, Text, Platform, Pressable, SafeAreaView, ScrollView, useWindowDimensions} from 'react-native'; -import {RTCView} from 'react-native-webrtc2'; +import {Keyboard, View, Text, Platform, Pressable, SafeAreaView, ScrollView, useWindowDimensions, DeviceEventEmitter} from 'react-native'; +import {RTCView} from 'react-native-webrtc'; import {showModalOverCurrentContext, mergeNavigationOptions, popTopScreen, goToScreen} from '@actions/navigation'; import CompassIcon from '@components/compass_icon'; -import FormattedText from '@components/formatted_text'; +import {WebsocketEvents} from '@constants'; import {THREAD} from '@constants/screen'; import {GenericAction} from '@mm-redux/types/actions'; import {displayUsername} from '@mm-redux/utils/user_utils'; @@ -17,8 +17,7 @@ import {makeStyleSheetFromTheme} from '@utils/theme'; import type {Theme} from '@mm-redux/types/theme'; import type {UserProfile} from '@mm-redux/types/users'; -import type {IDMappedObjects} from '@mm-redux/types/utilities'; -import type {Call, CallParticipant} from '@mmproducts/calls/store/types/calls'; +import type {Call, CallParticipant, VoiceEventData} from '@mmproducts/calls/store/types/calls'; type Props = { actions: { @@ -28,7 +27,6 @@ type Props = { }; theme: Theme; call: Call|null; - users: IDMappedObjects; currentParticipant: CallParticipant; teammateNameDisplay: string; screenShareURL: string; @@ -204,9 +202,6 @@ const getStyleSheet = makeStyleSheetFromTheme((props: any) => { }); const CallScreen = (props: Props) => { - if (!props.call) { - return null; - } const {width, height} = useWindowDimensions(); const isLandscape = width > height; @@ -224,6 +219,27 @@ const CallScreen = (props: Props) => { }); }, []); + const [speaker, setSpeaker] = useState(null); + const handleVoiceOn = (data: VoiceEventData) => { + if (data.channelId === props.call?.channelId) { + setSpeaker(props.call.participants[data.userId].profile); + } + }; + const handleVoiceOff = (data: VoiceEventData) => { + if (data.channelId === props.call?.channelId && ((speaker?.id === 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); + return () => { + onVoiceOn.remove(); + onVoiceOff.remove(); + }; + }, [props.call]); + const showOtherActions = () => { const screen = 'CallOtherActions'; const passProps = { @@ -259,12 +275,16 @@ const CallScreen = (props: Props) => { props.actions.muteMyself(props.call.channelId); } } - }, [props.call.channelId, props.currentParticipant]); + }, [props.call?.channelId, props.currentParticipant]); const toggleControlsInLandscape = useCallback(() => { setShowControlsInLandscape(!showControlsInLandscape); }, [showControlsInLandscape]); + if (!props.call) { + return null; + } + let screenShareView = null; if (props.screenShareURL && props.call.screenOn) { screenShareView = ( @@ -277,12 +297,9 @@ const CallScreen = (props: Props) => { streamURL={props.screenShareURL} style={style.screenShareImage} /> - + >{`You are seeing ${displayUsername(props.call.participants[props.call.screenOn].profile, props.teammateNameDisplay)} screen`} ); } @@ -307,11 +324,11 @@ const CallScreen = (props: Props) => { > - {displayUsername(props.users[user.id], props.teammateNameDisplay)} + {displayUsername(props.call?.participants[user.id].profile, props.teammateNameDisplay)} ); })} @@ -354,17 +371,13 @@ const CallScreen = (props: Props) => { style={style.muteIcon} /> {props.currentParticipant?.muted && - } + >{'Unmute'}} {!props.currentParticipant?.muted && - } + >{'Mute'}} } { size={24} style={{...style.buttonIcon, ...style.hangUpIcon}} /> - + >{'Leave'} { size={24} style={style.buttonIcon} /> - + >{'Chat thread'} { size={24} style={style.buttonIcon} /> - + >{'Settings'} { size={24} style={style.buttonIcon} /> - + >{'More'} {isLandscape && { style={{...style.buttonIcon, ...style.muteIconLandscape}} /> {props.currentParticipant?.muted && - } + >{'Unmute'}} {!props.currentParticipant?.muted && - } + >{'Mute'}} } diff --git a/app/products/calls/screens/call/index.ts b/app/products/calls/screens/call/index.ts index 2901119fc..ddaa767ac 100644 --- a/app/products/calls/screens/call/index.ts +++ b/app/products/calls/screens/call/index.ts @@ -19,7 +19,6 @@ function mapStateToProps(state: GlobalState) { theme: getTheme(state), call: currentCall, teammateNameDisplay: getTeammateNameDisplaySetting(state), - users: state.entities.users.profiles, currentParticipant: currentCall && currentCall.participants[currentUserId], screenShareURL: getScreenShareURL(state), }; diff --git a/app/products/calls/screens/call_other_actions/call_other_actions.tsx b/app/products/calls/screens/call_other_actions/call_other_actions.tsx index 0f79e7cd4..1b697a28b 100644 --- a/app/products/calls/screens/call_other_actions/call_other_actions.tsx +++ b/app/products/calls/screens/call_other_actions/call_other_actions.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import React, {useCallback} from 'react'; -import {injectIntl, intlShape} from 'react-intl'; +import {injectIntl} from 'react-intl'; import {View} from 'react-native'; import {dismissModal} from '@actions/navigation'; @@ -13,10 +13,9 @@ import type {Theme} from '@mm-redux/types/theme'; type Props = { theme: Theme; - intl: typeof intlShape; } -const CallOtherActions = ({theme, intl}: Props) => { +const CallOtherActions = ({theme}: Props) => { const close = () => { dismissModal(); }; @@ -41,21 +40,21 @@ const CallOtherActions = ({theme, intl}: Props) => { destructive={false} icon='account-plus-outline' onPress={addParticipants} - text={intl.formatMessage({id: 'call.add_participants', defaultMessage: 'Add participants'})} + text='Add participants' theme={theme} /> diff --git a/app/products/calls/simple-peer.ts b/app/products/calls/simple-peer.ts index 62270321b..11aa7a2de 100644 --- a/app/products/calls/simple-peer.ts +++ b/app/products/calls/simple-peer.ts @@ -18,7 +18,7 @@ import { RTCSessionDescriptionType, MessageEvent, RTCIceCandidateType, -} from 'react-native-webrtc2'; +} from 'react-native-webrtc'; import stream from 'readable-stream'; const queueMicrotask = (callback: any) => { @@ -94,7 +94,7 @@ export default class Peer extends stream.Duplex { private pc: RTCPeerConnection|null = null; private onFinishBound?: () => void; - constructor(localStream: MediaStream) { + constructor(localStream: MediaStream | null, iceServers?: string[]) { super({allowHalfOpen: false}); this.streams = localStream ? [localStream] : []; @@ -103,18 +103,24 @@ export default class Peer extends stream.Duplex { this.onFinish(); }; + const connConfig = { + iceServers: [ + { + urls: [ + 'stun:stun.l.google.com:19302', + 'stun:global.stun.twilio.com:3478', + ], + }, + ], + sdpSemantics: 'unified-plan', + }; + + if (iceServers && iceServers.length > 0) { + connConfig.iceServers[0].urls = iceServers; + } + try { - this.pc = new RTCPeerConnection({ - iceServers: [ - { - urls: [ - 'stun:stun.l.google.com:19302', - 'stun:global.stun.twilio.com:3478', - ], - }, - ], - sdpSemantics: 'unified-plan', - }); + this.pc = new RTCPeerConnection(connConfig); } catch (err) { this.destroy(errCode(err, 'ERR_PC_CONSTRUCTOR')); return; @@ -318,11 +324,11 @@ export default class Peer extends stream.Duplex { * @param {MediaStreamTrack} track * @param {MediaStream} s */ - addTrack(track: MediaStreamTrack, s: MediaStream) { + async addTrack(track: MediaStreamTrack, s: MediaStream) { if (this.destroying) { return; } - if (this.destroyed) { + if (this.destroyed || !this.pc) { throw errCode( new Error('cannot addTrack after peer is destroyed'), 'ERR_DESTROYED', @@ -330,10 +336,11 @@ export default class Peer extends stream.Duplex { } const submap = this.senderMap.get(track) || new Map(); // nested Maps map [track, stream] to sender - let sender = submap.get(s); + const sender = submap.get(s); if (!sender) { - sender = s.addTrack(track); - submap.set(s, sender); + const transceiver = await this.pc.addTransceiver(track, {direction: 'sendrecv'}) as any; + /* eslint-disable no-underscore-dangle */ + submap.set(s, transceiver._sender); this.senderMap.set(track, submap); this.needsNegotiation(); } else if (sender.removed) { @@ -351,6 +358,36 @@ export default class Peer extends stream.Duplex { } } + /** + * Replace a MediaStreamTrack by another in the connection. + * @param {MediaStreamTrack} oldTrack + * @param {MediaStreamTrack} newTrack + * @param {MediaStream} stream + */ + replaceTrack(oldTrack: MediaStreamTrack, newTrack: MediaStreamTrack | null, s: MediaStream) { + if (this.destroying) { + return; + } + if (this.destroyed) { + throw errCode(new Error('cannot replaceTrack after peer is destroyed'), 'ERR_DESTROYED'); + } + + const submap = this.senderMap.get(oldTrack); + const sender = submap ? submap.get(s) : null; + if (!sender) { + throw errCode(new Error('Cannot replace track that was never added.'), 'ERR_TRACK_NOT_ADDED'); + } + if (newTrack) { + this.senderMap.set(newTrack, submap); + } + + if (sender.replaceTrack == null) { + this.destroy(errCode(new Error('replaceTrack is not supported in this browser'), 'ERR_UNSUPPORTED_REPLACETRACK')); + } else { + sender.replaceTrack(newTrack); + } + } + needsNegotiation() { if (this.batchedNegotiation) { return; diff --git a/app/products/calls/store/actions/calls.ts b/app/products/calls/store/actions/calls.ts index 0a595bb0f..b3736a1b0 100644 --- a/app/products/calls/store/actions/calls.ts +++ b/app/products/calls/store/actions/calls.ts @@ -31,8 +31,9 @@ export function loadCalls(): ActionFunc { if (channel.call) { callsResults[channel.channel_id] = { participants: channel.call.users.reduce((prev: Dictionary, cur: string, curIdx: number) => { + const profile = getState().entities.users.profiles[cur]; const muted = channel.call.states && channel.call.states[curIdx] ? !channel.call.states[curIdx].unmuted : true; - prev[cur] = {id: cur, muted, isTalking: false}; + prev[cur] = {id: cur, muted, isTalking: false, profile}; return prev; }, {}), channelId: channel.channel_id, diff --git a/app/products/calls/store/actions/websockets.ts b/app/products/calls/store/actions/websockets.ts index d798a58ae..972bc7846 100644 --- a/app/products/calls/store/actions/websockets.ts +++ b/app/products/calls/store/actions/websockets.ts @@ -1,7 +1,11 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {GenericAction} from '@mm-redux/types/actions'; +import {DeviceEventEmitter} from 'react-native'; + +import {WebsocketEvents} from '@constants'; +import {getMissingProfilesByIds} from '@mm-redux/actions/users'; +import {GenericAction, DispatchFunc, GetStateFunc} from '@mm-redux/types/actions'; import {WebSocketMessage} from '@mm-redux/types/websocket'; import CallsTypes from '@mmproducts/calls/store/action_types/calls'; @@ -12,11 +16,13 @@ export function handleCallUserDisconnected(msg: WebSocketMessage): GenericAction }; } -export function handleCallUserConnected(msg: WebSocketMessage): GenericAction { - return { +export async function handleCallUserConnected(dispatch: DispatchFunc, getState: GetStateFunc, msg: WebSocketMessage) { + await dispatch(getMissingProfilesByIds([msg.data.userID])); + const profile = getState().entities.users.profiles[msg.data.userID]; + dispatch({ type: CallsTypes.RECEIVED_JOINED_CALL, - data: {channelId: msg.broadcast.channel_id, userId: msg.data.userID}, - }; + data: {channelId: msg.broadcast.channel_id, userId: msg.data.userID, profile}, + }); } export function handleCallUserMuted(msg: WebSocketMessage): GenericAction { @@ -33,18 +39,12 @@ export function handleCallUserUnmuted(msg: WebSocketMessage): GenericAction { }; } -export function handleCallUserVoiceOn(msg: WebSocketMessage): GenericAction { - return { - type: CallsTypes.RECEIVED_VOICE_ON_USER_CALL, - data: {channelId: msg.broadcast.channel_id, userId: msg.data.userID}, - }; +export function handleCallUserVoiceOn(msg: WebSocketMessage) { + DeviceEventEmitter.emit(WebsocketEvents.CALLS_USER_VOICE_ON, {channelId: msg.broadcast.channel_id, userId: msg.data.userID}); } -export function handleCallUserVoiceOff(msg: WebSocketMessage): GenericAction { - return { - type: CallsTypes.RECEIVED_VOICE_OFF_USER_CALL, - data: {channelId: msg.broadcast.channel_id, userId: msg.data.userID}, - }; +export function handleCallUserVoiceOff(msg: WebSocketMessage) { + DeviceEventEmitter.emit(WebsocketEvents.CALLS_USER_VOICE_OFF, {channelId: msg.broadcast.channel_id, userId: msg.data.userID}); } export function handleCallStarted(msg: WebSocketMessage): GenericAction { diff --git a/app/products/calls/store/reducers/calls.test.js b/app/products/calls/store/reducers/calls.test.js index b29fe203a..fb8de8541 100644 --- a/app/products/calls/store/reducers/calls.test.js +++ b/app/products/calls/store/reducers/calls.test.js @@ -10,8 +10,8 @@ import callsReducer from './calls'; describe('Reducers.calls.calls', () => { const call1 = { participants: { - 'user-1': {id: 'user-1', muted: false, isTalking: false}, - 'user-2': {id: 'user-2', muted: true, isTalking: true}, + 'user-1': {id: 'user-1', muted: false, isTalking: false, profile: {id: 'user-1'}}, + 'user-2': {id: 'user-2', muted: true, isTalking: true, profile: {id: 'user-2'}}, }, channelId: 'channel-1', startTime: 123, @@ -21,8 +21,8 @@ describe('Reducers.calls.calls', () => { }; const call2 = { participants: { - 'user-3': {id: 'user-3', muted: false, isTalking: false}, - 'user-4': {id: 'user-4', muted: true, isTalking: true}, + 'user-3': {id: 'user-3', muted: false, isTalking: false, profile: {id: 'user-3'}}, + 'user-4': {id: 'user-4', muted: true, isTalking: true, profile: {id: 'user-4'}}, }, channelId: 'channel-2', startTime: 123, @@ -32,8 +32,8 @@ describe('Reducers.calls.calls', () => { }; const call3 = { participants: { - 'user-5': {id: 'user-5', muted: false, isTalking: false}, - 'user-6': {id: 'user-6', muted: true, isTalking: true}, + 'user-5': {id: 'user-5', muted: false, isTalking: false, profile: {id: 'user-5'}}, + 'user-6': {id: 'user-6', muted: true, isTalking: true, profile: {id: 'user-6'}}, }, channelId: 'channel-3', startTime: 123, @@ -71,7 +71,7 @@ describe('Reducers.calls.calls', () => { { 'channel-1': { participants: { - 'user-2': {id: 'user-2', muted: true, isTalking: true}, + 'user-2': {id: 'user-2', muted: true, isTalking: true, profile: {id: 'user-2'}}, }, channelId: 'channel-1', startTime: 123, @@ -97,7 +97,7 @@ describe('Reducers.calls.calls', () => { const initialState = {calls: {'channel-1': call1}}; const testAction = { type: CallsTypes.RECEIVED_JOINED_CALL, - data: {channelId: 'channel-1', userId: 'user-3'}, + data: {channelId: 'channel-1', userId: 'user-3', profile: {id: 'user-3'}}, }; let state = callsReducer(initialState, testAction); assert.deepEqual( @@ -105,9 +105,9 @@ describe('Reducers.calls.calls', () => { { 'channel-1': { participants: { - 'user-1': {id: 'user-1', muted: false, isTalking: false}, - 'user-2': {id: 'user-2', muted: true, isTalking: true}, - 'user-3': {id: 'user-3', muted: true, isTalking: false}, + 'user-1': {id: 'user-1', muted: false, isTalking: false, profile: {id: 'user-1'}}, + 'user-2': {id: 'user-2', muted: true, isTalking: true, profile: {id: 'user-2'}}, + 'user-3': {id: 'user-3', muted: true, isTalking: false, profile: {id: 'user-3'}}, }, channelId: 'channel-1', startTime: 123, @@ -118,7 +118,7 @@ describe('Reducers.calls.calls', () => { }, ); - testAction.data = {channelId: 'invalid-channel', userId: 'user-1'}; + testAction.data = {channelId: 'invalid-channel', userId: 'user-1', profile: {id: 'user-1'}}; state = callsReducer(initialState, testAction); assert.deepEqual(state.calls, {'channel-1': call1}); @@ -179,44 +179,6 @@ describe('Reducers.calls.calls', () => { state = callsReducer(initialState, testAction); assert.deepEqual(state.calls, initialState.calls); }); - - it('RECEIVED_VOICE_ON_USER_CALL', async () => { - const initialState = {calls: {'channel-1': call1, 'channel-2': call2}}; - const testAction = { - type: CallsTypes.RECEIVED_VOICE_ON_USER_CALL, - data: {channelId: 'channel-1', userId: 'user-1'}, - }; - let state = callsReducer(initialState, testAction); - assert.equal(state.calls['channel-1'].participants['user-1'].isTalking, true); - assert.deepEqual(state.calls['channel-1'].speakers, ['user-1', 'user-2']); - - testAction.data = {channelId: 'channel-1', userId: 'invalidUser'}; - state = callsReducer(initialState, testAction); - assert.deepEqual(state.calls, initialState.calls); - - testAction.data = {channelId: 'invalid-channel', userId: 'user-2'}; - state = callsReducer(initialState, testAction); - assert.deepEqual(state.calls, initialState.calls); - }); - - it('RECEIVED_VOICE_OFF_USER_CALL', async () => { - const initialState = {calls: {'channel-1': call1, 'channel-2': call2}}; - const testAction = { - type: CallsTypes.RECEIVED_VOICE_OFF_USER_CALL, - data: {channelId: 'channel-1', userId: 'user-2'}, - }; - let state = callsReducer(initialState, testAction); - assert.equal(state.calls['channel-1'].participants['user-2'].isTalking, false); - assert.deepEqual(state.calls['channel-1'].speakers, []); - - testAction.data = {channelId: 'channel-1', userId: 'invalidUser'}; - state = callsReducer(initialState, testAction); - assert.deepEqual(state.calls, initialState.calls); - - testAction.data = {channelId: 'invalid-channel', userId: 'user-2'}; - state = callsReducer(initialState, testAction); - assert.deepEqual(state.calls, initialState.calls); - }); it('RECEIVED_CHANNEL_CALL_SCREEN_ON', async () => { const initialState = {calls: {'channel-1': call1, 'channel-2': call2}}; const testAction = { diff --git a/app/products/calls/store/reducers/calls.ts b/app/products/calls/store/reducers/calls.ts index e568f016e..db72040c5 100644 --- a/app/products/calls/store/reducers/calls.ts +++ b/app/products/calls/store/reducers/calls.ts @@ -31,7 +31,7 @@ function calls(state: Dictionary = {}, action: GenericAction) { return nextState; } case CallsTypes.RECEIVED_JOINED_CALL: { - const {channelId, userId} = action.data; + const {channelId, userId, profile} = action.data; if (!state[channelId]) { return state; } @@ -40,6 +40,7 @@ function calls(state: Dictionary = {}, action: GenericAction) { id: userId, muted: true, isTalking: false, + profile, }; const nextState = {...state}; nextState[channelId] = channelUpdate; @@ -87,41 +88,6 @@ function calls(state: Dictionary = {}, action: GenericAction) { nextState[channelId] = channelUpdate; return nextState; } - case CallsTypes.RECEIVED_VOICE_ON_USER_CALL: { - const {channelId, userId} = action.data; - if (!state[channelId]) { - return state; - } - if (!state[channelId].participants[userId]) { - return state; - } - const userUpdate = {...state[channelId].participants[userId], isTalking: true}; - const channelUpdate = {...state[channelId], participants: {...state[channelId].participants}}; - channelUpdate.participants[userId] = userUpdate; - channelUpdate.speakers = [userId, ...(channelUpdate.speakers || [])]; - const nextState = {...state}; - nextState[channelId] = channelUpdate; - return nextState; - } - case CallsTypes.RECEIVED_VOICE_OFF_USER_CALL: { - const {channelId, userId} = action.data; - if (!state[channelId]) { - return state; - } - if (!state[channelId].participants[userId]) { - return state; - } - const userUpdate = {...state[channelId].participants[userId], isTalking: false}; - const channelUpdate = {...state[channelId], participants: {...state[channelId].participants}}; - channelUpdate.participants[userId] = userUpdate; - channelUpdate.speakers = channelUpdate.speakers?.filter((id) => id !== userId); - if (!channelUpdate.speakers) { - channelUpdate.speakers = []; - } - const nextState = {...state}; - nextState[channelId] = channelUpdate; - return nextState; - } case CallsTypes.RECEIVED_CHANNEL_CALL_SCREEN_ON: { const {channelId, userId} = action.data; if (!state[channelId]) { diff --git a/app/products/calls/store/types/calls.ts b/app/products/calls/store/types/calls.ts index a81210a93..04bbeb152 100644 --- a/app/products/calls/store/types/calls.ts +++ b/app/products/calls/store/types/calls.ts @@ -1,6 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {UserProfile} from '@mm-redux/types/users'; import {Dictionary} from '@mm-redux/types/utilities'; export type CallsState = { @@ -23,6 +24,7 @@ export type CallParticipant = { id: string; muted: boolean; isTalking: boolean; + profile: UserProfile; } export type ServerChannelState = { @@ -44,3 +46,8 @@ export type ServerCallState = { thread_id: string; screen_sharing_id: string; } + +export type VoiceEventData = { + channelId: string; + userId: string; +} diff --git a/app/products/calls/websocket.ts b/app/products/calls/websocket.ts new file mode 100644 index 000000000..69536a5c4 --- /dev/null +++ b/app/products/calls/websocket.ts @@ -0,0 +1,100 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import {EventEmitter} from 'events'; + +import {encode} from '@msgpack/msgpack/dist'; + +export default class WebSocketClient extends EventEmitter { + private ws: WebSocket | null; + private seqNo = 0; + private connID = ''; + private eventPrefix = 'custom_com.mattermost.calls'; + + constructor(connURL: string) { + super(); + this.ws = new WebSocket(connURL); + + this.ws.onerror = (err) => { + this.emit('error', err); + this.ws = null; + this.close(); + }; + + this.ws.onclose = () => { + this.ws = null; + this.close(); + }; + + this.ws.onmessage = ({data}) => { + if (!data) { + return; + } + let msg; + try { + msg = JSON.parse(data); + } catch (err) { + console.log(err); // eslint-disable-line no-console + } + + if (!msg || !msg.event || !msg.data) { + return; + } + + if (msg.event === 'hello') { + this.connID = msg.data.connection_id; + this.emit('open'); + return; + } else if (!this.connID) { + return; + } + + if (msg.data.connID !== this.connID) { + return; + } + + if (msg.event === this.eventPrefix + '_join') { + this.emit('join'); + } + + if (msg.event === this.eventPrefix + '_error') { + this.emit('error', msg.data); + } + + if (msg.event === this.eventPrefix + '_signal') { + this.emit('message', msg.data); + } + }; + } + + send(action: string, data?: Object, binary?: boolean) { + const msg = { + action: `${this.eventPrefix}_${action}`, + seq: this.seqNo++, + data, + }; + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + if (binary) { + this.ws.send(encode(msg)); + } else { + this.ws.send(JSON.stringify(msg)); + } + } + } + + close() { + if (this.ws) { + this.ws.close(); + this.ws = null; + } + this.seqNo = 0; + this.connID = ''; + this.emit('close'); + } + + state() { + if (!this.ws) { + return WebSocket.CLOSED; + } + return this.ws.readyState; + } +} diff --git a/app/screens/channel_info/channel_info.js b/app/screens/channel_info/channel_info.js index 0c1a97e23..34c13ac24 100644 --- a/app/screens/channel_info/channel_info.js +++ b/app/screens/channel_info/channel_info.js @@ -51,6 +51,7 @@ export default class ChannelInfo extends PureComponent { currentUserId: PropTypes.string, isTeammateGuest: PropTypes.bool.isRequired, isDirectMessage: PropTypes.bool.isRequired, + isGroupMessage: PropTypes.bool.isRequired, teammateId: PropTypes.string, theme: PropTypes.object.isRequired, customStatus: PropTypes.object, @@ -116,7 +117,7 @@ export default class ChannelInfo extends PureComponent { }; actionsRows = (channelIsArchived) => { - const {currentChannel, currentUserId, isDirectMessage, theme, isCallsEnabled, callsFeatureEnabled, isChannelAdmin} = this.props; + const {currentChannel, currentUserId, isDirectMessage, isGroupMessage, theme, isCallsEnabled, callsFeatureEnabled, isChannelAdmin} = this.props; if (channelIsArchived) { return ( @@ -191,7 +192,7 @@ export default class ChannelInfo extends PureComponent { testID='channel_info.start_call.action' theme={theme} onPress={this.toggleCalls} - canEnableDisableCalls={isChannelAdmin} + canEnableDisableCalls={isDirectMessage || isGroupMessage || isChannelAdmin} enabled={isCallsEnabled} /> } diff --git a/app/screens/channel_info/index.js b/app/screens/channel_info/index.js index 9a1c04124..3f02bee0c 100644 --- a/app/screens/channel_info/index.js +++ b/app/screens/channel_info/index.js @@ -56,7 +56,8 @@ function makeMapStateToProps() { customStatusExpirySupported = customStatusEnabled ? isCustomStatusExpirySupported(state) : false; } - if (currentChannel.type === General.GM_CHANNEL) { + const isGroupMessage = currentChannel.type === General.GM_CHANNEL; + if (isGroupMessage) { currentChannelMemberCount = currentChannel.display_name.split(',').length; } @@ -70,6 +71,7 @@ function makeMapStateToProps() { currentUserId, isTeammateGuest, isDirectMessage, + isGroupMessage, teammateId, theme: getTheme(state), customStatus, diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 5f2e66fdd..561994d48 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -57,27 +57,6 @@ "apps.suggestion.no_static": "No matching options.", "apps.suggestion.no_suggestion": "No matching suggestions.", "archivedChannelMessage": "You are viewing an **archived channel**. New messages cannot be posted.", - "call_message.call_ended": "Call ended", - "call_message.call_ended_at": "Ended at {time}", - "call_message.call_lasted": "Lasted {duration}", - "call_message.current_call": "Current Call", - "call_message.join_call": "Join Call", - "call_message.started_a_call": "{user} started a call", - "call.add_participants": "Add participants", - "call.chat_thread": "Chat thread", - "call.copy_call_link": "Copy call link", - "call.give_feedback": "Give Feedback", - "call.leave": "Leave", - "call.more": "More", - "call.mute": "Mute", - "call.screen_share_user": "You are seing {userDisplayName} screen", - "call.settings": "Settings", - "call.unmute": "Unmute", - "calls.confirm-to-join-cancel": "Cancel", - "calls.confirm-to-join-description": "You are already on a channel call in ~{callChannelName}. Do you want to leave your current call and join the call in ~{currentChannelName}?", - "calls.confirm-to-join-leave-and-join": "Leave & Join", - "calls.confirm-to-join-title": "Are you sure you want to switch to a different call?", - "calls.join_call.participants_list_header": "CALL PARTICIPANTS", "camera_type.photo.option": "Capture Photo", "camera_type.title": "Choose an action", "camera_type.video.option": "Record Video", @@ -156,8 +135,6 @@ "create_comment.addComment": "Add a comment...", "create_post.deactivated": "You are viewing an archived channel with a deactivated user.", "create_post.write": "Write to {channelDisplayName}", - "current_call.channel-name": "~{channelName}", - "current_call.user-is-speaking": "{username} is speaking", "custom_status.expiry_dropdown.custom": "Custom", "custom_status.expiry_dropdown.date_and_time": "Date and Time", "custom_status.expiry_dropdown.dont_clear": "Don't clear", @@ -318,13 +295,9 @@ "mobile.channel_info.copy_header": "Copy Header", "mobile.channel_info.copy_purpose": "Copy Purpose", "mobile.channel_info.delete_failed": "We couldn't archive the channel {displayName}. Please check your connection and try again.", - "mobile.channel_info.disable_calls": "Disable Calls", "mobile.channel_info.edit": "Edit Channel", - "mobile.channel_info.enable_calls": "Enable Calls", - "mobile.channel_info.join_ongoing_call": "Join Ongoing Call", "mobile.channel_info.privateChannel": "Private Channel", "mobile.channel_info.publicChannel": "Public Channel", - "mobile.channel_info.start_call": "Start Call", "mobile.channel_info.unarchive_failed": "We couldn't unarchive the channel {displayName}. Please check your connection and try again.", "mobile.channel_list.alertNo": "No", "mobile.channel_list.alertYes": "Yes", diff --git a/ios/Podfile.lock b/ios/Podfile.lock index b0e7913e6..7a1f31efb 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -276,7 +276,7 @@ PODS: - react-native-video/Video (= 5.2.0) - react-native-video/Video (5.2.0): - React-Core - - react-native-webrtc2 (1.84.5): + - react-native-webrtc (1.75.3): - React - react-native-webview (11.15.0): - React-Core @@ -492,7 +492,7 @@ DEPENDENCIES: - react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`) - react-native-startup-time (from `../node_modules/react-native-startup-time`) - react-native-video (from `../node_modules/react-native-video`) - - react-native-webrtc2 (from `../node_modules/react-native-webrtc2`) + - react-native-webrtc (from `../node_modules/react-native-webrtc`) - react-native-webview (from `../node_modules/react-native-webview`) - React-perflogger (from `../node_modules/react-native/ReactCommon/reactperflogger`) - React-RCTActionSheet (from `../node_modules/react-native/Libraries/ActionSheetIOS`) @@ -631,8 +631,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-startup-time" react-native-video: :path: "../node_modules/react-native-video" - react-native-webrtc2: - :path: "../node_modules/react-native-webrtc2" + react-native-webrtc: + :path: "../node_modules/react-native-webrtc" react-native-webview: :path: "../node_modules/react-native-webview" React-perflogger: @@ -766,7 +766,7 @@ SPEC CHECKSUMS: react-native-safe-area-context: 584dc04881deb49474363f3be89e4ca0e854c057 react-native-startup-time: 1a068b744ce5097a85ebe0fbff691b05961c324d react-native-video: a4c2635d0802f983594b7057e1bce8f442f0ad28 - react-native-webrtc2: b3f1d5504c8bbd3aa4ce9b6c3ce0fbc47c7e146a + react-native-webrtc: 86d841823e66d68cc1f86712db1c2956056bf0c2 react-native-webview: e89bf2dba26a04cda967814df3ed1be99f291233 React-perflogger: 93075d8931c32cd1fce8a98c15d2d5ccc4d891bd React-RCTActionSheet: 7d3041e6761b4f3044a37079ddcb156575fb6d89 diff --git a/package-lock.json b/package-lock.json index e9b2b4314..4dc3894de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,11 +5,13 @@ "requires": true, "packages": { "": { + "name": "mattermost-mobile", "version": "1.48.1", "hasInstallScript": true, "license": "Apache 2.0", "dependencies": { "@mattermost/react-native-paste-input": "0.3.5", + "@msgpack/msgpack": "2.7.1", "@react-native-async-storage/async-storage": "1.15.14", "@react-native-community/cameraroll": "4.1.2", "@react-native-community/clipboard": "1.5.1", @@ -37,6 +39,7 @@ "jail-monkey": "2.6.0", "mime-db": "1.51.0", "moment-timezone": "0.5.34", + "pako": "2.0.4", "prop-types": "15.8.0", "react": "17.0.2", "react-intl": "2.8.0", @@ -79,7 +82,7 @@ "react-native-svg": "12.1.1", "react-native-vector-icons": "9.0.0", "react-native-video": "5.2.0", - "react-native-webrtc2": "1.84.5", + "react-native-webrtc": "github:streamer45/react-native-webrtc", "react-native-webview": "11.15.0", "react-native-youtube": "2.0.2", "react-redux": "7.2.6", @@ -2715,6 +2718,14 @@ "react-native": "*" } }, + "node_modules/@msgpack/msgpack": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-2.7.1.tgz", + "integrity": "sha512-ApwiSL2c9ObewdOE/sqt788P1C5lomBOHyO8nUBCr4ofErBCnYQ003NtJ8lS9OQZc11ximkbmgAZJjB8y6cCdA==", + "engines": { + "node": ">= 10" + } + }, "node_modules/@nicolo-ribaudo/chokidar-2": { "version": "2.1.8-no-fsevents.3", "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/chokidar-2/-/chokidar-2-2.1.8-no-fsevents.3.tgz", @@ -6450,6 +6461,13 @@ "pako": "~1.0.5" } }, + "node_modules/browserify-zlib/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true, + "peer": true + }, "node_modules/browserslist": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.18.1.tgz", @@ -17401,7 +17419,6 @@ "node_modules/mmjstool": { "version": "1.0.0", "resolved": "git+ssh://git@github.com/mattermost/mattermost-utilities.git#3faa6075089a541d8c90ed85114e644c7a23fedf", - "integrity": "sha512-84xbA6yRt+pEbYk/OmM9sbJQp7PkSAgqvn0N/QXuj1RKR+upxdAa6vTbTTBA+XweECN6xebCWncGJJiqWrtR1g==", "dev": true, "dependencies": { "estree-walk": "2.2.0", @@ -19230,11 +19247,9 @@ } }, "node_modules/pako": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", - "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", - "dev": true, - "peer": true + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-v8tweI900AUkZN6heMU/4Uy4cXRc2AYNRggVmTR+dEncawDJgCdLMximOVA2p4qO57WMynangsfGRb5WD6L1Bg==" }, "node_modules/parallel-transform": { "version": "1.2.0", @@ -20743,10 +20758,23 @@ "shaka-player": "^2.5.9" } }, - "node_modules/react-native-webrtc2": { - "version": "1.84.5", - "resolved": "https://registry.npmjs.org/react-native-webrtc2/-/react-native-webrtc2-1.84.5.tgz", - "integrity": "sha512-3M777RkkjQGnmDu0LrpFxfxmu8SRFfJqozxlBMPMpi91aoqQ77Ut/BH3xYnrYs3vWx/VtZdPYOxYCfrumI8WiA==" + "node_modules/react-native-webrtc": { + "version": "1.75.3", + "resolved": "git+ssh://git@github.com/streamer45/react-native-webrtc.git#7fe7d434892e6b29c5a6086d30b10f482db1e592", + "dependencies": { + "base64-js": "^1.1.2", + "event-target-shim": "^1.0.5", + "prop-types": "^15.5.10", + "uuid": "^3.3.2" + }, + "peerDependencies": { + "react-native": ">=0.40.0" + } + }, + "node_modules/react-native-webrtc/node_modules/event-target-shim": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-1.1.1.tgz", + "integrity": "sha1-qG5e5r2qFgVEddp5fM3fDFVphJE=" }, "node_modules/react-native-webview": { "version": "11.15.0", @@ -28089,6 +28117,11 @@ "requires": { } }, + "@msgpack/msgpack": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-2.7.1.tgz", + "integrity": "sha512-ApwiSL2c9ObewdOE/sqt788P1C5lomBOHyO8nUBCr4ofErBCnYQ003NtJ8lS9OQZc11ximkbmgAZJjB8y6cCdA==" + }, "@nicolo-ribaudo/chokidar-2": { "version": "2.1.8-no-fsevents.3", "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/chokidar-2/-/chokidar-2-2.1.8-no-fsevents.3.tgz", @@ -31097,6 +31130,15 @@ "peer": true, "requires": { "pako": "~1.0.5" + }, + "dependencies": { + "pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true, + "peer": true + } } }, "browserslist": { @@ -39755,7 +39797,6 @@ }, "mmjstool": { "version": "git+ssh://git@github.com/mattermost/mattermost-utilities.git#3faa6075089a541d8c90ed85114e644c7a23fedf", - "integrity": "sha512-84xbA6yRt+pEbYk/OmM9sbJQp7PkSAgqvn0N/QXuj1RKR+upxdAa6vTbTTBA+XweECN6xebCWncGJJiqWrtR1g==", "dev": true, "from": "mmjstool@git://github.com/mattermost/mattermost-utilities#3faa6075089a541d8c90ed85114e644c7a23fedf", "requires": { @@ -41188,11 +41229,9 @@ } }, "pako": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", - "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", - "dev": true, - "peer": true + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-v8tweI900AUkZN6heMU/4Uy4cXRc2AYNRggVmTR+dEncawDJgCdLMximOVA2p4qO57WMynangsfGRb5WD6L1Bg==" }, "parallel-transform": { "version": "1.2.0", @@ -42493,10 +42532,22 @@ "shaka-player": "^2.5.9" } }, - "react-native-webrtc2": { - "version": "1.84.5", - "resolved": "https://registry.npmjs.org/react-native-webrtc2/-/react-native-webrtc2-1.84.5.tgz", - "integrity": "sha512-3M777RkkjQGnmDu0LrpFxfxmu8SRFfJqozxlBMPMpi91aoqQ77Ut/BH3xYnrYs3vWx/VtZdPYOxYCfrumI8WiA==" + "react-native-webrtc": { + "version": "git+ssh://git@github.com/streamer45/react-native-webrtc.git#7fe7d434892e6b29c5a6086d30b10f482db1e592", + "from": "react-native-webrtc@github:streamer45/react-native-webrtc", + "requires": { + "base64-js": "^1.1.2", + "event-target-shim": "^1.0.5", + "prop-types": "^15.5.10", + "uuid": "^3.3.2" + }, + "dependencies": { + "event-target-shim": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-1.1.1.tgz", + "integrity": "sha1-qG5e5r2qFgVEddp5fM3fDFVphJE=" + } + } }, "react-native-webview": { "version": "11.15.0", diff --git a/package.json b/package.json index ad7270454..b38d38b17 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "private": true, "dependencies": { "@mattermost/react-native-paste-input": "0.3.5", + "@msgpack/msgpack": "2.7.1", "@react-native-async-storage/async-storage": "1.15.14", "@react-native-community/cameraroll": "4.1.2", "@react-native-community/clipboard": "1.5.1", @@ -35,6 +36,7 @@ "jail-monkey": "2.6.0", "mime-db": "1.51.0", "moment-timezone": "0.5.34", + "pako": "2.0.4", "prop-types": "15.8.0", "react": "17.0.2", "react-intl": "2.8.0", @@ -77,7 +79,7 @@ "react-native-svg": "12.1.1", "react-native-vector-icons": "9.0.0", "react-native-video": "5.2.0", - "react-native-webrtc2": "1.84.5", + "react-native-webrtc": "github:streamer45/react-native-webrtc", "react-native-webview": "11.15.0", "react-native-youtube": "2.0.2", "react-redux": "7.2.6", diff --git a/types/modules/react-native-webrtc2.d.ts b/types/modules/react-native-webrtc.d.ts similarity index 98% rename from types/modules/react-native-webrtc2.d.ts rename to types/modules/react-native-webrtc.d.ts index f5e71102d..60f3314e5 100644 --- a/types/modules/react-native-webrtc2.d.ts +++ b/types/modules/react-native-webrtc.d.ts @@ -8,7 +8,7 @@ // Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped // TypeScript Version: 2.8 -declare module 'react-native-webrtc2' { +declare module 'react-native-webrtc' { export const RTCView: any; export type RTCSignalingState = | 'stable' @@ -162,6 +162,8 @@ declare module 'react-native-webrtc2' { addStream(stream: MediaStream): void; + addTrack(track: MediaStreamTrack): void; + addTransceiver(kind: 'audio'|'video'|MediaStreamTrack, init: any): void; removeStream(stream: MediaStream): void;