diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index b3cb70322..8a2dd8456 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -9,6 +9,10 @@ + + + + { + return (dispatch: DispatchFunc, getState: GetStateFunc) => { switch (msg.event) { case WebsocketEvents.POSTED: case WebsocketEvents.EPHEMERAL_MESSAGE: @@ -421,6 +439,33 @@ function handleEvent(msg: WebSocketMessage) { return dispatch(handleSidebarCategoryOrderUpdated(msg)); } + if (getFeatureFlagValue(getState(), 'CallsMobile') === 'true') { + switch (msg.event) { + case WebsocketEvents.CALLS_CHANNEL_ENABLED: + return dispatch(handleCallChannelEnabled(msg)); + case WebsocketEvents.CALLS_CHANNEL_DISABLED: + return dispatch(handleCallChannelDisabled(msg)); + case WebsocketEvents.CALLS_USER_CONNECTED: + return dispatch(handleCallUserConnected(msg)); + case WebsocketEvents.CALLS_USER_DISCONNECTED: + return dispatch(handleCallUserDisconnected(msg)); + case WebsocketEvents.CALLS_USER_MUTED: + return dispatch(handleCallUserMuted(msg)); + case WebsocketEvents.CALLS_USER_UNMUTED: + return dispatch(handleCallUserUnmuted(msg)); + case WebsocketEvents.CALLS_USER_VOICE_ON: + return dispatch(handleCallUserVoiceOn(msg)); + case WebsocketEvents.CALLS_USER_VOICE_OFF: + return dispatch(handleCallUserVoiceOff(msg)); + case WebsocketEvents.CALLS_CALL_START: + return dispatch(handleCallStarted(msg)); + case WebsocketEvents.CALLS_SCREEN_ON: + return dispatch(handleCallScreenOn(msg)); + case WebsocketEvents.CALLS_SCREEN_OFF: + return dispatch(handleCallScreenOff(msg)); + } + } + return {data: true}; }; } diff --git a/app/client/rest/base.ts b/app/client/rest/base.ts index 342e0971a..9647378cb 100644 --- a/app/client/rest/base.ts +++ b/app/client/rest/base.ts @@ -290,6 +290,10 @@ export default class ClientBase { return `${this.url}/plugins/com.mattermost.apps`; } + getCallsRoute() { + return `${this.url}/plugins/com.mattermost.calls`; + } + // Client Helpers handleRedirectProtocol = (url: string, response: RNFetchBlobFetchRepsonse) => { const serverUrl = this.getUrl(); diff --git a/app/client/rest/index.ts b/app/client/rest/index.ts index d78413699..2b125c9ba 100644 --- a/app/client/rest/index.ts +++ b/app/client/rest/index.ts @@ -1,6 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import ClientCalls, {ClientCallsMix} from '@mmproducts/calls/client/rest'; import mix from '@utils/mix'; import ClientApps, {ClientAppsMix} from './apps'; @@ -34,7 +35,8 @@ interface Client extends ClientBase, ClientSharedChannelsMix, ClientTeamsMix, ClientTosMix, - ClientUsersMix + ClientUsersMix, + ClientCallsMix {} class Client extends mix(ClientBase).with( @@ -52,6 +54,7 @@ class Client extends mix(ClientBase).with( ClientTeams, ClientTos, ClientUsers, + ClientCalls, ) {} const Client4 = new Client(); diff --git a/app/components/__snapshots__/formatted_relative_time.test.js.snap b/app/components/__snapshots__/formatted_relative_time.test.js.snap new file mode 100644 index 000000000..54dbbad25 --- /dev/null +++ b/app/components/__snapshots__/formatted_relative_time.test.js.snap @@ -0,0 +1,7 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FormattedRelativeTime should match snapshot 1`] = ` + + a few seconds ago + +`; diff --git a/app/components/avatars/avatars.tsx b/app/components/avatars/avatars.tsx index fd5e9cb06..70dc84f5e 100644 --- a/app/components/avatars/avatars.tsx +++ b/app/components/avatars/avatars.tsx @@ -86,6 +86,7 @@ export interface AvatarsProps { breakAt?: number; style?: StyleProp; theme: Theme; + listTitle?: JSX.Element; } export default class Avatars extends PureComponent { @@ -94,11 +95,12 @@ export default class Avatars extends PureComponent { }; showParticipantsList = () => { - const {userIds} = this.props; + const {userIds, listTitle} = this.props; const screen = 'ParticipantsList'; const passProps = { userIds, + listTitle, }; showModalOverCurrentContext(screen, passProps); diff --git a/app/components/formatted_relative_time.test.js b/app/components/formatted_relative_time.test.js new file mode 100644 index 000000000..8a8c588cd --- /dev/null +++ b/app/components/formatted_relative_time.test.js @@ -0,0 +1,62 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {shallow} from 'enzyme'; +import moment from 'moment'; +import React from 'react'; + +import FormattedRelativeTime from './formatted_relative_time'; + +jest.mock('react', () => ({ + ...jest.requireActual('react'), + useEffect: (f) => f(), +})); + +describe('FormattedRelativeTime', () => { + const baseProps = { + value: moment.now() - 15000, + updateIntervalInSeconds: 10000, + }; + + test('should match snapshot', () => { + const wrapper = shallow(); + + expect(wrapper.getElement()).toMatchSnapshot(); + }); + + test('should match string in the past', () => { + const props = {...baseProps, value: moment.now() - ((10 * 60 * 60 * 1000) + (30 * 60 * 1000) + (25 * 1000) + 500)}; + const wrapper = shallow(); + + expect(wrapper.getElement().props.children).toBe('11 hours ago'); + }); + + test('should match string in the future', () => { + const props = {...baseProps, value: moment.now() + 15500}; + const wrapper = shallow(); + + expect(wrapper.getElement().props.children).toBe('in a few seconds'); + }); + + test('should re-render after updateIntervalInSeconds', () => { + jest.useFakeTimers(); + const props = {...baseProps, value: moment.now(), updateIntervalInSeconds: 120}; + const wrapper = shallow(); + expect(wrapper.getElement().props.children).toBe('a few seconds ago'); + jest.advanceTimersByTime(60000); + expect(wrapper.getElement().props.children).toBe('a few seconds ago'); + jest.advanceTimersByTime(120000); + expect(wrapper.getElement().props.children).toBe('2 minutes ago'); + jest.useRealTimers(); + }); + + test('should not re-render if updateIntervalInSeconds is not passed', () => { + jest.useFakeTimers(); + const props = {value: baseProps.value}; + const wrapper = shallow(); + expect(wrapper.getElement().props.children).toBe('a few seconds ago'); + jest.advanceTimersByTime(120000000000); + expect(wrapper.getElement().props.children).toBe('a few seconds ago'); + jest.useRealTimers(); + }); +}); diff --git a/app/components/formatted_relative_time.tsx b/app/components/formatted_relative_time.tsx new file mode 100644 index 000000000..5c26cb653 --- /dev/null +++ b/app/components/formatted_relative_time.tsx @@ -0,0 +1,44 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import moment from 'moment-timezone'; +import React, {useEffect, useState} from 'react'; +import {Text, TextProps} from 'react-native'; + +import type {UserTimezone} from '@mm-redux/types/users'; + +type FormattedRelativeTimeProps = TextProps & { + timezone?: UserTimezone | string; + value: number | string | Date; + updateIntervalInSeconds?: number; +} + +const FormattedRelativeTime = ({timezone, value, updateIntervalInSeconds, ...props}: FormattedRelativeTimeProps) => { + const getFormattedRelativeTime = () => { + let zone = timezone; + if (typeof timezone === 'object') { + zone = timezone.useAutomaticTimezone ? timezone.automaticTimezone : timezone.manualTimezone; + } + + return timezone ? moment.tz(value, zone as string).fromNow() : moment(value).fromNow(); + }; + + const [formattedTime, setFormattedTime] = useState(getFormattedRelativeTime()); + useEffect(() => { + if (updateIntervalInSeconds) { + const interval = setInterval(() => setFormattedTime(getFormattedRelativeTime()), updateIntervalInSeconds * 1000); + return () => { + clearInterval(interval); + }; + } + return () => null; + }, [updateIntervalInSeconds]); + + return ( + + {formattedTime} + + ); +}; + +export default FormattedRelativeTime; diff --git a/app/components/post_draft/__snapshots__/post_draft.test.js.snap b/app/components/post_draft/__snapshots__/post_draft.test.js.snap index 6eb7aa0c7..64aebb2df 100644 --- a/app/components/post_draft/__snapshots__/post_draft.test.js.snap +++ b/app/components/post_draft/__snapshots__/post_draft.test.js.snap @@ -513,7 +513,7 @@ exports[`PostDraft Should render the DraftInput 1`] = ` > diff --git a/app/components/post_draft/quick_actions/file_quick_action/__snapshots__/file_quick_action.test.js.snap b/app/components/post_draft/quick_actions/file_quick_action/__snapshots__/file_quick_action.test.js.snap index 2bdb2e904..cecd56a41 100644 --- a/app/components/post_draft/quick_actions/file_quick_action/__snapshots__/file_quick_action.test.js.snap +++ b/app/components/post_draft/quick_actions/file_quick_action/__snapshots__/file_quick_action.test.js.snap @@ -30,7 +30,7 @@ exports[`FileQuickAction should match snapshot 1`] = ` > diff --git a/app/components/post_draft/quick_actions/file_quick_action/index.tsx b/app/components/post_draft/quick_actions/file_quick_action/index.tsx index 2a225db71..f2dab0ac5 100644 --- a/app/components/post_draft/quick_actions/file_quick_action/index.tsx +++ b/app/components/post_draft/quick_actions/file_quick_action/index.tsx @@ -83,7 +83,7 @@ const FileQuickAction = ({disabled, fileCount = 0, intl, maxFileCount, onUploadF > diff --git a/app/components/post_list/more_messages_button/__snapshots__/more_messages_button.test.js.snap b/app/components/post_list/more_messages_button/__snapshots__/more_messages_button.test.js.snap index 903143459..dfc597af7 100644 --- a/app/components/post_list/more_messages_button/__snapshots__/more_messages_button.test.js.snap +++ b/app/components/post_list/more_messages_button/__snapshots__/more_messages_button.test.js.snap @@ -18,7 +18,7 @@ exports[`MoreMessagesButton should match snapshot 1`] = ` Object { "transform": Array [ Object { - "translateY": -438, + "translateY": -550, }, ], }, diff --git a/app/components/post_list/more_messages_button/more_messages_button.test.js b/app/components/post_list/more_messages_button/more_messages_button.test.js index 303139d79..42527996e 100644 --- a/app/components/post_list/more_messages_button/more_messages_button.test.js +++ b/app/components/post_list/more_messages_button/more_messages_button.test.js @@ -13,7 +13,7 @@ import {shallowWithIntl} from '@test/intl-test-helper'; import MoreMessagesButton, { MIN_INPUT, MAX_INPUT, - INDICATOR_BAR_FACTOR, + BARS_FACTOR, CANCEL_TIMER_DELAY, } from './more_messages_button'; @@ -296,17 +296,19 @@ describe('MoreMessagesButton', () => { expect(Animated.spring).not.toHaveBeenCalledTimes(1); }); - it('should animate to MAX_INPUT - INDICATOR_BAR_FACTOR if visible and indicator bar hides', () => { + it('should animate to MAX_INPUT - BARS_FACTOR if visible and indicator bar hides', () => { instance.buttonVisible = true; instance.onIndicatorBarVisible(false); expect(Animated.spring).toHaveBeenCalledWith(instance.top, { - toValue: MAX_INPUT - INDICATOR_BAR_FACTOR, + toValue: MAX_INPUT - BARS_FACTOR, useNativeDriver: true, }); }); it('should animate to MAX_INPUT if visible and indicator becomes visible', () => { instance.buttonVisible = true; + instance.joinCallBarVisible = true; + instance.currentCallBarVisible = true; instance.onIndicatorBarVisible(true); expect(Animated.spring).toHaveBeenCalledWith(instance.top, { toValue: MAX_INPUT, @@ -414,13 +416,15 @@ describe('MoreMessagesButton', () => { instance.show(); expect(instance.buttonVisible).toBe(true); expect(Animated.spring).toHaveBeenCalledWith(instance.top, { - toValue: MAX_INPUT - INDICATOR_BAR_FACTOR, + toValue: MAX_INPUT - BARS_FACTOR, useNativeDriver: true, }); }); - it('should account for the indicator bar height when the indicator is visible', () => { + it('should account for the indicator bar heights when the indicator is visible', () => { instance.indicatorBarVisible = true; + instance.joinCallBarVisible = true; + instance.currentCallBarVisible = true; instance.buttonVisible = false; wrapper.setState({moreText: '1 new message'}); wrapper.setProps({deepLinkURL: null, unreadCount: 1}); @@ -457,13 +461,15 @@ describe('MoreMessagesButton', () => { instance.hide(); expect(instance.buttonVisible).toBe(false); expect(Animated.spring).toHaveBeenCalledWith(instance.top, { - toValue: MIN_INPUT + INDICATOR_BAR_FACTOR, + toValue: MIN_INPUT + BARS_FACTOR, useNativeDriver: true, }); }); - it('should account for the indicator bar height when the indicator is visible', () => { + it('should account for the indicator bars heights when the indicator is visible', () => { instance.indicatorBarVisible = true; + instance.joinCallBarVisible = true; + instance.currentCallBarVisible = true; instance.buttonVisible = true; instance.hide(); diff --git a/app/components/post_list/more_messages_button/more_messages_button.tsx b/app/components/post_list/more_messages_button/more_messages_button.tsx index a1bf54693..0ea47cac5 100644 --- a/app/components/post_list/more_messages_button/more_messages_button.tsx +++ b/app/components/post_list/more_messages_button/more_messages_button.tsx @@ -7,7 +7,7 @@ import {ActivityIndicator, Animated, AppState, AppStateStatus, NativeEventSubscr import CompassIcon from '@components/compass_icon'; import TouchableWithFeedback from '@components/touchable_with_feedback'; -import ViewTypes, {INDICATOR_BAR_HEIGHT} from '@constants/view'; +import ViewTypes, {INDICATOR_BAR_HEIGHT, JOIN_CALL_BAR_HEIGHT, CURRENT_CALL_BAR_HEIGHT} from '@constants/view'; import EventEmitter from '@mm-redux/utils/event_emitter'; import {messageCount} from '@mm-redux/utils/post_list'; import {t} from '@utils/i18n'; @@ -17,22 +17,22 @@ import type {Theme} from '@mm-redux/types/theme'; const HIDDEN_TOP = -400; const SHOWN_TOP = 0; -export const INDICATOR_BAR_FACTOR = Math.abs(INDICATOR_BAR_HEIGHT / (HIDDEN_TOP - SHOWN_TOP)); +export const BARS_FACTOR = Math.abs((INDICATOR_BAR_HEIGHT + JOIN_CALL_BAR_HEIGHT + CURRENT_CALL_BAR_HEIGHT) / (HIDDEN_TOP - SHOWN_TOP)); export const MIN_INPUT = 0; export const MAX_INPUT = 1; const TOP_INTERPOL_CONFIG: Animated.InterpolationConfigType = { inputRange: [ MIN_INPUT, - MIN_INPUT + INDICATOR_BAR_FACTOR, - MAX_INPUT - INDICATOR_BAR_FACTOR, + MIN_INPUT + BARS_FACTOR, + MAX_INPUT - BARS_FACTOR, MAX_INPUT, ], outputRange: [ - HIDDEN_TOP - INDICATOR_BAR_HEIGHT, + HIDDEN_TOP - (INDICATOR_BAR_HEIGHT + JOIN_CALL_BAR_HEIGHT + CURRENT_CALL_BAR_HEIGHT), HIDDEN_TOP, SHOWN_TOP, - SHOWN_TOP + INDICATOR_BAR_HEIGHT, + SHOWN_TOP + INDICATOR_BAR_HEIGHT + JOIN_CALL_BAR_HEIGHT + CURRENT_CALL_BAR_HEIGHT, ], extrapolate: 'clamp', }; @@ -71,6 +71,8 @@ export default class MoreMessageButton extends React.PureComponent void) = undefined; removeScrollEndIndexListener: undefined | (() => void) = undefined; @@ -81,6 +83,8 @@ export default class MoreMessageButton extends React.PureComponent { this.indicatorBarVisible = indicatorVisible; + this.animateButton(); + } + + onCurrentCallBarVisible = (currentCallVisible: boolean) => { + this.currentCallBarVisible = currentCallVisible; + this.animateButton(); + } + + onJoinCallBarVisible = (joinCallVisible: boolean) => { + this.joinCallBarVisible = joinCallVisible; + this.animateButton(); + } + + animateButton = () => { if (this.buttonVisible) { - const toValue = this.indicatorBarVisible ? MAX_INPUT : MAX_INPUT - INDICATOR_BAR_FACTOR; + const toValue = MAX_INPUT - this.getBarsFactor(); Animated.spring(this.top, { toValue, useNativeDriver: true, @@ -169,18 +189,22 @@ export default class MoreMessageButton extends React.PureComponent { if (!this.buttonVisible && this.state.moreText && !this.props.deepLinkURL && !this.canceled && this.props.unreadCount > 0) { this.buttonVisible = true; - const toValue = this.indicatorBarVisible ? MAX_INPUT : MAX_INPUT - INDICATOR_BAR_FACTOR; - Animated.spring(this.top, { - toValue, - useNativeDriver: true, - }).start(); + this.animateButton(); } } + getBarsFactor = () => { + return Math.abs(( + (this.indicatorBarVisible ? 0 : INDICATOR_BAR_HEIGHT) + + (this.joinCallBarVisible ? 0 : JOIN_CALL_BAR_HEIGHT) + + (this.currentCallBarVisible ? 0 : CURRENT_CALL_BAR_HEIGHT) + ) / (HIDDEN_TOP - SHOWN_TOP)); + } + hide = () => { if (this.buttonVisible) { this.buttonVisible = false; - const toValue = this.indicatorBarVisible ? MIN_INPUT : MIN_INPUT + INDICATOR_BAR_FACTOR; + const toValue = MIN_INPUT + this.getBarsFactor(); Animated.spring(this.top, { toValue, useNativeDriver: true, diff --git a/app/components/post_list/post/index.ts b/app/components/post_list/post/index.ts index de5f85ac8..4e90b6ec1 100644 --- a/app/components/post_list/post/index.ts +++ b/app/components/post_list/post/index.ts @@ -7,7 +7,7 @@ import {showPermalink} from '@actions/views/permalink'; import {THREAD} from '@constants/screen'; import {removePost} from '@mm-redux/actions/posts'; import {getChannel} from '@mm-redux/selectors/entities/channels'; -import {getConfig} from '@mm-redux/selectors/entities/general'; +import {getConfig, getFeatureFlagValue} from '@mm-redux/selectors/entities/general'; import {getPost, isRootPost} from '@mm-redux/selectors/entities/posts'; import {getMyPreferences, getTeammateNameDisplaySetting, isCollapsedThreadsEnabled} from '@mm-redux/selectors/entities/preferences'; import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams'; @@ -49,6 +49,7 @@ function mapSateToProps(state: GlobalState, ownProps: OwnProps) { const teammateNameDisplay = getTeammateNameDisplaySetting(state); const enablePostUsernameOverride = config.EnablePostUsernameOverride === 'true'; const isConsecutivePost = post && previousPost && !author?.is_bot && !isRootPost(state, post.id) && areConsecutivePosts(post, previousPost); + const callsFeatureEnabled = getFeatureFlagValue(state, 'CallsMobile') === 'true'; let isFirstReply = true; let isLastReply = true; let canDelete = false; @@ -97,6 +98,7 @@ function mapSateToProps(state: GlobalState, ownProps: OwnProps) { teammateNameDisplay, thread, threadStarter: getUser(state, post.user_id), + callsFeatureEnabled, }; } diff --git a/app/components/post_list/post/post.tsx b/app/components/post_list/post/post.tsx index 130c892cc..5b99657ff 100644 --- a/app/components/post_list/post/post.tsx +++ b/app/components/post_list/post/post.tsx @@ -16,6 +16,7 @@ import {UserThread} from '@mm-redux/types/threads'; import {UserProfile} from '@mm-redux/types/users'; import EventEmitter from '@mm-redux/utils/event_emitter'; import {fromAutoResponder, isPostEphemeral, isPostPendingOrFailed, isSystemMessage} from '@mm-redux/utils/post_utils'; +import CallMessage from '@mmproducts/calls/components/call_message'; import {preventDoubleTap} from '@utils/tap'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; @@ -54,6 +55,7 @@ type PostProps = { theme: Theme; thread: UserThread; threadStarter: UserProfile; + callsFeatureEnabled: boolean; }; const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { @@ -113,7 +115,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { const Post = ({ canDelete, collapsedThreadsEnabled, enablePostUsernameOverride, highlight, highlightPinnedOrFlagged = true, intl, isConsecutivePost, isFirstReply, isFlagged, isLastReply, location, post, removePost, rootPostAuthor, shouldRenderReplyButton, skipFlaggedHeader, skipPinnedHeader, showAddReaction = true, showPermalink, style, - teammateNameDisplay, testID, theme, thread, threadStarter, + teammateNameDisplay, testID, theme, thread, threadStarter, callsFeatureEnabled, }: PostProps) => { const pressDetected = useRef(false); const styles = getStyleSheet(theme); @@ -239,6 +241,13 @@ const Post = ({ theme={theme} /> ); + } else if (post.type === 'custom_calls' && callsFeatureEnabled) { + body = ( + + ); } else { body = ( + + + + + display_name + + + + +`; + +exports[`ChannelItem should match snapshot when there is a call and calls are enabled 1`] = ` + + + + + + display_name + + + + + +`; + exports[`ChannelItem should match snapshot with custom status emoji 1`] = ` {customStatus} {badge} + {this.props.callsFeatureEnabled && this.props.channelHasCall && + + } @@ -288,5 +298,11 @@ export const getStyleSheet = makeStyleSheetFromTheme((theme) => { muted: { opacity: 0.5, }, + hasCall: { + color: theme.sidebarText, + flex: 1, + textAlign: 'right', + marginRight: 20, + }, }; }); diff --git a/app/components/sidebars/main/channels_list/channel_item/channel_item.test.js b/app/components/sidebars/main/channels_list/channel_item/channel_item.test.js index 894a74e23..ca4b0f1cc 100644 --- a/app/components/sidebars/main/channels_list/channel_item/channel_item.test.js +++ b/app/components/sidebars/main/channels_list/channel_item/channel_item.test.js @@ -40,6 +40,8 @@ describe('ChannelItem', () => { isSearchResult: false, isBot: false, customStatusEnabled: true, + channelHasCall: false, + callsFeatureEnabled: false, }; test('should match snapshot', () => { @@ -50,6 +52,34 @@ describe('ChannelItem', () => { expect(wrapper.getElement()).toMatchSnapshot(); }); + test('should match snapshot when there is a call and calls are enabled', () => { + const newProps = { + ...baseProps, + callsFeatureEnabled: true, + channelHasCall: true, + }; + + const wrapper = shallowWithIntl( + , + ); + + expect(wrapper.getElement()).toMatchSnapshot(); + }); + + test('should match snapshot when there is a call and but calls are disabled', () => { + const newProps = { + ...baseProps, + callsFeatureEnabled: false, + channelHasCall: true, + }; + + const wrapper = shallowWithIntl( + , + ); + + expect(wrapper.getElement()).toMatchSnapshot(); + }); + test('should match snapshot with mentions and muted', () => { const newProps = { ...baseProps, diff --git a/app/components/sidebars/main/channels_list/channel_item/index.js b/app/components/sidebars/main/channels_list/channel_item/index.js index a035fd9c9..56b0494ab 100644 --- a/app/components/sidebars/main/channels_list/channel_item/index.js +++ b/app/components/sidebars/main/channels_list/channel_item/index.js @@ -11,10 +11,12 @@ import { makeGetChannel, shouldHideDefaultChannel, } from '@mm-redux/selectors/entities/channels'; +import {getFeatureFlagValue} from '@mm-redux/selectors/entities/general'; import {getTheme, getTeammateNameDisplaySetting, isCollapsedThreadsEnabled} from '@mm-redux/selectors/entities/preferences'; import {getCurrentUserId, getUser} from '@mm-redux/selectors/entities/users'; import {getMsgCountInChannel, getUserIdFromChannelName, isChannelMuted} from '@mm-redux/utils/channel_utils'; import {displayUsername} from '@mm-redux/utils/user_utils'; +import {getCalls} from '@mmproducts/calls/store/selectors/calls'; import {isCustomStatusEnabled} from '@selectors/custom_status'; import {getViewingGlobalThreads} from '@selectors/threads'; import {getDraftForChannel} from '@selectors/views'; @@ -31,6 +33,7 @@ function makeMapStateToProps() { const currentUserId = getCurrentUserId(state); const channelDraft = getDraftForChannel(state, channel.id); const collapsedThreadsEnabled = isCollapsedThreadsEnabled(state); + const channelHasCall = Boolean(getCalls(state)[ownProps.channelId]); let displayName = channel.display_name; let isGuest = false; @@ -72,6 +75,7 @@ function makeMapStateToProps() { if (member && member.notify_props) { showUnreadForMsgs = member.notify_props.mark_unread !== General.MENTION; } + const callsFeatureEnabled = getFeatureFlagValue(state, 'CallsMobile') === 'true'; const viewingGlobalThreads = getViewingGlobalThreads(state); return { @@ -92,6 +96,8 @@ function makeMapStateToProps() { unreadMsgs, viewingGlobalThreads, customStatusEnabled: isCustomStatusEnabled(state), + channelHasCall, + callsFeatureEnabled, }; }; } diff --git a/app/constants/view.js b/app/constants/view.js index eed6e72a7..821da633c 100644 --- a/app/constants/view.js +++ b/app/constants/view.js @@ -27,6 +27,8 @@ export const NotificationLevels = { export const NOTIFY_ALL_MEMBERS = 5; export const INDICATOR_BAR_HEIGHT = 38; +export const JOIN_CALL_BAR_HEIGHT = 38; +export const CURRENT_CALL_BAR_HEIGHT = 74; export const CHANNEL_ITEM_LARGE_BADGE_MAX_WIDTH = 38; export const CHANNEL_ITEM_SMALL_BADGE_MAX_WIDTH = 32; @@ -107,6 +109,9 @@ const ViewTypes = keyMirror({ VIEWING_GLOBAL_THREADS_ALL: null, THREAD_LAST_VIEWED_AT: null, + + JOIN_CALL_BAR_VISIBLE: null, + CURRENT_CALL_BAR_VISIBLE: null, }); const RequiredServer = { diff --git a/app/constants/websocket.ts b/app/constants/websocket.ts index f1b36fe8b..1632a9f15 100644 --- a/app/constants/websocket.ts +++ b/app/constants/websocket.ts @@ -51,5 +51,16 @@ const WebsocketEvents = { SIDEBAR_CATEGORY_UPDATED: 'sidebar_category_updated', SIDEBAR_CATEGORY_DELETED: 'sidebar_category_deleted', SIDEBAR_CATEGORY_ORDER_UPDATED: 'sidebar_category_order_updated', + CALLS_CHANNEL_ENABLED: 'custom_com.mattermost.calls_channel_enable_voice', + CALLS_CHANNEL_DISABLED: 'custom_com.mattermost.calls_channel_disable_voice', + CALLS_USER_CONNECTED: 'custom_com.mattermost.calls_user_connected', + CALLS_USER_DISCONNECTED: 'custom_com.mattermost.calls_user_disconnected', + CALLS_USER_MUTED: 'custom_com.mattermost.calls_user_muted', + CALLS_USER_UNMUTED: 'custom_com.mattermost.calls_user_unmuted', + CALLS_USER_VOICE_ON: 'custom_com.mattermost.calls_user_voice_on', + CALLS_USER_VOICE_OFF: 'custom_com.mattermost.calls_user_voice_off', + CALLS_CALL_START: 'custom_com.mattermost.calls_call_start', + CALLS_SCREEN_ON: 'custom_com.mattermost.calls_user_screen_on', + CALLS_SCREEN_OFF: 'custom_com.mattermost.calls_user_screen_off', }; export default WebsocketEvents; diff --git a/app/mm-redux/action_types/index.ts b/app/mm-redux/action_types/index.ts index 3e5856ceb..25ebf85c5 100644 --- a/app/mm-redux/action_types/index.ts +++ b/app/mm-redux/action_types/index.ts @@ -1,6 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import CallsTypes from '@mmproducts/calls/store/action_types/calls'; + import AppsTypes from './apps'; import BotTypes from './bots'; import ChannelCategoryTypes from './channel_categories'; @@ -43,4 +45,5 @@ export { ThreadTypes, RemoteClusterTypes, AppsTypes, + CallsTypes, }; diff --git a/app/mm-redux/actions/index.ts b/app/mm-redux/actions/index.ts index d878a1103..73970fcf4 100644 --- a/app/mm-redux/actions/index.ts +++ b/app/mm-redux/actions/index.ts @@ -1,6 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import * as calls from '@mmproducts/calls/store/actions/calls'; + import * as bots from './bots'; import * as channels from './channels'; import * as emojis from './emojis'; @@ -37,5 +39,6 @@ export { timezone, users, remoteCluster, + calls, }; diff --git a/app/mm-redux/reducers/entities/index.ts b/app/mm-redux/reducers/entities/index.ts index d8e659bda..9fc2487da 100644 --- a/app/mm-redux/reducers/entities/index.ts +++ b/app/mm-redux/reducers/entities/index.ts @@ -3,6 +3,8 @@ import {combineReducers} from 'redux'; +import calls from '@mmproducts/calls/store/reducers/calls'; + import apps from './apps'; import bots from './bots'; import channelCategories from './channel_categories'; @@ -43,4 +45,5 @@ export default combineReducers({ threads, remoteCluster, apps, + calls, }); diff --git a/app/mm-redux/types/index.ts b/app/mm-redux/types/index.ts index 8add318ea..abbf24f7a 100644 --- a/app/mm-redux/types/index.ts +++ b/app/mm-redux/types/index.ts @@ -1,6 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import * as calls from '@mmproducts/calls/store/types/calls'; + import * as actions from './actions'; import * as bots from './bots'; import * as channels from './channels'; @@ -32,6 +34,7 @@ export { bots, plugins, store, + calls, channels, errors, emojis, diff --git a/app/mm-redux/types/posts.ts b/app/mm-redux/types/posts.ts index b15c51930..090e8c164 100644 --- a/app/mm-redux/types/posts.ts +++ b/app/mm-redux/types/posts.ts @@ -26,7 +26,8 @@ export type PostType = 'system_add_remove' | 'system_join_leave' | 'system_leave_channel' | 'system_purpose_change' | - 'system_remove_from_channel'; + 'system_remove_from_channel' | + 'custom_calls'; export type PostEmbedType = 'image' | 'message_attachment' | 'opengraph'; diff --git a/app/mm-redux/types/store.ts b/app/mm-redux/types/store.ts index 052bbdc5c..13d5410ed 100644 --- a/app/mm-redux/types/store.ts +++ b/app/mm-redux/types/store.ts @@ -1,6 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {CallsState} from '@mmproducts/calls/store/types/calls'; + import {AppsState} from './apps'; import {Bot} from './bots'; import {ChannelCategoriesState} from './channel_categories'; @@ -58,6 +60,7 @@ export type GlobalState = { }; }; apps: AppsState; + calls: CallsState; }; errors: Array; requests: { diff --git a/app/products/calls/client/rest.ts b/app/products/calls/client/rest.ts new file mode 100644 index 000000000..c9cd0ead0 --- /dev/null +++ b/app/products/calls/client/rest.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {ServerChannelState} from '@mmproducts/calls/store/types/calls'; + +export interface ClientCallsMix { + getCalls: () => Promise; + enableChannelCalls: (channelId: string) => Promise; + disableChannelCalls: (channelId: string) => Promise; +} + +const ClientCalls = (superclass: any) => class extends superclass { + getCalls = async () => { + return this.doFetch( + `${this.getCallsRoute()}/channels`, + {method: 'get'}, + ); + }; + + enableChannelCalls = async (channelId: string) => { + return this.doFetch( + `${this.getCallsRoute()}/${channelId}`, + {method: 'post', body: JSON.stringify({enabled: true})}, + ); + }; + + disableChannelCalls = async (channelId: string) => { + return this.doFetch( + `${this.getCallsRoute()}/${channelId}`, + {method: 'post', body: JSON.stringify({enabled: false})}, + ); + }; +}; + +export default ClientCalls; diff --git a/app/products/calls/components/__snapshots__/call_avatar.test.js.snap b/app/products/calls/components/__snapshots__/call_avatar.test.js.snap new file mode 100644 index 000000000..ac488add8 --- /dev/null +++ b/app/products/calls/components/__snapshots__/call_avatar.test.js.snap @@ -0,0 +1,196 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CallAvatar should match snapshot muted 1`] = ` + + + + + + + + +`; + +exports[`CallAvatar should match snapshot size large 1`] = ` + + + + + + + + +`; + +exports[`CallAvatar should match snapshot unmuted 1`] = ` + + + + + + + + +`; diff --git a/app/products/calls/components/__snapshots__/call_duration.test.js.snap b/app/products/calls/components/__snapshots__/call_duration.test.js.snap new file mode 100644 index 000000000..9b6b5cffa --- /dev/null +++ b/app/products/calls/components/__snapshots__/call_duration.test.js.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CallDuration should match snapshot 1`] = ` + + 00:15 + +`; 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 new file mode 100644 index 000000000..b6deb6db0 --- /dev/null +++ b/app/products/calls/components/__snapshots__/enable_disable_calls.test.js.snap @@ -0,0 +1,151 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EnableDisableCalls should match snapshot if calls are disabled 1`] = ` + + + + +`; + +exports[`EnableDisableCalls should match snapshot if calls are enabled 1`] = ` + + + + +`; diff --git a/app/products/calls/components/__snapshots__/floating_call_container.test.js.snap b/app/products/calls/components/__snapshots__/floating_call_container.test.js.snap new file mode 100644 index 000000000..f1450109e --- /dev/null +++ b/app/products/calls/components/__snapshots__/floating_call_container.test.js.snap @@ -0,0 +1,21 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FloatingCallContainer should match snapshot 1`] = ` + + + test + + +`; diff --git a/app/products/calls/components/call_avatar.test.js b/app/products/calls/components/call_avatar.test.js new file mode 100644 index 000000000..2740a04cc --- /dev/null +++ b/app/products/calls/components/call_avatar.test.js @@ -0,0 +1,36 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {shallow} from 'enzyme'; +import React from 'react'; + +import CallAvatar from './call_avatar'; + +describe('CallAvatar', () => { + const baseProps = { + userId: 'user-id', + volume: 1, + muted: false, + size: 'm', + }; + + test('should match snapshot unmuted', () => { + const wrapper = shallow(); + + expect(wrapper.getElement()).toMatchSnapshot(); + }); + + test('should match snapshot muted', () => { + const props = {...baseProps, muted: true}; + const wrapper = shallow(); + + expect(wrapper.getElement()).toMatchSnapshot(); + }); + + test('should match snapshot size large', () => { + const props = {...baseProps, size: 'l'}; + const wrapper = shallow(); + + expect(wrapper.getElement()).toMatchSnapshot(); + }); +}); diff --git a/app/products/calls/components/call_avatar.tsx b/app/products/calls/components/call_avatar.tsx new file mode 100644 index 000000000..da9893efd --- /dev/null +++ b/app/products/calls/components/call_avatar.tsx @@ -0,0 +1,82 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {View, StyleSheet} from 'react-native'; + +import CompassIcon from '@components/compass_icon'; +import ProfilePicture from '@components/profile_picture'; + +type Props = { + userId: string; + volume: number; + muted?: boolean; + size?: 'm' | 'l'; +} + +const getStyleSheet = (props: Props) => { + const baseSize = props.size === 'm' || !props.size ? 40 : 72; + + return StyleSheet.create({ + pictureHalo: { + backgroundColor: 'rgba(255,255,255,' + (0.16 * props.volume) + ')', + height: baseSize + 16, + width: baseSize + 16, + padding: 4, + marginRight: 4, + borderRadius: (baseSize + 16) / 2, + }, + pictureHalo2: { + backgroundColor: 'rgba(255,255,255,' + (0.24 * props.volume) + ')', + height: baseSize + 8, + width: baseSize + 8, + padding: 4, + borderRadius: (baseSize + 8) / 2, + }, + picture: { + borderRadius: baseSize / 2, + height: baseSize, + width: baseSize, + }, + mute: { + position: 'absolute', + bottom: -5, + right: -5, + width: 24, + height: 24, + borderRadius: 12, + padding: 2, + backgroundColor: props.muted ? 'black' : '#3DB887', + borderColor: 'black', + borderWidth: 2, + color: 'white', + textAlign: 'center', + textAlignVertical: 'center', + overflow: 'hidden', + }, + }); +}; + +const CallAvatar = (props: Props) => { + const style = getStyleSheet(props); + return ( + + + + + {props.muted !== undefined && + } + + + + ); +}; +export default CallAvatar; diff --git a/app/products/calls/components/call_duration.test.js b/app/products/calls/components/call_duration.test.js new file mode 100644 index 000000000..9418851cb --- /dev/null +++ b/app/products/calls/components/call_duration.test.js @@ -0,0 +1,63 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {shallow} from 'enzyme'; +import moment from 'moment'; +import React from 'react'; + +import CallDuration from './call_duration'; + +jest.mock('react', () => ({ + ...jest.requireActual('react'), + useEffect: (f) => f(), +})); + +describe('CallDuration', () => { + const baseProps = { + style: {}, + value: moment.now() - 15000, + updateIntervalInSeconds: 10000, + }; + + test('should match snapshot', () => { + const wrapper = shallow(); + + expect(wrapper.getElement()).toMatchSnapshot(); + }); + + test('should match snapshot more in the past', () => { + const props = {...baseProps, value: moment.now() - ((10 * 60 * 60 * 1000) + (30 * 60 * 1000) + (25 * 1000) + 500)}; + const wrapper = shallow(); + + expect(wrapper.getElement().props.children).toBe('10:30:25'); + }); + + test('should match snapshot more in the future', () => { + const props = {...baseProps, value: moment.now() + 15500}; + const wrapper = shallow(); + + expect(wrapper.getElement().props.children).toBe('00:00'); + }); + + test('should re-render after updateIntervalInSeconds', () => { + jest.useFakeTimers(); + const props = {...baseProps, value: moment.now(), updateIntervalInSeconds: 10}; + const wrapper = shallow(); + expect(wrapper.getElement().props.children).toBe('00:00'); + jest.advanceTimersByTime(5000); + expect(wrapper.getElement().props.children).toBe('00:00'); + jest.advanceTimersByTime(5000); + expect(wrapper.getElement().props.children).toBe('00:10'); + jest.useRealTimers(); + }); + + test('should not re-render if updateIntervalInSeconds is not passed', () => { + jest.useFakeTimers(); + const props = {value: moment.now()}; + const wrapper = shallow(); + expect(wrapper.getElement().props.children).toBe('00:00'); + jest.advanceTimersByTime(500000000); + expect(wrapper.getElement().props.children).toBe('00:00'); + jest.useRealTimers(); + }); +}); diff --git a/app/products/calls/components/call_duration.tsx b/app/products/calls/components/call_duration.tsx new file mode 100644 index 000000000..6aa49d0b3 --- /dev/null +++ b/app/products/calls/components/call_duration.tsx @@ -0,0 +1,52 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import moment from 'moment-timezone'; +import React, {useEffect, useState} from 'react'; +import {Text, StyleProp, TextStyle} from 'react-native'; + +type CallDurationProps = { + style: StyleProp; + value: number; + updateIntervalInSeconds?: number; +} + +const CallDuration = ({value, style, updateIntervalInSeconds}: CallDurationProps) => { + const getCallDuration = () => { + const now = moment(); + const startTime = moment(value); + if (now < startTime) { + return '00:00'; + } + + const totalSeconds = now.diff(startTime, 'seconds'); + const seconds = totalSeconds % 60; + const totalMinutes = Math.floor(totalSeconds / 60); + const minutes = totalMinutes % 60; + const hours = Math.floor(totalMinutes / 60); + + if (hours > 0) { + return `${hours}:${minutes < 10 ? '0' + minutes : minutes}:${seconds < 10 ? '0' + seconds : seconds}`; + } + return `${minutes < 10 ? '0' + minutes : minutes}:${seconds < 10 ? '0' + seconds : seconds}`; + }; + + const [formattedTime, setFormattedTime] = useState(getCallDuration()); + useEffect(() => { + if (updateIntervalInSeconds) { + const interval = setInterval(() => setFormattedTime(getCallDuration()), updateIntervalInSeconds * 1000); + return () => { + clearInterval(interval); + }; + } + return () => null; + }, [updateIntervalInSeconds]); + + return ( + + {formattedTime} + + ); +}; + +export default CallDuration; diff --git a/app/products/calls/components/call_message/__snapshots__/call_message.test.js.snap b/app/products/calls/components/call_message/__snapshots__/call_message.test.js.snap new file mode 100644 index 000000000..42ac7fe86 --- /dev/null +++ b/app/products/calls/components/call_message/__snapshots__/call_message.test.js.snap @@ -0,0 +1,292 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CallMessage should match snapshot 1`] = ` + + + + + + + + + + + +`; + +exports[`CallMessage should match snapshot for ended call 1`] = ` + + + + + + , + } + } + /> + + • + + + + + +`; + +exports[`CallMessage should match snapshot for the call already in the current channel 1`] = ` + + + + + + + + + + + +`; diff --git a/app/products/calls/components/call_message/call_message.test.js b/app/products/calls/components/call_message/call_message.test.js new file mode 100644 index 000000000..35b192410 --- /dev/null +++ b/app/products/calls/components/call_message/call_message.test.js @@ -0,0 +1,86 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Alert, TouchableOpacity} from 'react-native'; + +import Preferences from '@mm-redux/constants/preferences'; +import {shallowWithIntl} from '@test/intl-test-helper'; + +import CallMessage from './call_message'; + +describe('CallMessage', () => { + const baseProps = { + actions: { + joinCall: jest.fn(), + }, + theme: Preferences.THEMES.denim, + post: { + props: { + start_at: 100, + }, + type: 'custom_calls', + }, + user: { + id: 'user-1-id', + username: 'user-1-username', + nickname: 'User 1', + }, + teammateNameDisplay: Preferences.DISPLAY_PREFER_NICKNAME, + confirmToJoin: false, + isMilitaryTime: false, + userTimezone: 'utc', + currentChannelName: 'Current Channel', + callChannelName: 'Call Channel', + }; + + test('should match snapshot', () => { + const wrapper = shallowWithIntl().dive(); + + expect(wrapper.getElement()).toMatchSnapshot(); + }); + + test('should match snapshot for the call already in the current channel', () => { + const props = {...baseProps, alreadyInTheCall: true}; + const wrapper = shallowWithIntl().dive(); + + expect(wrapper.getElement()).toMatchSnapshot(); + }); + + test('should match snapshot for ended call', () => { + const props = {...baseProps, post: {...baseProps.post, props: {start_at: 100, end_at: 200}}}; + const wrapper = shallowWithIntl().dive(); + + expect(wrapper.getElement()).toMatchSnapshot(); + }); + + test('should join on click join button', () => { + const joinCall = jest.fn(); + const props = {...baseProps, actions: {joinCall}}; + const wrapper = shallowWithIntl().dive(); + + wrapper.find(TouchableOpacity).simulate('press'); + expect(Alert.alert).not.toHaveBeenCalled(); + expect(props.actions.joinCall).toHaveBeenCalled(); + }); + + test('should ask for confirmation on click join button', () => { + const joinCall = jest.fn(); + const props = {...baseProps, confirmToJoin: true, actions: {joinCall}}; + const wrapper = shallowWithIntl().dive(); + + wrapper.find(TouchableOpacity).simulate('press'); + expect(Alert.alert).toHaveBeenCalled(); + expect(props.actions.joinCall).not.toHaveBeenCalled(); + }); + + test('should not ask or join on click current call button if I am in the current call', () => { + const joinCall = jest.fn(); + const props = {...baseProps, actions: {joinCall}, alreadyInTheCall: true}; + const wrapper = shallowWithIntl().dive(); + + wrapper.find(TouchableOpacity).simulate('press'); + expect(Alert.alert).not.toHaveBeenCalled(); + expect(props.actions.joinCall).not.toHaveBeenCalled(); + }); +}); diff --git a/app/products/calls/components/call_message/call_message.tsx b/app/products/calls/components/call_message/call_message.tsx new file mode 100644 index 000000000..1f0beb050 --- /dev/null +++ b/app/products/calls/components/call_message/call_message.tsx @@ -0,0 +1,204 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import moment from 'moment-timezone'; +import React, {useCallback} from 'react'; +import {injectIntl, IntlShape} from 'react-intl'; +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'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +import type {Post} from '@mm-redux/types/posts'; +import type {Theme} from '@mm-redux/types/theme'; +import type {UserProfile} from '@mm-redux/types/users'; + +type CallMessageProps = { + actions: { + joinCall: (channelId: string) => void; + }; + post: Post; + user: UserProfile; + theme: Theme; + teammateNameDisplay: string; + confirmToJoin: boolean; + alreadyInTheCall: boolean; + isMilitaryTime: boolean; + userTimezone: string; + currentChannelName: string; + callChannelName: string; + intl: typeof IntlShape; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + messageStyle: { + flexDirection: 'row', + color: changeOpacity(theme.centerChannelColor, 0.6), + fontSize: 15, + lineHeight: 20, + paddingTop: 5, + paddingBottom: 5, + }, + messageText: { + flex: 1, + }, + joinCallIcon: { + padding: 12, + backgroundColor: '#339970', + borderRadius: 8, + marginRight: 5, + color: 'white', + overflow: 'hidden', + }, + phoneHangupIcon: { + padding: 12, + backgroundColor: changeOpacity(theme.centerChannelColor, 0.6), + borderRadius: 8, + marginRight: 5, + color: 'white', + overflow: 'hidden', + }, + joinCallButtonText: { + color: 'white', + }, + joinCallButtonIcon: { + color: 'white', + marginRight: 5, + }, + startedText: { + color: theme.centerChannelColor, + fontWeight: 'bold', + }, + joinCallButton: { + flexDirection: 'row', + padding: 12, + backgroundColor: '#339970', + borderRadius: 8, + alignItems: 'center', + alignContent: 'center', + }, + timeText: { + color: theme.centerChannelColor, + }, + endCallInfo: { + flexDirection: 'row', + alignItems: 'center', + alignContent: 'center', + }, + separator: { + color: theme.centerChannelColor, + marginLeft: 5, + marginRight: 5, + }, + }; +}); + +const CallMessage = ({post, user, teammateNameDisplay, confirmToJoin, alreadyInTheCall, theme, actions, userTimezone, isMilitaryTime, currentChannelName, callChannelName, intl}: CallMessageProps) => { + const style = getStyleSheet(theme); + const joinHandler = useCallback(() => { + if (alreadyInTheCall) { + return; + } + leaveAndJoinWithAlert(intl, post.channel_id, callChannelName, currentChannelName, confirmToJoin, actions.joinCall); + }, [post.channel_id, callChannelName, currentChannelName, confirmToJoin, actions.joinCall]); + + if (post.props.end_at) { + return ( + + + + + + + ), + }} + /> + {'•'} + + + + + ); + } + + return ( + + + + + + + + + + {alreadyInTheCall && + } + {!alreadyInTheCall && + } + + + ); +}; + +export default injectIntl(CallMessage); diff --git a/app/products/calls/components/call_message/index.ts b/app/products/calls/components/call_message/index.ts new file mode 100644 index 000000000..1ade01316 --- /dev/null +++ b/app/products/calls/components/call_message/index.ts @@ -0,0 +1,55 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {connect} from 'react-redux'; +import {bindActionCreators, Dispatch} from 'redux'; + +import {Preferences} from '@mm-redux/constants'; +import {getChannel} from '@mm-redux/selectors/entities/channels'; +import {getBool, getTeammateNameDisplaySetting} from '@mm-redux/selectors/entities/preferences'; +import {isTimezoneEnabled} from '@mm-redux/selectors/entities/timezone'; +import {getUser, getCurrentUser} from '@mm-redux/selectors/entities/users'; +import {getUserCurrentTimezone} from '@mm-redux/utils/timezone_utils'; +import {joinCall} from '@mmproducts/calls/store/actions/calls'; +import {getCalls, getCurrentCall} from '@mmproducts/calls/store/selectors/calls'; + +import CallMessage from './call_message'; + +import type {Post} from '@mm-redux/types/posts'; +import type {GlobalState} from '@mm-redux/types/store'; +import type {Theme} from '@mm-redux/types/theme'; + +type OwnProps = { + post: Post; + theme: Theme; +} + +function mapStateToProps(state: GlobalState, ownProps: OwnProps) { + const {post} = ownProps; + const user = getUser(state, post.user_id); + const currentUser = getCurrentUser(state); + const currentCall = getCurrentCall(state); + const call = getCalls(state)[post.channel_id]; + const enableTimezone = isTimezoneEnabled(state); + + return { + user, + teammateNameDisplay: getTeammateNameDisplaySetting(state), + confirmToJoin: Boolean(currentCall && call && currentCall.channelId !== call.channelId), + alreadyInTheCall: Boolean(currentCall && call && currentCall.channelId === call.channelId), + isMilitaryTime: getBool(state, Preferences.CATEGORY_DISPLAY_SETTINGS, 'use_military_time'), + userTimezone: enableTimezone ? getUserCurrentTimezone(currentUser.timezone) : undefined, + currentChannelName: getChannel(state, post.channel_id)?.display_name, + callChannelName: currentCall ? getChannel(state, currentCall.channelId)?.display_name : '', + }; +} + +function mapDispatchToProps(dispatch: Dispatch) { + return { + actions: bindActionCreators({ + joinCall, + }, dispatch), + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(CallMessage); 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 new file mode 100644 index 000000000..4b9951509 --- /dev/null +++ b/app/products/calls/components/current_call/__snapshots__/current_call.test.js.snap @@ -0,0 +1,253 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CurrentCall should match snapshot muted 1`] = ` + + + + + + + + + + + + + + + + + + + +`; + +exports[`CurrentCall should match snapshot unmuted 1`] = ` + + + + + + + + + + + + + + + + + + + +`; diff --git a/app/products/calls/components/current_call/current_call.test.js b/app/products/calls/components/current_call/current_call.test.js new file mode 100644 index 000000000..f284c5345 --- /dev/null +++ b/app/products/calls/components/current_call/current_call.test.js @@ -0,0 +1,94 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {shallow} from 'enzyme'; +import React from 'react'; +import {TouchableOpacity} from 'react-native'; + +import Preferences from '@mm-redux/constants/preferences'; + +import CurrentCall from './current_call'; + +describe('CurrentCall', () => { + const baseProps = { + actions: { + muteMyself: jest.fn(), + unmuteMyself: jest.fn(), + }, + theme: Preferences.THEMES.denim, + channel: { + display_name: 'Channel Name', + }, + speaker: { + id: 'user-1-id', + muted: false, + isTalking: true, + }, + speakerUser: { + id: 'user-1-id', + username: 'user-1-username', + nickname: 'User 1', + }, + call: { + participants: { + 'user-1-id': { + id: 'user-1-id', + muted: false, + isTalking: false, + }, + 'user-2-id': { + id: 'user-2-id', + muted: true, + isTalking: true, + }, + }, + channelId: 'channel-id', + startTime: 100, + speakers: 'user-2-id', + screenOn: false, + threadId: false, + }, + currentParticipant: { + id: 'user-2-id', + muted: true, + isTalking: true, + }, + teammateNameDisplay: Preferences.DISPLAY_PREFER_NICKNAME, + }; + + test('should match snapshot muted', () => { + const props = {...baseProps, currentParticipant: {...baseProps.currentParticipant, muted: true}}; + const wrapper = shallow(); + + expect(wrapper.getElement()).toMatchSnapshot(); + }); + + test('should match snapshot unmuted', () => { + const props = {...baseProps, currentParticipant: {...baseProps.currentParticipant, muted: false}}; + const wrapper = shallow(); + + expect(wrapper.getElement()).toMatchSnapshot(); + }); + + test('should mute on click mute button', () => { + const muteMyself = jest.fn(); + const unmuteMyself = jest.fn(); + const props = {...baseProps, actions: {muteMyself, unmuteMyself}, currentParticipant: {...baseProps.currentParticipant, muted: false}}; + const wrapper = shallow(); + + wrapper.find(TouchableOpacity).simulate('press'); + expect(props.actions.muteMyself).toHaveBeenCalled(); + expect(props.actions.unmuteMyself).not.toHaveBeenCalled(); + }); + + test('should ask for confirmation on click', () => { + const muteMyself = jest.fn(); + const unmuteMyself = jest.fn(); + const props = {...baseProps, actions: {unmuteMyself, muteMyself}, currentParticipant: {...baseProps.currentParticipant, muted: true}}; + const wrapper = shallow(); + + wrapper.find(TouchableOpacity).simulate('press'); + expect(props.actions.muteMyself).not.toHaveBeenCalled(); + expect(props.actions.unmuteMyself).toHaveBeenCalled(); + }); +}); diff --git a/app/products/calls/components/current_call/current_call.tsx b/app/products/calls/components/current_call/current_call.tsx new file mode 100644 index 000000000..1459459dd --- /dev/null +++ b/app/products/calls/components/current_call/current_call.tsx @@ -0,0 +1,175 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useEffect} from 'react'; +import {View, Text, TouchableOpacity, Pressable, Platform} from 'react-native'; +import {Options} from 'react-native-navigation'; + +import {goToScreen} from '@actions/navigation'; +import CompassIcon from '@components/compass_icon'; +import FormattedText from '@components/formatted_text'; +import ViewTypes, {CURRENT_CALL_BAR_HEIGHT} from '@constants/view'; +import {GenericAction} from '@mm-redux/types/actions'; +import EventEmitter from '@mm-redux/utils/event_emitter'; +import {displayUsername} from '@mm-redux/utils/user_utils'; +import CallAvatar from '@mmproducts/calls/components/call_avatar'; +import {makeStyleSheetFromTheme} from '@utils/theme'; + +import type {Channel} from '@mm-redux/types/channels'; +import type {Theme} from '@mm-redux/types/theme'; +import type {UserProfile} from '@mm-redux/types/users'; +import type {Call, CallParticipant} from '@mmproducts/calls/store/types/calls'; + +type Props = { + actions: { + muteMyself: (channelId: string) => GenericAction; + unmuteMyself: (channelId: string) => GenericAction; + }; + theme: Theme; + channel: Channel; + speaker: CallParticipant; + speakerUser: UserProfile; + call: Call; + currentParticipant: CallParticipant; + teammateNameDisplay: string; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + wrapper: { + padding: 10, + }, + container: { + flexDirection: 'row', + backgroundColor: '#3F4350', + width: '100%', + borderRadius: 5, + padding: 4, + height: CURRENT_CALL_BAR_HEIGHT - 10, + alignItems: 'center', + }, + pressable: { + zIndex: 10, + }, + userInfo: { + flex: 1, + }, + speakingUser: { + color: theme.sidebarText, + fontWeight: '600', + fontSize: 16, + }, + currentChannel: { + color: theme.sidebarText, + opacity: 0.64, + }, + micIcon: { + color: theme.sidebarText, + width: 42, + height: 42, + textAlign: 'center', + textAlignVertical: 'center', + justifyContent: 'center', + backgroundColor: '#3DB887', + borderRadius: 4, + margin: 4, + padding: 9, + overflow: 'hidden', + }, + muted: { + backgroundColor: 'transparent', + }, + expandIcon: { + color: theme.sidebarText, + padding: 8, + marginRight: 8, + }, + }; +}); + +const CurrentCall = (props: Props) => { + useEffect(() => { + EventEmitter.emit(ViewTypes.CURRENT_CALL_BAR_VISIBLE, Boolean(props.call)); + return () => { + EventEmitter.emit(ViewTypes.CURRENT_CALL_BAR_VISIBLE, Boolean(false)); + }; + }, [props.call]); + + const goToCallScreen = useCallback(() => { + const options: Options = { + layout: { + backgroundColor: '#000', + componentBackgroundColor: '#000', + orientation: ['portrait', 'landscape'], + }, + topBar: { + background: { + color: '#000', + }, + visible: Platform.OS === 'android', + }, + }; + goToScreen('Call', 'Call', {}, options); + }, []); + + const muteUnmute = useCallback(() => { + if (props.currentParticipant?.muted) { + props.actions.unmuteMyself(props.call.channelId); + } else { + props.actions.muteMyself(props.call.channelId); + } + }, [props.currentParticipant?.muted]); + + if (!props.call) { + return null; + } + + const style = getStyleSheet(props.theme); + return ( + + + + + + + + + + + + + + + + + + + + ); +}; +export default CurrentCall; diff --git a/app/products/calls/components/current_call/index.ts b/app/products/calls/components/current_call/index.ts new file mode 100644 index 000000000..cdca02f26 --- /dev/null +++ b/app/products/calls/components/current_call/index.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import {connect} from 'react-redux'; +import {bindActionCreators, Dispatch} from 'redux'; + +import {getChannel} from '@mm-redux/selectors/entities/channels'; +import {getTheme, getTeammateNameDisplaySetting} from '@mm-redux/selectors/entities/preferences'; +import {getCurrentUserId} from '@mm-redux/selectors/entities/users'; +import {muteMyself, unmuteMyself} from '@mmproducts/calls/store/actions/calls'; +import {getCurrentCall} from '@mmproducts/calls/store/selectors/calls'; + +import CurrentCall from './current_call'; + +import type {GlobalState} from '@mm-redux/types/store'; + +function mapStateToProps(state: GlobalState) { + const currentCall = getCurrentCall(state); + const currentUserId = getCurrentUserId(state); + const speakerId = currentCall && currentCall.speakers && currentCall.speakers[0]; + const speaker = currentCall && ((speakerId && currentCall.participants[speakerId]) || Object.values(currentCall.participants)[0]); + const currentParticipant = currentCall?.participants[currentUserId]; + return { + theme: getTheme(state), + call: currentCall, + speaker, + speakerUser: speaker ? state.entities.users.profiles[speaker.id] : null, + channel: getChannel(state, currentCall?.channelId || ''), + currentParticipant, + teammateNameDisplay: getTeammateNameDisplaySetting(state), + }; +} + +function mapDispatchToProps(dispatch: Dispatch) { + return { + actions: bindActionCreators({ + muteMyself, + unmuteMyself, + }, dispatch), + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(CurrentCall); diff --git a/app/products/calls/components/enable_disable_calls.test.js b/app/products/calls/components/enable_disable_calls.test.js new file mode 100644 index 000000000..689cee34d --- /dev/null +++ b/app/products/calls/components/enable_disable_calls.test.js @@ -0,0 +1,39 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {shallow} from 'enzyme'; +import React from 'react'; + +import Preferences from '@mm-redux/constants/preferences'; + +import EnableDisableCalls from './enable_disable_calls'; + +describe('EnableDisableCalls', () => { + const baseProps = { + testID: 'test-id', + theme: Preferences.THEMES.denim, + onPress: jest.fn(), + canEnableDisableCalls: true, + enabled: false, + }; + + test('should match snapshot if calls are disabled', () => { + const wrapper = shallow(); + + expect(wrapper.getElement()).toMatchSnapshot(); + }); + + test('should match snapshot if calls are enabled', () => { + const props = {...baseProps, enabled: true}; + const wrapper = shallow(); + + expect(wrapper.getElement()).toMatchSnapshot(); + }); + + test('should be null if you can not enable/disable calls', () => { + const props = {...baseProps, canEnableDisableCalls: false}; + const wrapper = shallow(); + + expect(wrapper.getElement()).toBeNull(); + }); +}); diff --git a/app/products/calls/components/enable_disable_calls.tsx b/app/products/calls/components/enable_disable_calls.tsx new file mode 100644 index 000000000..3c16adec8 --- /dev/null +++ b/app/products/calls/components/enable_disable_calls.tsx @@ -0,0 +1,45 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; + +import {Theme} from '@mm-redux/types/theme'; +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 = { + testID?: string; + theme: Theme; + onPress: (channelId: string) => void; + canEnableDisableCalls: boolean; + enabled: boolean; +} + +const EnableDisableCalls = (props: Props) => { + const {testID, canEnableDisableCalls, theme, onPress, enabled} = props; + + const handleEnableDisableCalls = useCallback(preventDoubleTap(onPress), [onPress]); + + if (!canEnableDisableCalls) { + return null; + } + + return ( + <> + + + + ); +}; + +export default EnableDisableCalls; diff --git a/app/products/calls/components/floating_call_container.test.js b/app/products/calls/components/floating_call_container.test.js new file mode 100644 index 000000000..be9eaae21 --- /dev/null +++ b/app/products/calls/components/floating_call_container.test.js @@ -0,0 +1,16 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {shallow} from 'enzyme'; +import React from 'react'; +import {Text} from 'react-native'; + +import FloatingCallContainer from './floating_call_container'; + +describe('FloatingCallContainer', () => { + test('should match snapshot', () => { + const wrapper = shallow({'test'}); + + expect(wrapper.getElement()).toMatchSnapshot(); + }); +}); diff --git a/app/products/calls/components/floating_call_container.tsx b/app/products/calls/components/floating_call_container.tsx new file mode 100644 index 000000000..1aa5de5c4 --- /dev/null +++ b/app/products/calls/components/floating_call_container.tsx @@ -0,0 +1,57 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useState, useEffect} from 'react'; +import {View, Platform} from 'react-native'; + +import {ViewTypes} from '@constants'; +import EventEmitter from '@mm-redux/utils/event_emitter'; +import {makeStyleSheetFromTheme} from '@utils/theme'; + +const { + IOS_TOP_PORTRAIT, + STATUS_BAR_HEIGHT, +} = ViewTypes; + +const getStyleSheet = makeStyleSheetFromTheme(() => { + const topBarHeight = Platform.select({android: 9, ios: IOS_TOP_PORTRAIT - STATUS_BAR_HEIGHT}) || 0; + + return { + wrapper: { + position: 'absolute', + top: topBarHeight + ViewTypes.STATUS_BAR_HEIGHT + 27, + width: '100%', + ...Platform.select({ + android: { + elevation: 9, + }, + ios: { + zIndex: 9, + }, + }), + }, + withIndicatorBar: { + top: topBarHeight + ViewTypes.STATUS_BAR_HEIGHT + 27 + ViewTypes.INDICATOR_BAR_HEIGHT, + }, + }; +}); + +type Props = { + children: React.ReactNodeArray; +} + +const FloatingCallContainer = (props: Props) => { + const style = getStyleSheet(props); + const [indicatorBarVisible, setIndicatorBarVisible] = useState(false); + useEffect(() => { + EventEmitter.on(ViewTypes.INDICATOR_BAR_VISIBLE, setIndicatorBarVisible); + return () => EventEmitter.off(ViewTypes.INDICATOR_BAR_VISIBLE, setIndicatorBarVisible); + }, []); + return ( + + {props.children} + + ); +}; + +export default FloatingCallContainer; 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 new file mode 100644 index 000000000..ffeab48d0 --- /dev/null +++ b/app/products/calls/components/join_call/__snapshots__/join_call.test.js.snap @@ -0,0 +1,89 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`JoinCall should match snapshot 1`] = ` + + + + Join Call + + + + + + + } + userIds={ + Array [ + "user-1-id", + "user-2-id", + ] + } + /> + + +`; diff --git a/app/products/calls/components/join_call/index.ts b/app/products/calls/components/join_call/index.ts new file mode 100644 index 000000000..9b8d6f5ba --- /dev/null +++ b/app/products/calls/components/join_call/index.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import {connect} from 'react-redux'; +import {bindActionCreators, Dispatch} from 'redux'; + +import {getChannel, getCurrentChannelId} from '@mm-redux/selectors/entities/channels'; +import {getTheme} from '@mm-redux/selectors/entities/preferences'; +import {joinCall} from '@mmproducts/calls/store/actions/calls'; +import {getCalls, getCurrentCall} from '@mmproducts/calls/store/selectors/calls'; + +import JoinCall from './join_call'; + +import type {GlobalState} from '@mm-redux/types/store'; + +function mapStateToProps(state: GlobalState) { + const currentChannelId = getCurrentChannelId(state); + const call = getCalls(state)[currentChannelId]; + const currentCall = getCurrentCall(state); + return { + theme: getTheme(state), + call: call === currentCall ? null : call, + confirmToJoin: Boolean(currentCall && call && currentCall.channelId !== call.channelId), + alreadyInTheCall: Boolean(currentCall && call && currentCall.channelId === call.channelId), + currentChannelName: getChannel(state, currentChannelId)?.display_name, + callChannelName: currentCall ? getChannel(state, currentCall.channelId)?.display_name : '', + }; +} + +function mapDispatchToProps(dispatch: Dispatch) { + return { + actions: bindActionCreators({ + joinCall, + }, dispatch), + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(JoinCall); diff --git a/app/products/calls/components/join_call/join_call.test.js b/app/products/calls/components/join_call/join_call.test.js new file mode 100644 index 000000000..12ecf19d4 --- /dev/null +++ b/app/products/calls/components/join_call/join_call.test.js @@ -0,0 +1,75 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Alert} from 'react-native'; + +import Preferences from '@mm-redux/constants/preferences'; +import {shallowWithIntl} from '@test/intl-test-helper'; + +import JoinCall from './join_call'; + +describe('JoinCall', () => { + const baseProps = { + actions: { + joinCall: jest.fn(), + }, + theme: Preferences.THEMES.denim, + call: { + participants: { + 'user-1-id': { + id: 'user-1-id', + muted: false, + isTalking: false, + }, + 'user-2-id': { + id: 'user-2-id', + muted: true, + isTalking: true, + }, + }, + channelId: 'channel-id', + startTime: 100, + speakers: 'user-2-id', + screenOn: false, + threadId: false, + }, + confirmToJoin: false, + alreadyInTheCall: false, + currentChannelName: 'Current Channel', + callChannelName: 'Call Channel', + }; + + test('should match snapshot', () => { + const wrapper = shallowWithIntl().dive(); + + expect(wrapper.getElement()).toMatchSnapshot(); + }); + + test('should not show it when I am already in the call', () => { + const props = {...baseProps, alreadyInTheCall: true}; + const wrapper = shallowWithIntl().dive(); + + expect(wrapper.getElement()).toBeNull(); + }); + + test('should join on click', () => { + const joinCall = jest.fn(); + const props = {...baseProps, actions: {joinCall}}; + const wrapper = shallowWithIntl().dive(); + + wrapper.simulate('press'); + expect(Alert.alert).not.toHaveBeenCalled(); + expect(props.actions.joinCall).toHaveBeenCalled(); + }); + + test('should ask for confirmation on click', () => { + const joinCall = jest.fn(); + const props = {...baseProps, confirmToJoin: true, actions: {joinCall}}; + const wrapper = shallowWithIntl().dive(); + + wrapper.simulate('press'); + expect(Alert.alert).toHaveBeenCalled(); + expect(props.actions.joinCall).not.toHaveBeenCalled(); + }); +}); diff --git a/app/products/calls/components/join_call/join_call.tsx b/app/products/calls/components/join_call/join_call.tsx new file mode 100644 index 000000000..bcdf10c76 --- /dev/null +++ b/app/products/calls/components/join_call/join_call.tsx @@ -0,0 +1,132 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useEffect, useMemo} from 'react'; +import {injectIntl, IntlShape} from 'react-intl'; +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'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +import type {Theme} from '@mm-redux/types/theme'; +import type {Call} from '@mmproducts/calls/store/types/calls'; + +type Props = { + actions: { + joinCall: (channelId: string) => any; + }; + theme: Theme; + call: Call; + confirmToJoin: boolean; + alreadyInTheCall: boolean; + currentChannelName: string; + callChannelName: string; + intl: typeof IntlShape; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + flexDirection: 'row', + backgroundColor: '#3DB887', + width: '100%', + padding: 5, + justifyContent: 'center', + alignItems: 'center', + height: JOIN_CALL_BAR_HEIGHT, + }, + joinCallIcon: { + color: theme.sidebarText, + marginLeft: 10, + marginRight: 5, + }, + joinCall: { + color: theme.sidebarText, + fontWeight: 'bold', + fontSize: 16, + }, + started: { + flex: 1, + color: theme.sidebarText, + fontWeight: '400', + marginLeft: 10, + }, + avatars: { + marginRight: 5, + }, + headerText: { + color: changeOpacity(theme.centerChannelColor, 0.56), + fontSize: 12, + fontWeight: '600', + paddingHorizontal: 16, + paddingVertical: 0, + top: 16, + }, + }; +}); + +const JoinCall = (props: Props) => { + if (!props.call) { + return null; + } + + useEffect(() => { + EventEmitter.emit(ViewTypes.JOIN_CALL_BAR_VISIBLE, Boolean(props.call && !props.alreadyInTheCall)); + return () => { + EventEmitter.emit(ViewTypes.JOIN_CALL_BAR_VISIBLE, Boolean(false)); + }; + }, [props.call, props.alreadyInTheCall]); + + const joinHandler = useCallback(() => { + leaveAndJoinWithAlert(props.intl, props.call.channelId, props.callChannelName, props.currentChannelName, props.confirmToJoin, props.actions.joinCall); + }, [props.call.channelId, props.callChannelName, props.currentChannelName, props.confirmToJoin, props.actions.joinCall]); + + if (props.alreadyInTheCall) { + return null; + } + + const style = getStyleSheet(props.theme); + const userIds = useMemo(() => { + return Object.values(props.call.participants || {}).map((x) => x.id); + }, [props.call.participants]); + + return ( + + + {'Join Call'} + + + + + + } + /> + + + ); +}; +export default injectIntl(JoinCall); diff --git a/app/products/calls/components/leave_and_join_alert.tsx b/app/products/calls/components/leave_and_join_alert.tsx new file mode 100644 index 000000000..4c0de85c7 --- /dev/null +++ b/app/products/calls/components/leave_and_join_alert.tsx @@ -0,0 +1,29 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {IntlShape} from 'react-intl'; +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}), + [ + { + text: intl.formatMessage({id: 'calls.confirm-to-join-cancel', defaultMessage: 'Cancel'}), + }, + { + text: intl.formatMessage({id: 'calls.confirm-to-join-leave-and-join', defaultMessage: 'Leave & Join'}), + onPress: () => joinCall(channelId), + style: 'cancel', + }, + ], + ); + } else { + joinCall(channelId); + } +} 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 new file mode 100644 index 000000000..056244493 --- /dev/null +++ b/app/products/calls/components/start_call/__snapshots__/start_call.test.js.snap @@ -0,0 +1,151 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`StartCall should match snapshot 1`] = ` + + + + +`; + +exports[`StartCall should match snapshot when there is already an ongoing call in the channel 1`] = ` + + + + +`; diff --git a/app/products/calls/components/start_call/index.ts b/app/products/calls/components/start_call/index.ts new file mode 100644 index 000000000..ba67a14d2 --- /dev/null +++ b/app/products/calls/components/start_call/index.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import {connect} from 'react-redux'; +import {bindActionCreators, Dispatch} from 'redux'; + +import {getChannel, getCurrentChannelId} from '@mm-redux/selectors/entities/channels'; +import {joinCall} from '@mmproducts/calls/store/actions/calls'; +import {getCalls, getCurrentCall} from '@mmproducts/calls/store/selectors/calls'; + +import StartCall from './start_call'; + +import type {GlobalState} from '@mm-redux/types/store'; + +function mapStateToProps(state: GlobalState) { + const currentChannelId = getCurrentChannelId(state); + const call = getCalls(state)[currentChannelId]; + const currentCall = getCurrentCall(state); + return { + confirmToJoin: Boolean(currentCall && currentCall.channelId !== currentChannelId), + alreadyInTheCall: Boolean(currentCall && call && currentCall.channelId === call.channelId), + callChannelName: currentCall ? getChannel(state, currentCall.channelId)?.display_name : '', + ongoingCall: Boolean(call), + }; +} + +function mapDispatchToProps(dispatch: Dispatch) { + return { + actions: bindActionCreators({ + joinCall, + }, dispatch), + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(StartCall); diff --git a/app/products/calls/components/start_call/start_call.test.js b/app/products/calls/components/start_call/start_call.test.js new file mode 100644 index 000000000..d1ea59646 --- /dev/null +++ b/app/products/calls/components/start_call/start_call.test.js @@ -0,0 +1,75 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Alert, TouchableHighlight} from 'react-native'; + +import Preferences from '@mm-redux/constants/preferences'; +import ChannelInfoRow from '@screens/channel_info/channel_info_row'; +import {shallowWithIntl} from '@test/intl-test-helper'; + +import StartCall from './start_call'; + +describe('StartCall', () => { + const baseProps = { + actions: { + joinCall: jest.fn(), + }, + testID: 'test-id', + theme: Preferences.THEMES.denim, + currentChannelId: 'channel-id', + currentChannelName: 'Channel Name', + canStartCall: true, + callChannelName: 'Call channel name', + confirmToJoin: false, + alreadyInTheCall: false, + ongoingCall: false, + }; + + test('should match snapshot', () => { + const wrapper = shallowWithIntl().dive(); + + expect(wrapper.getElement()).toMatchSnapshot(); + }); + + test('should match snapshot when there is already an ongoing call in the channel', () => { + const props = {...baseProps, ongoingCall: true}; + const wrapper = shallowWithIntl().dive(); + + expect(wrapper.getElement()).toMatchSnapshot(); + }); + + test('should be null when you are already in the channel call', () => { + const props = {...baseProps, alreadyInTheCall: true}; + const wrapper = shallowWithIntl().dive(); + + expect(wrapper.getElement()).toBeNull(); + }); + + test('should be null if you can not start a call', () => { + const props = {...baseProps, canStartCall: false}; + const wrapper = shallowWithIntl().dive(); + + expect(wrapper.getElement()).toBeNull(); + }); + + test('should join on click', () => { + const joinCall = jest.fn(); + const props = {...baseProps, actions: {joinCall}}; + const wrapper = shallowWithIntl().dive(); + + wrapper.find(ChannelInfoRow).dive().find(TouchableHighlight).simulate('press'); + expect(Alert.alert).not.toHaveBeenCalled(); + expect(props.actions.joinCall).toHaveBeenCalled(); + }); + + test('should ask for confirmation on click', () => { + const joinCall = jest.fn(); + const props = {...baseProps, confirmToJoin: true, actions: {joinCall}}; + const wrapper = shallowWithIntl().dive(); + + wrapper.find(ChannelInfoRow).dive().find(TouchableHighlight).simulate('press'); + expect(Alert.alert).toHaveBeenCalled(); + expect(props.actions.joinCall).not.toHaveBeenCalled(); + }); +}); diff --git a/app/products/calls/components/start_call/start_call.tsx b/app/products/calls/components/start_call/start_call.tsx new file mode 100644 index 000000000..4b976f023 --- /dev/null +++ b/app/products/calls/components/start_call/start_call.tsx @@ -0,0 +1,61 @@ +// 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 {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 = { + actions: { + joinCall: (channelId: string) => any; + }; + testID?: string; + theme: Theme; + currentChannelId: string; + currentChannelName: string; + callChannelName: string; + confirmToJoin: boolean; + alreadyInTheCall: boolean; + canStartCall: boolean; + ongoingCall: boolean; + intl: typeof IntlShape; +} + +const StartCall = (props: Props) => { + const {testID, canStartCall, theme, actions, currentChannelId, callChannelName, currentChannelName, confirmToJoin, alreadyInTheCall, ongoingCall, intl} = props; + + const handleStartCall = useCallback(preventDoubleTap(() => { + leaveAndJoinWithAlert(intl, currentChannelId, callChannelName, currentChannelName, confirmToJoin, actions.joinCall); + }), [actions.joinCall, currentChannelId]); + + if (!canStartCall) { + return null; + } + + if (alreadyInTheCall) { + return null; + } + + return ( + <> + + + + ); +}; + +export default injectIntl(StartCall); diff --git a/app/products/calls/connection.ts b/app/products/calls/connection.ts new file mode 100644 index 000000000..a8b2a5c07 --- /dev/null +++ b/app/products/calls/connection.ts @@ -0,0 +1,153 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import InCallManager from 'react-native-incall-manager'; +import { + MediaStream, + MediaStreamTrack, + mediaDevices, +} from 'react-native-webrtc2'; + +import {Client4} from '@client/rest'; + +import Peer from './simple-peer'; + +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; + 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; + 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 disconnect = () => { + ws.close(); + + streams.forEach((s) => { + s.getTracks().forEach((track: MediaStreamTrack) => { + track.stop(); + }); + }); + + if (peer) { + peer.destroy(); + } + InCallManager.stop(); + + if (closeCb) { + closeCb(); + } + }; + + const mute = () => { + if (audioTrack) { + audioTrack.enabled = false; + } + if (ws) { + ws.send(JSON.stringify({ + type: 'mute', + })); + } + }; + + const unmute = () => { + if (audioTrack) { + audioTrack.enabled = true; + } + if (ws) { + ws.send(JSON.stringify({ + type: 'unmute', + })); + } + }; + + ws.onerror = (err) => console.log('WS ERROR', err); // eslint-disable-line no-console + + ws.onopen = async () => { + InCallManager.start({media: 'audio'}); + peer = new Peer(stream); + peer.on('signal', (data: any) => { + if (data.type === 'offer' || data.type === 'answer') { + ws.send(JSON.stringify({ + type: 'signal', + data, + })); + } else if (data.type === 'candidate') { + ws.send(JSON.stringify({ + type: 'ice', + data, + })); + } + }); + + peer.on('stream', (remoteStream: MediaStream) => { + streams.push(remoteStream); + if (remoteStream.getVideoTracks().length > 0) { + setScreenShareURL(remoteStream.toURL()); + } + }); + + 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); + } + }; + }; + + const waitForReady = () => { + const waitForReadyImpl = (callback: () => void, fail: () => void, timeout: number) => { + if (timeout <= 0) { + fail(); + return; + } + setTimeout(() => { + if (ws.readyState === WebSocket.OPEN) { + callback(); + } else { + waitForReadyImpl(callback, fail, timeout - 10); + } + }, 10); + }; + + const promise = new Promise((resolve, reject) => { + waitForReadyImpl(resolve, reject, websocketConnectTimeout); + }); + + return promise; + }; + + client = { + disconnect, + mute, + unmute, + waitForReady, + }; + + return client; +} 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 new file mode 100644 index 000000000..b0ffaa522 --- /dev/null +++ b/app/products/calls/screens/call/__snapshots__/call_screen.test.js.snap @@ -0,0 +1,2078 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CallScreen Landscape should match snapshot 1`] = ` + + + + + + + + + + + + + + User 1 + + + + + + User 2 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`CallScreen Landscape should match snapshot with screenshare 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`CallScreen Portrait should match snapshot 1`] = ` + + + + + + + + + + + + + + User 1 + + + + + + User 2 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`CallScreen Portrait should match snapshot with screenshare 1`] = ` + + + + + + + + + + + + + + User 1 + + + + + + User 2 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`CallScreen should show controls in landscape view on click the screen share 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`CallScreen should show controls in landscape view on click the users list 1`] = ` + + + + + + + + + + + + + + User 1 + + + + + + User 2 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; diff --git a/app/products/calls/screens/call/call_screen.test.js b/app/products/calls/screens/call/call_screen.test.js new file mode 100644 index 000000000..b2dbda5b2 --- /dev/null +++ b/app/products/calls/screens/call/call_screen.test.js @@ -0,0 +1,168 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {shallow} from 'enzyme'; +import React from 'react'; + +import Preferences from '@mm-redux/constants/preferences'; + +import CallScreen from './call_screen'; + +describe('CallScreen', () => { + const baseProps = { + actions: { + muteMyself: jest.fn(), + unmuteMyself: jest.fn(), + leaveCall: jest.fn(), + }, + theme: Preferences.THEMES.denim, + call: { + participants: { + 'user-1-id': { + id: 'user-1-id', + muted: false, + isTalking: false, + }, + 'user-2-id': { + id: 'user-2-id', + muted: true, + isTalking: true, + }, + }, + channelId: 'channel-id', + startTime: 100, + speakers: 'user-2-id', + screenOn: false, + 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, + }, + teammateNameDisplay: Preferences.DISPLAY_PREFER_NICKNAME, + screenShareURL: '', + }; + + beforeEach(() => { + jest.doMock('react-native/Libraries/Utilities/useWindowDimensions', () => ({ + default: jest.fn().mockReturnValue({width: 800, height: 400}), + })); + }); + + afterEach(() => { + jest.resetModules(); + }); + + test('should show controls in landscape view on click the users list', () => { + const props = {...baseProps, call: {...baseProps.call, screenOn: false}}; + 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 wrapper = shallow(); + wrapper.find({testID: 'screen-share-container'}).simulate('press'); + expect(wrapper.getElement()).toMatchSnapshot(); + }); + + ['Portrait', 'Landscape'].forEach((orientation) => { + describe(orientation, () => { + beforeEach(() => { + if (orientation === 'Landscape') { + jest.doMock('react-native/Libraries/Utilities/useWindowDimensions', () => ({ + default: jest.fn().mockReturnValue({width: 800, height: 400}), + })); + } else { + jest.doMock('react-native/Libraries/Utilities/useWindowDimensions', () => ({ + default: jest.fn().mockReturnValue({width: 400, height: 800}), + })); + } + }); + + afterEach(() => { + jest.resetModules(); + }); + + test('should match snapshot', () => { + const wrapper = shallow(); + + expect(wrapper.getElement()).toMatchSnapshot(); + }); + + test('should match snapshot with screenshare', () => { + const props = {...baseProps, call: {...baseProps.call, screenOn: true}, screenShareURL: 'screen-share-url'}; + const wrapper = shallow(); + + expect(wrapper.getElement()).toMatchSnapshot(); + }); + + test('should leave on click leave button', () => { + const leaveCall = jest.fn(); + const props = {...baseProps, actions: {...baseProps.actions, leaveCall}}; + const wrapper = shallow(); + + wrapper.find({testID: 'leave'}).simulate('press'); + expect(props.actions.leaveCall).toHaveBeenCalled(); + }); + + test('should mute myself on click mute/unmute button if i am not muted', () => { + const muteMyself = jest.fn(); + const unmuteMyself = jest.fn(); + const props = { + ...baseProps, + actions: { + ...baseProps.actions, + muteMyself, + unmuteMyself, + }, + currentParticipant: { + ...baseProps.currentParticipant, + muted: false, + }, + }; + const wrapper = shallow(); + + wrapper.find({testID: 'mute-unmute'}).simulate('press'); + expect(props.actions.muteMyself).toHaveBeenCalled(); + expect(props.actions.unmuteMyself).not.toHaveBeenCalled(); + }); + + test('should mute myself on click mute/unmute button if i am muted', () => { + const muteMyself = jest.fn(); + const unmuteMyself = jest.fn(); + const props = { + ...baseProps, + actions: { + ...baseProps.actions, + muteMyself, + unmuteMyself, + }, + currentParticipant: { + ...baseProps.currentParticipant, + muted: true, + }, + }; + const wrapper = shallow(); + + wrapper.find({testID: 'mute-unmute'}).simulate('press'); + expect(props.actions.muteMyself).not.toHaveBeenCalled(); + expect(props.actions.unmuteMyself).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/app/products/calls/screens/call/call_screen.tsx b/app/products/calls/screens/call/call_screen.tsx new file mode 100644 index 000000000..ed5fccdcb --- /dev/null +++ b/app/products/calls/screens/call/call_screen.tsx @@ -0,0 +1,461 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// 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 {showModalOverCurrentContext, mergeNavigationOptions, popTopScreen, goToScreen} from '@actions/navigation'; +import CompassIcon from '@components/compass_icon'; +import FormattedText from '@components/formatted_text'; +import {THREAD} from '@constants/screen'; +import {GenericAction} from '@mm-redux/types/actions'; +import {displayUsername} from '@mm-redux/utils/user_utils'; +import CallAvatar from '@mmproducts/calls/components/call_avatar'; +import CallDuration from '@mmproducts/calls/components/call_duration'; +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'; + +type Props = { + actions: { + muteMyself: (channelId: string) => GenericAction; + unmuteMyself: (channelId: string) => GenericAction; + leaveCall: () => GenericAction; + }; + theme: Theme; + call: Call|null; + users: IDMappedObjects; + currentParticipant: CallParticipant; + teammateNameDisplay: string; + screenShareURL: string; +} + +const getStyleSheet = makeStyleSheetFromTheme((props: any) => { + const showControls = !props.isLandscape || props.showControlsInLandscape; + const buttons: any = { + flexDirection: 'column', + backgroundColor: 'rgba(255,255,255,0.16)', + width: '100%', + paddingBottom: 10, + ...Platform.select({ + android: { + elevation: 4, + }, + ios: { + zIndex: 4, + }, + }), + }; + if (props.isLandscape) { + buttons.height = 128; + buttons.position = 'absolute'; + buttons.backgroundColor = 'rgba(0,0,0,0.64)'; + buttons.bottom = 0; + if (!showControls) { + buttons.bottom = 1000; + } + } + const header: any = { + flexDirection: 'row', + width: '100%', + padding: 14, + ...Platform.select({ + android: { + elevation: 4, + }, + ios: { + zIndex: 4, + }, + }), + }; + if (props.isLandscape) { + header.position = 'absolute'; + header.top = 0; + header.backgroundColor = 'rgba(0,0,0,0.64)'; + header.height = 64; + header.padding = 0; + if (!showControls) { + header.top = -1000; + } + } + const usersScroll: any = {}; + const users: any = { + flex: 1, + flexDirection: 'row', + flexWrap: 'wrap', + width: '100%', + height: '100%', + alignContent: 'center', + alignItems: 'center', + }; + + if (props.isLandscape && props.call?.screenOn) { + usersScroll.position = 'absolute'; + usersScroll.height = 0; + } + return { + wrapper: { + flex: 1, + }, + container: { + ...Platform.select({ + android: { + elevation: 3, + }, + ios: { + zIndex: 3, + }, + }), + flexDirection: 'column', + backgroundColor: 'black', + width: '100%', + height: '100%', + borderRadius: 5, + alignItems: 'center', + }, + header, + time: { + flex: 1, + color: props.theme.sidebarText, + margin: 10, + padding: 10, + }, + users, + usersScroll, + user: { + flexGrow: 1, + flexDirection: 'column', + alignItems: 'center', + marginTop: props.call?.screenOn ? 0 : 10, + marginBottom: props.call?.screenOn ? 0 : 10, + marginLeft: 10, + marginRight: 10, + }, + username: { + color: props.theme.sidebarText, + }, + buttons, + button: { + flexDirection: 'column', + alignItems: 'center', + flex: 1, + }, + mute: { + flexDirection: 'column', + alignItems: 'center', + padding: 30, + backgroundColor: props.currentParticipant?.muted ? 'rgba(255,255,255,0.16)' : '#3DB887', + borderRadius: 20, + marginBottom: 10, + marginTop: 20, + marginLeft: 10, + marginRight: 10, + }, + otherButtons: { + flexDirection: 'row', + alignItems: 'center', + alignContent: 'space-between', + }, + collapseIcon: { + color: props.theme.sidebarText, + margin: 10, + padding: 10, + backgroundColor: 'rgba(255,255,255,0.12)', + borderRadius: 4, + overflow: 'hidden', + }, + muteIcon: { + color: props.theme.sidebarText, + }, + buttonText: { + color: props.theme.sidebarText, + }, + buttonIcon: { + color: props.theme.sidebarText, + backgroundColor: 'rgba(255,255,255,0.12)', + borderRadius: 34, + padding: 22, + width: 68, + height: 68, + margin: 10, + overflow: 'hidden', + }, + muteIconLandscape: { + backgroundColor: props.currentParticipant?.muted ? 'rgba(255,255,255,0.16)' : '#3DB887', + }, + hangUpIcon: { + backgroundColor: '#D24B4E', + }, + screenShareImage: { + flex: 7, + width: '100%', + height: '100%', + alignItems: 'center', + }, + screenShareText: { + color: 'white', + margin: 3, + }, + }; +}); + +const CallScreen = (props: Props) => { + if (!props.call) { + return null; + } + const {width, height} = useWindowDimensions(); + const isLandscape = width > height; + + const [showControlsInLandscape, setShowControlsInLandscape] = useState(false); + + const style = getStyleSheet({...props, showControlsInLandscape, isLandscape}); + useEffect(() => { + mergeNavigationOptions('Call', { + layout: { + componentBackgroundColor: 'black', + }, + topBar: { + visible: false, + }, + }); + }, []); + + const showOtherActions = () => { + const screen = 'CallOtherActions'; + const passProps = { + }; + + Keyboard.dismiss(); + const otherActionsRequest = requestAnimationFrame(() => { + showModalOverCurrentContext(screen, passProps); + cancelAnimationFrame(otherActionsRequest); + }); + }; + + const minimizeCallHandler = useCallback(() => popTopScreen(), []); + + const leaveCallHandler = useCallback(() => { + popTopScreen(); + props.actions.leaveCall(); + }, [props.actions.leaveCall]); + + const openThreadHandler = useCallback(() => { + const passProps = { + channelId: props.call?.channelId, + rootId: props.call?.threadId, + }; + goToScreen(THREAD, '', passProps); + }, [props.call]); + + const muteUnmuteHandler = useCallback(() => { + if (props.call) { + if (props.currentParticipant?.muted) { + props.actions.unmuteMyself(props.call.channelId); + } else { + props.actions.muteMyself(props.call.channelId); + } + } + }, [props.call.channelId, props.currentParticipant]); + + const toggleControlsInLandscape = useCallback(() => { + setShowControlsInLandscape(!showControlsInLandscape); + }, [showControlsInLandscape]); + + let screenShareView = null; + if (props.screenShareURL && props.call.screenOn) { + screenShareView = ( + + + + + ); + } + let usersList = null; + if (!props.call.screenOn || !isLandscape) { + usersList = ( + + + {Object.values(props.call.participants).map((user) => { + return ( + + + {displayUsername(props.users[user.id], props.teammateNameDisplay)} + + ); + })} + + + ); + } + + return ( + + + + + + + + + {usersList} + {screenShareView} + + {!isLandscape && + + + {props.currentParticipant?.muted && + } + {!props.currentParticipant?.muted && + } + } + + + + + + + + + + + + + + + + + + {isLandscape && + + + {props.currentParticipant?.muted && + } + {!props.currentParticipant?.muted && + } + } + + + + + ); +}; + +export default CallScreen; diff --git a/app/products/calls/screens/call/index.ts b/app/products/calls/screens/call/index.ts new file mode 100644 index 000000000..2901119fc --- /dev/null +++ b/app/products/calls/screens/call/index.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import {connect} from 'react-redux'; +import {bindActionCreators, Dispatch} from 'redux'; + +import {getTheme, getTeammateNameDisplaySetting} from '@mm-redux/selectors/entities/preferences'; +import {getCurrentUserId} from '@mm-redux/selectors/entities/users'; +import {muteMyself, unmuteMyself, leaveCall} from '@mmproducts/calls//store/actions/calls'; +import {getCurrentCall, getScreenShareURL} from '@mmproducts/calls/store/selectors/calls'; + +import CallScreen from './call_screen'; + +import type {GlobalState} from '@mm-redux/types/store'; + +function mapStateToProps(state: GlobalState) { + const currentCall = getCurrentCall(state); + const currentUserId = getCurrentUserId(state); + return { + theme: getTheme(state), + call: currentCall, + teammateNameDisplay: getTeammateNameDisplaySetting(state), + users: state.entities.users.profiles, + currentParticipant: currentCall && currentCall.participants[currentUserId], + screenShareURL: getScreenShareURL(state), + }; +} + +function mapDispatchToProps(dispatch: Dispatch) { + return { + actions: bindActionCreators({ + muteMyself, + unmuteMyself, + leaveCall, + }, dispatch), + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(CallScreen); diff --git a/app/products/calls/screens/call_other_actions/__snapshots__/action.test.js.snap b/app/products/calls/screens/call_other_actions/__snapshots__/action.test.js.snap new file mode 100644 index 000000000..ab6765fe7 --- /dev/null +++ b/app/products/calls/screens/call_other_actions/__snapshots__/action.test.js.snap @@ -0,0 +1,189 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Action should match snapshot 1`] = ` + + + + + + + + + test-text + + + + + + +`; + +exports[`Action should match snapshot when is destructive 1`] = ` + + + + + + + + + test-text + + + + + + +`; diff --git a/app/products/calls/screens/call_other_actions/__snapshots__/call_other_actions.test.js.snap b/app/products/calls/screens/call_other_actions/__snapshots__/call_other_actions.test.js.snap new file mode 100644 index 000000000..263e08b4f --- /dev/null +++ b/app/products/calls/screens/call_other_actions/__snapshots__/call_other_actions.test.js.snap @@ -0,0 +1,151 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CallOtherActions should match snapshot 1`] = ` + + + + + + + +`; diff --git a/app/products/calls/screens/call_other_actions/action.test.js b/app/products/calls/screens/call_other_actions/action.test.js new file mode 100644 index 000000000..13ebfee53 --- /dev/null +++ b/app/products/calls/screens/call_other_actions/action.test.js @@ -0,0 +1,42 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {shallow} from 'enzyme'; +import React from 'react'; + +import Preferences from '@mm-redux/constants/preferences'; + +import Action from './action'; + +describe('Action', () => { + const baseProps = { + theme: Preferences.THEMES.denim, + destructive: false, + icon: 'test-icon', + onPress: jest.fn(), + text: 'test-text', + }; + + test('should match snapshot', () => { + const wrapper = shallow(); + + expect(wrapper.getElement()).toMatchSnapshot(); + }); + + test('should match snapshot when is destructive', () => { + const props = {...baseProps, destructive: true}; + const wrapper = shallow(); + + expect(wrapper.getElement()).toMatchSnapshot(); + }); + + test('should call on callback on press', () => { + const onPress = jest.fn(); + const props = {...baseProps, onPress}; + const wrapper = shallow(); + + wrapper.find({testID: 'action'}).simulate('press'); + + expect(onPress).toBeCalled(); + }); +}); diff --git a/app/products/calls/screens/call_other_actions/action.tsx b/app/products/calls/screens/call_other_actions/action.tsx new file mode 100644 index 000000000..0773b54a5 --- /dev/null +++ b/app/products/calls/screens/call_other_actions/action.tsx @@ -0,0 +1,129 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import { + Text, + Platform, + TouchableHighlight, + TouchableNativeFeedback, + View, +} from 'react-native'; + +import CompassIcon from '@components/compass_icon'; +import {preventDoubleTap} from '@utils/tap'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +import type {Theme} from '@mm-redux/types/theme'; + +type Props = { + destructive: boolean; + icon: string; + onPress: () => null; + text: string; + theme: Theme; +}; + +const Action = (props: Props) => { + const handleOnPress = preventDoubleTap(() => { + props.onPress(); + }, 500); + + const {destructive, icon, text, theme} = props; + const style = getStyleSheet(theme); + + const Touchable = Platform.select({ + ios: TouchableHighlight as any, + android: TouchableNativeFeedback as any, + }); + + const touchableProps = Platform.select({ + ios: { + underlayColor: 'rgba(0, 0, 0, 0.1)', + }, + android: { + background: TouchableNativeFeedback.Ripple( //eslint-disable-line new-cap + 'rgba(0, 0, 0, 0.1)', + false, + ), + }, + }); + + return ( + + + + + + + + + {text} + + + + + + + ); +}; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + height: 51, + width: '100%', + }, + destructive: { + color: '#D0021B', + }, + row: { + flex: 1, + flexDirection: 'row', + }, + iconContainer: { + alignItems: 'center', + height: 50, + justifyContent: 'center', + width: 60, + }, + noIconContainer: { + height: 50, + width: 18, + }, + icon: { + color: changeOpacity(theme.centerChannelColor, 0.64), + }, + textContainer: { + justifyContent: 'center', + flex: 1, + height: 50, + marginRight: 5, + }, + text: { + color: theme.centerChannelColor, + fontSize: 16, + lineHeight: 19, + opacity: 0.9, + letterSpacing: -0.45, + }, + footer: { + marginHorizontal: 17, + borderBottomWidth: 0.5, + borderBottomColor: changeOpacity(theme.centerChannelColor, 0.2), + }, + }; +}); + +export default Action; diff --git a/app/products/calls/screens/call_other_actions/call_other_actions.test.js b/app/products/calls/screens/call_other_actions/call_other_actions.test.js new file mode 100644 index 000000000..fdb7bff93 --- /dev/null +++ b/app/products/calls/screens/call_other_actions/call_other_actions.test.js @@ -0,0 +1,21 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import Preferences from '@mm-redux/constants/preferences'; +import {shallowWithIntl} from '@test/intl-test-helper'; + +import CallOtherActions from './call_other_actions'; + +describe('CallOtherActions', () => { + const baseProps = { + theme: Preferences.THEMES.denim, + }; + + test('should match snapshot', () => { + const wrapper = shallowWithIntl().dive(); + + expect(wrapper.getElement()).toMatchSnapshot(); + }); +}); 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 new file mode 100644 index 000000000..0f79e7cd4 --- /dev/null +++ b/app/products/calls/screens/call_other_actions/call_other_actions.tsx @@ -0,0 +1,66 @@ +// 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 {View} from 'react-native'; + +import {dismissModal} from '@actions/navigation'; +import SlideUpPanel from '@components/slide_up_panel'; + +import Action from './action'; + +import type {Theme} from '@mm-redux/types/theme'; + +type Props = { + theme: Theme; + intl: typeof intlShape; +} + +const CallOtherActions = ({theme, intl}: Props) => { + const close = () => { + dismissModal(); + }; + + // TODO: Implement this whenever we support participants invitation to calls + const addParticipants = useCallback(() => null, []); + + // TODO: Implement this whenever we support calls links + const copyCallLink = useCallback(() => null, []); + + // TODO: Implement this whenever we support give feedback + const giveFeedback = useCallback(() => null, []); + + return ( + + + + + + + + ); +}; + +export default injectIntl(CallOtherActions); diff --git a/app/products/calls/screens/call_other_actions/index.ts b/app/products/calls/screens/call_other_actions/index.ts new file mode 100644 index 000000000..7ae7fc102 --- /dev/null +++ b/app/products/calls/screens/call_other_actions/index.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {connect} from 'react-redux'; + +import {getTheme} from '@mm-redux/selectors/entities/preferences'; + +import CallOtherActions from './call_other_actions'; + +import type {GlobalState} from '@mm-redux/types/store'; + +function mapStateToProps(state: GlobalState) { + return { + theme: getTheme(state), + }; +} + +export default connect(mapStateToProps)(CallOtherActions); diff --git a/app/products/calls/simple-peer.ts b/app/products/calls/simple-peer.ts new file mode 100644 index 000000000..62270321b --- /dev/null +++ b/app/products/calls/simple-peer.ts @@ -0,0 +1,1001 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +/*! based on simple-peer. MIT License. Feross Aboukhadijeh + * */ + +import {Buffer} from 'buffer'; + +import { + RTCPeerConnection, + RTCIceCandidate, + RTCSessionDescription, + MediaStream, + MediaStreamTrack, + EventOnCandidate, + EventOnAddStream, + RTCDataChannel, + RTCSessionDescriptionType, + MessageEvent, + RTCIceCandidateType, +} from 'react-native-webrtc2'; +import stream from 'readable-stream'; + +const queueMicrotask = (callback: any) => { + Promise.resolve().then(callback).catch((e) => setTimeout(() => { + throw e; + })); +}; + +const errCode = (err: Error, code: string) => { + Object.defineProperty(err, 'code', {value: code, enumerable: true, configurable: true}); + return err; +}; + +function generateId(): string { + // Implementation taken from http://stackoverflow.com/a/2117523 + let id = 'xxxxxxxxxxxxxxxxxxxx'; + + id = id.replace(/[xy]/g, (c) => { + const r = Math.floor(Math.random() * 16); + + let v; + if (c === 'x') { + v = r; + } else { + v = (r & 0x3) | 0x8; + } + + return v.toString(16); + }); + + return id; +} + +const MAX_BUFFERED_AMOUNT = 64 * 1024; +const ICECOMPLETE_TIMEOUT = 5 * 1000; +const CHANNEL_CLOSING_TIMEOUT = 5 * 1000; + +/** + * WebRTC peer connection. Same API as node core `net.Socket`, plus a few extra methods. + * Duplex stream. + * @param {Object} opts + */ +export default class Peer extends stream.Duplex { + destroyed = false; + destroying = false; + connecting = false; + isConnected = false; + id = generateId().slice(0, 7); + channelName = generateId(); + streams: MediaStream[] + + private pcReady = false; + private channelReady = false; + private iceComplete = false; // ice candidate trickle done (got null candidate) + private iceCompleteTimer: ReturnType|null = null; // send an offer/answer anyway after some timeout + private channel: RTCDataChannel|null = null; + private pendingCandidates: RTCIceCandidateType[] = []; + + private isNegotiating = false; // is this peer waiting for negotiation to complete? + private batchedNegotiation = false; // batch synchronous negotiations + private queuedNegotiation = false; // is there a queued negotiation request? + private sendersAwaitingStable = []; + private senderMap = new Map(); + private closingInterval: ReturnType|null = null; + + private remoteTracks: MediaStreamTrack[] = []; + private remoteStreams: MediaStream[] = []; + + private chunk = null; + private cb: ((error?: Error | null) => void) | null = null; + private interval: ReturnType|null = null; + + private pc: RTCPeerConnection|null = null; + private onFinishBound?: () => void; + + constructor(localStream: MediaStream) { + super({allowHalfOpen: false}); + + this.streams = localStream ? [localStream] : []; + + this.onFinishBound = () => { + this.onFinish(); + }; + + try { + this.pc = new RTCPeerConnection({ + iceServers: [ + { + urls: [ + 'stun:stun.l.google.com:19302', + 'stun:global.stun.twilio.com:3478', + ], + }, + ], + sdpSemantics: 'unified-plan', + }); + } catch (err) { + this.destroy(errCode(err, 'ERR_PC_CONSTRUCTOR')); + return; + } + + // We prefer feature detection whenever possible, but sometimes that's not + // possible for certain implementations. + this.pc.oniceconnectionstatechange = () => { + this.onIceStateChange(); + }; + this.pc.onicegatheringstatechange = () => { + this.onIceStateChange(); + }; + this.pc.onconnectionstatechange = () => { + this.onConnectionStateChange(); + }; + this.pc.onsignalingstatechange = () => { + this.onSignalingStateChange(); + }; + this.pc.onicecandidate = (event: EventOnCandidate) => { + this.onIceCandidate(event); + }; + + // Other spec events, unused by this implementation: + // - onconnectionstatechange + // - onicecandidateerror + // - onfingerprintfailure + // - onnegotiationneeded + + this.setupData(this.pc.createDataChannel(this.channelName, {})); + + if (this.streams) { + this.streams.forEach((s) => { + this.addStream(s); + }); + } + + this.pc.onaddstream = (event: EventOnAddStream) => { + this.onStream(event); + }; + + this.needsNegotiation(); + + this.once('finish', this.onFinishBound); + } + + get bufferSize() { + return (this.channel && this.channel.bufferedAmount) || 0; + } + + // HACK: it's possible channel.readyState is "closing" before peer.destroy() fires + // https://bugs.chromium.org/p/chromium/issues/detail?id=882743 + get connected() { + return this.isConnected && this.channel?.readyState === 'open'; + } + + signal(dataIn: string | any) { + if (this.destroying) { + return; + } + if (this.destroyed) { + throw errCode( + new Error('cannot signal after peer is destroyed'), + 'ERR_DESTROYED', + ); + } + + let data = dataIn; + if (typeof data === 'string') { + try { + data = JSON.parse(dataIn); + } catch (err) { + data = {}; + } + } + + if (data.renegotiate) { + this.needsNegotiation(); + } + if (data.transceiverRequest) { + this.addTransceiver( + data.transceiverRequest.kind, + data.transceiverRequest.init, + ); + } + if (data.candidate) { + if (this.pc?.remoteDescription && this.pc?.remoteDescription.type) { + this.addIceCandidate(data.candidate); + } else { + this.pendingCandidates.push(data.candidate); + } + } + if (data.sdp) { + this.pc?. + setRemoteDescription( + new RTCSessionDescription(data), + ). + then(() => { + if (this.destroyed) { + return; + } + + this.pendingCandidates.forEach((candidate: RTCIceCandidateType) => { + this.addIceCandidate(candidate); + }); + this.pendingCandidates = []; + + if (this.pc?.remoteDescription.type === 'offer') { + this.createAnswer(); + } + }). + catch((err: Error) => { + this.destroy(errCode(err, 'ERR_SET_REMOTE_DESCRIPTION')); + }); + } + if ( + !data.sdp && + !data.candidate && + !data.renegotiate && + !data.transceiverRequest + ) { + this.destroy( + errCode( + new Error('signal() called with invalid signal data'), + 'ERR_SIGNALING', + ), + ); + } + } + + addIceCandidate(candidate: RTCIceCandidateType) { + const iceCandidateObj = new RTCIceCandidate(candidate); + this.pc?.addIceCandidate(iceCandidateObj).catch((err: Error) => { + this.destroy(errCode(err, 'ERR_ADD_ICE_CANDIDATE')); + }); + } + + /** + * Send text/binary data to the remote peer. + * @param {ArrayBufferView|ArrayBuffer|Buffer|string|Blob} chunk + */ + send(chunk: string | ArrayBuffer | ArrayBufferView) { + if (this.destroying) { + return; + } + if (this.destroyed) { + throw errCode( + new Error('cannot send after peer is destroyed'), + 'ERR_DESTROYED', + ); + } + this.channel?.send(chunk); + } + + /** + * Add a Transceiver to the connection. + * @param {String} kind + * @param {Object} init + */ + addTransceiver(kind: 'audio'|'video'|MediaStreamTrack, init: any) { + if (this.destroying) { + return; + } + if (this.destroyed) { + throw errCode( + new Error('cannot addTransceiver after peer is destroyed'), + 'ERR_DESTROYED', + ); + } + + try { + this.pc?.addTransceiver(kind, init); + this.needsNegotiation(); + } catch (err) { + this.destroy(errCode(err, 'ERR_ADD_TRANSCEIVER')); + } + } + + /** + * Add a MediaStream to the connection. + * @param {MediaStream} s + */ + addStream(s: MediaStream) { + if (this.destroying) { + return; + } + if (this.destroyed) { + throw errCode( + new Error('cannot addStream after peer is destroyed'), + 'ERR_DESTROYED', + ); + } + + s.getTracks().forEach((track: MediaStreamTrack) => { + this.addTrack(track, s); + }); + } + + /** + * Add a MediaStreamTrack to the connection. + * @param {MediaStreamTrack} track + * @param {MediaStream} s + */ + addTrack(track: MediaStreamTrack, s: MediaStream) { + if (this.destroying) { + return; + } + if (this.destroyed) { + throw errCode( + new Error('cannot addTrack after peer is destroyed'), + 'ERR_DESTROYED', + ); + } + + const submap = this.senderMap.get(track) || new Map(); // nested Maps map [track, stream] to sender + let sender = submap.get(s); + if (!sender) { + sender = s.addTrack(track); + submap.set(s, sender); + this.senderMap.set(track, submap); + this.needsNegotiation(); + } else if (sender.removed) { + throw errCode( + new Error( + 'Track has been removed. You should enable/disable tracks that you want to re-add.', + ), + 'ERR_SENDER_REMOVED', + ); + } else { + throw errCode( + new Error('Track has already been added to that stream.'), + 'ERR_SENDER_ALREADY_ADDED', + ); + } + } + + needsNegotiation() { + if (this.batchedNegotiation) { + return; + } // batch synchronous renegotiations + this.batchedNegotiation = true; + queueMicrotask(() => { + this.batchedNegotiation = false; + this.negotiate(); + }); + } + + negotiate() { + if (this.destroying) { + return; + } + if (this.destroyed) { + throw errCode( + new Error('cannot negotiate after peer is destroyed'), + 'ERR_DESTROYED', + ); + } + + if (this.isNegotiating) { + this.queuedNegotiation = true; + } else { + setTimeout(() => { + // HACK: Chrome crashes if we immediately call createOffer + this.createOffer(); + }, 0); + } + this.isNegotiating = true; + } + + _destroy(err: Error | null, cb: (error: Error | null) => void) { + if (this.destroyed || this.destroying) { + return; + } + this.destroying = true; + + setTimeout(() => { + // allow events concurrent with the call to _destroy() to fire (see #692) + this.destroyed = true; + this.destroying = false; + + this.readable = false; + this.writable = false; + + // if (!this._readableState?.ended) this.push(null); + // if (!this._writableState?.finished) this.end(); + + this.isConnected = false; + this.pcReady = false; + this.channelReady = false; + this.remoteTracks = []; + this.remoteStreams = []; + this.senderMap = new Map(); + + if (this.closingInterval) { + clearInterval(this.closingInterval); + } + this.closingInterval = null; + + if (this.interval) { + clearInterval(this.interval); + } + this.interval = null; + this.chunk = null; + this.cb = null; + + if (this.onFinishBound) { + this.removeListener('finish', this.onFinishBound); + } + this.onFinishBound = undefined; + + if (this.channel) { + try { + this.channel.close(); + } catch (err) {} // eslint-disable-line + + // allow events concurrent with destruction to be handled + this.channel.onmessage = null; + this.channel.onopen = null; + this.channel.onclose = null; + this.channel.onerror = null; + } + if (this.pc) { + try { + this.pc.close(); + } catch (err) {} // eslint-disable-line + + // allow events concurrent with destruction to be handled + this.pc.oniceconnectionstatechange = () => undefined; + this.pc.onicegatheringstatechange = () => undefined; + this.pc.onsignalingstatechange = () => undefined; + this.pc.onicecandidate = () => undefined; + } + this.pc = null; + this.channel = null; + + if (err) { + this.emit('error', err); + } + this.emit('close'); + cb(null); + }, 0); + } + + setupData(channel: RTCDataChannel) { + if (!channel) { + // In some situations `pc.createDataChannel()` returns `undefined` (in wrtc), + // which is invalid behavior. Handle it gracefully. + // See: https://github.com/feross/simple-peer/issues/163 + this.destroy( + errCode( + new Error( + 'Data channel is missing `channel` property', + ), + 'ERR_DATA_CHANNEL', + ), + ); + return; + } + + this.channel = channel; + this.channel.binaryType = 'arraybuffer'; + + if (typeof this.channel.bufferedAmountLowThreshold === 'number') { + this.channel.bufferedAmountLowThreshold = MAX_BUFFERED_AMOUNT; + } + + this.channelName = this.channel.label; + + this.channel.onmessage = (e: MessageEvent) => { + this.onChannelMessage(e); + }; + this.channel.onbufferedamountlow = () => { + this.onChannelBufferedAmountLow(); + }; + this.channel.onopen = () => { + this.onChannelOpen(); + }; + this.channel.onclose = () => { + this.onChannelClose(); + }; + this.channel.onerror = (e: any) => { + const err = + e.error instanceof Error ? + e.error : + new Error( + `Datachannel error: ${e.message} ${e.filename}:${e.lineno}:${e.colno}`, + ); + this.destroy(errCode(err, 'ERR_DATA_CHANNEL')); + }; + + // HACK: Chrome will sometimes get stuck in readyState "closing", let's check for this condition + // https://bugs.chromium.org/p/chromium/issues/detail?id=882743 + let isClosing = false; + this.closingInterval = setInterval(() => { + // No "onclosing" event + if (this.channel && this.channel.readyState === 'closing') { + if (isClosing) { + this.onChannelClose(); + } // closing timed out: equivalent to onclose firing + isClosing = true; + } else { + isClosing = false; + } + }, CHANNEL_CLOSING_TIMEOUT); + } + + _read() { + return null; + } + + _write(chunk: any, encoding: string, cb: (error?: Error | null) => void): void { + if (this.destroyed) { + cb( + errCode( + new Error('cannot write after peer is destroyed'), + 'ERR_DATA_CHANNEL', + ), + ); + return; + } + + if (this.isConnected) { + try { + this.send(chunk); + } catch (err) { + this.destroy(errCode(err, 'ERR_DATA_CHANNEL')); + return; + } + if (this.channel?.bufferedAmount && this.channel?.bufferedAmount > MAX_BUFFERED_AMOUNT) { + this.cb = cb; + } else { + cb(null); + } + } else { + this.chunk = chunk; + this.cb = cb; + } + } + + // When stream finishes writing, close socket. Half open connections are not + // supported. + onFinish() { + if (this.destroyed) { + return; + } + + // Wait a bit before destroying so the socket flushes. + const destroySoon = () => { + setTimeout(() => this.destroy(), 1000); + }; + + if (this.isConnected) { + destroySoon(); + } else { + this.once('connect', destroySoon); + } + } + + startIceCompleteTimeout() { + if (this.destroyed) { + return; + } + if (this.iceCompleteTimer) { + return; + } + this.iceCompleteTimer = setTimeout(() => { + if (!this.iceComplete) { + this.iceComplete = true; + this.emit('iceTimeout'); + this.emit('iceComplete'); + } + }, ICECOMPLETE_TIMEOUT); + } + + createOffer() { + if (this.destroyed) { + return; + } + + this.pc?. + createOffer({}). + then((offer: RTCSessionDescriptionType) => { + if (this.destroyed) { + return; + } + + const sendOffer = () => { + if (this.destroyed) { + return; + } + const signal = this.pc?.localDescription || offer; + this.emit('signal', { + type: signal.type, + sdp: signal.sdp, + }); + }; + + const onSuccess = () => { + if (this.destroyed) { + return; + } + sendOffer(); + }; + + const onError = (err: Error) => { + this.destroy(errCode(err, 'ERR_SET_LOCAL_DESCRIPTION')); + }; + + this.pc?. + setLocalDescription(offer). + then(onSuccess). + catch(onError); + }). + catch((err: Error) => { + this.destroy(errCode(err, 'ERR_CREATE_OFFER')); + }); + } + + createAnswer() { + if (this.destroyed) { + return; + } + + this.pc?. + createAnswer({}). + then((answer: RTCSessionDescriptionType) => { + if (this.destroyed) { + return; + } + + const sendAnswer = () => { + if (this.destroyed) { + return; + } + const signal = this.pc?.localDescription || answer; + this.emit('signal', { + type: signal.type, + sdp: signal.sdp, + }); + }; + + const onSuccess = () => { + if (this.destroyed) { + return; + } + sendAnswer(); + }; + + const onError = (err: Error) => { + this.destroy(errCode(err, 'ERR_SET_LOCAL_DESCRIPTION')); + }; + + this.pc?. + setLocalDescription(answer). + then(onSuccess). + catch(onError); + }). + catch((err: Error) => { + this.destroy(errCode(err, 'ERR_CREATE_ANSWER')); + }); + } + + onConnectionStateChange() { + if (this.destroyed) { + return; + } + if (this.pc?.connectionState === 'failed') { + this.destroy( + errCode( + new Error('Connection failed.'), + 'ERR_CONNECTION_FAILURE', + ), + ); + } + } + + onIceStateChange() { + if (this.destroyed) { + return; + } + const iceConnectionState = this.pc?.iceConnectionState; + const iceGatheringState = this.pc?.iceGatheringState; + + this.emit('iceStateChange', iceConnectionState, iceGatheringState); + + if ( + iceConnectionState === 'connected' || + iceConnectionState === 'completed' + ) { + this.pcReady = true; + this.maybeReady(); + } + if (iceConnectionState === 'failed') { + this.destroy( + errCode( + new Error('Ice connection failed.'), + 'ERR_ICE_CONNECTION_FAILURE', + ), + ); + } + if (iceConnectionState === 'closed') { + this.destroy( + errCode( + new Error('Ice connection closed.'), + 'ERR_ICE_CONNECTION_CLOSED', + ), + ); + } + } + + getStats(cb: (error: Error|null, reports?: any) => void) { + // statreports can come with a value array instead of properties + const flattenValues = (report: any) => { + if ( + Object.prototype.toString.call(report.values) === + '[object Array]' + ) { + report.values.forEach((value: any) => { + Object.assign(report, value); + }); + } + return report; + }; + + this.pc?.getStats().then( + (res: any) => { + const reports: any[] = []; + res.forEach((report: any) => { + reports.push(flattenValues(report)); + }); + cb(null, reports); + }, + (err: Error) => cb(err), + ); + } + + maybeReady() { + if ( + this.isConnected || + this.connecting || + !this.pcReady || + !this.channelReady + ) { + return; + } + + this.connecting = true; + + // HACK: We can't rely on order here, for details see https://github.com/js-platform/node-webrtc/issues/339 + const findCandidatePair = () => { + if (this.destroyed) { + return; + } + + this.getStats((err, itemsParam) => { + if (this.destroyed) { + return; + } + + let items = itemsParam; + + // Treat getStats error as non-fatal. It's not essential. + if (err) { + items = []; + } + + const remoteCandidates: {[key: string]: any} = {}; + const localCandidates: {[key: string]: any} = {}; + const candidatePairs: {[key: string]: any} = {}; + let foundSelectedCandidatePair = false; + + items.forEach((item: any) => { + if ( + item.type === 'remotecandidate' || + item.type === 'remote-candidate' + ) { + remoteCandidates[item.id] = item; + } + if ( + item.type === 'localcandidate' || + item.type === 'local-candidate' + ) { + localCandidates[item.id] = item; + } + if ( + item.type === 'candidatepair' || + item.type === 'candidate-pair' + ) { + candidatePairs[item.id] = item; + } + }); + + items.forEach((item: any) => { + if ( + (item.type === 'transport' && + item.selectedCandidatePairId) || + (item.type === 'googCandidatePair' && + item.googActiveConnection === 'true') || + ((item.type === 'candidatepair' || + item.type === 'candidate-pair') && + item.selected) + ) { + foundSelectedCandidatePair = true; + } + }); + + // Ignore candidate pair selection in browsers like Safari 11 that do not have any local or remote candidates + // But wait until at least 1 candidate pair is available + if ( + !foundSelectedCandidatePair && + (!Object.keys(candidatePairs).length || + Object.keys(localCandidates).length) + ) { + setTimeout(findCandidatePair, 100); + return; + } + this.connecting = false; + this.isConnected = true; + + if (this.chunk) { + try { + this.send(this.chunk); + } catch (err2) { + this.destroy(errCode(err2, 'ERR_DATA_CHANNEL')); + return; + } + this.chunk = null; + + const cb = this.cb; + this.cb = null; + if (cb) { + cb(null); + } + } + + // If `bufferedAmountLowThreshold` and 'onbufferedamountlow' are unsupported, + // fallback to using setInterval to implement backpressure. + if ( + typeof this.channel?.bufferedAmountLowThreshold !== 'number' + ) { + this.interval = setInterval(() => this.onInterval(), 150); + if (this.interval.unref) { + this.interval.unref(); + } + } + + this.emit('connect'); + }); + }; + findCandidatePair(); + } + + onInterval() { + if ( + !this.cb || + !this.channel || + this.channel.bufferedAmount > MAX_BUFFERED_AMOUNT + ) { + return; + } + this.onChannelBufferedAmountLow(); + } + + onSignalingStateChange() { + if (this.destroyed) { + return; + } + + if (this.pc?.signalingState === 'stable') { + this.isNegotiating = false; + + // HACK: Firefox doesn't yet support removing tracks when signalingState !== 'stable' + this.sendersAwaitingStable.forEach((sender) => { + this.pc?.removeTrack(sender); + this.queuedNegotiation = true; + }); + this.sendersAwaitingStable = []; + + if (this.queuedNegotiation) { + this.queuedNegotiation = false; + this.needsNegotiation(); // negotiate again + } else { + this.emit('negotiated'); + } + } + + this.emit('signalingStateChange', this.pc?.signalingState); + } + + onIceCandidate(event: EventOnCandidate) { + if (this.destroyed) { + return; + } + if (event.candidate) { + this.emit('signal', { + type: 'candidate', + candidate: { + candidate: event.candidate.candidate, + sdpMLineIndex: event.candidate.sdpMLineIndex, + sdpMid: event.candidate.sdpMid, + }, + }); + } else if (!event.candidate && !this.iceComplete) { + this.iceComplete = true; + this.emit('iceComplete'); + } + + // as soon as we've received one valid candidate start timeout + if (event.candidate) { + this.startIceCompleteTimeout(); + } + } + + onChannelMessage(event: MessageEvent) { + if (this.destroyed) { + return; + } + let data = event.data; + if (data instanceof ArrayBuffer) { + data = Buffer.from(data); + } + this.push(data); + } + + onChannelBufferedAmountLow() { + if (this.destroyed || !this.cb) { + return; + } + const cb = this.cb; + this.cb = null; + cb(null); + } + + onChannelOpen() { + if (this.isConnected || this.destroyed) { + return; + } + this.channelReady = true; + this.maybeReady(); + } + + onChannelClose() { + if (this.destroyed) { + return; + } + this.destroy(); + } + + onStream(event: EventOnAddStream) { + if (this.destroyed) { + return; + } + + event.target._remoteStreams.forEach((eventStream: MediaStream) => { // eslint-disable-line + eventStream._tracks.forEach((eventTrack: MediaStreamTrack) => { // eslint-disable-line + if ( + this.remoteTracks.some((remoteTrack: MediaStreamTrack) => { // eslint-disable-line + return remoteTrack.id === eventTrack.id; + }) + ) { + return; + } // Only fire one 'stream' event, even though there may be multiple tracks per stream + + if (event.track) { + this.remoteTracks.push(event.track); + this.emit('track', eventTrack, eventStream); + } + }); + + if ( + this.remoteStreams.some((remoteStream) => { + return remoteStream.id === eventStream.id; + }) + ) { + return; + } // Only fire one 'stream' event, even though there may be multiple tracks per stream + + this.remoteStreams.push(eventStream); + queueMicrotask(() => { + this.emit('stream', eventStream); // ensure all tracks have been added + }); + }); + } +} diff --git a/app/products/calls/store/action_types/calls.ts b/app/products/calls/store/action_types/calls.ts new file mode 100644 index 000000000..6ad7e3003 --- /dev/null +++ b/app/products/calls/store/action_types/calls.ts @@ -0,0 +1,25 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import keyMirror from '@mm-redux/utils/key_mirror'; + +export default keyMirror({ + RECEIVED_CALLS: null, + RECEIVED_CALL_STARTED: null, + RECEIVED_CALL_FINISHED: null, + RECEIVED_CHANNEL_CALL_ENABLED: null, + RECEIVED_CHANNEL_CALL_DISABLED: null, + RECEIVED_CHANNEL_CALL_SCREEN_ON: null, + RECEIVED_CHANNEL_CALL_SCREEN_OFF: null, + RECEIVED_JOINED_CALL: null, + RECEIVED_LEFT_CALL: null, + RECEIVED_MYSELF_JOINED_CALL: null, + RECEIVED_MYSELF_LEFT_CALL: null, + RECEIVED_MUTE_USER_CALL: null, + RECEIVED_UNMUTE_USER_CALL: null, + RECEIVED_VOICE_ON_USER_CALL: null, + RECEIVED_VOICE_OFF_USER_CALL: null, + RECEIVED_RAISE_HAND_CALL: null, + RECEIVED_UNRAISE_HAND_CALL: null, + SET_SCREENSHARE_URL: null, +}); diff --git a/app/products/calls/store/actions/calls.test.js b/app/products/calls/store/actions/calls.test.js new file mode 100644 index 000000000..becd1d325 --- /dev/null +++ b/app/products/calls/store/actions/calls.test.js @@ -0,0 +1,144 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import assert from 'assert'; + +import {Client4} from '@client/rest'; +import configureStore from '@test/test_store'; + +import CallsTypes from '../action_types/calls'; + +import * as CallsActions from './calls'; + +jest.mock('@client/rest', () => ({ + Client4: { + setUrl: jest.fn(), + getCalls: jest.fn(() => [ + { + call: { + users: ['user-1', 'user-2'], + states: { + 'user-1': {unmuted: true}, + 'user-2': {unmuted: false}, + }, + start_at: 123, + screen_sharing_id: '', + thread_id: 'thread-1', + }, + channel_id: 'channel-1', + enabled: true, + }, + ]), + enableChannelCalls: jest.fn(() => null), + disableChannelCalls: jest.fn(() => null), + }, +})); + +jest.mock('@mmproducts/calls/connection', () => ({ + newClient: jest.fn(() => Promise.resolve({ + disconnect: jest.fn(), + mute: jest.fn(), + unmute: jest.fn(), + waitForReady: jest.fn(() => Promise.resolve()), + })), +})); + +export function addFakeCall(channelId) { + return { + type: CallsTypes.RECEIVED_CALL_STARTED, + data: { + participants: { + xohi8cki9787fgiryne716u84o: {id: 'xohi8cki9787fgiryne716u84o', isTalking: true, muted: false}, + xohi8cki9787fgiryne716u841: {id: 'xohi8cki9787fgiryne716u84o', isTalking: true, muted: true}, + xohi8cki9787fgiryne716u842: {id: 'xohi8cki9787fgiryne716u84o', isTalking: false, uted: false}, + xohi8cki9787fgiryne716u843: {id: 'xohi8cki9787fgiryne716u84o', isTalking: false, muted: true}, + xohi8cki9787fgiryne716u844: {id: 'xohi8cki9787fgiryne716u84o', isTalking: true, muted: false}, + xohi8cki9787fgiryne716u845: {id: 'xohi8cki9787fgiryne716u84o', isTalking: true, muted: true}, + }, + channelId, + startTime: (new Date()).getTime(), + }, + }; +} + +describe('Actions.Calls', () => { + let store; + const {newClient} = require('@mmproducts/calls/connection'); + + beforeEach(async () => { + newClient.mockClear(); + Client4.setUrl.mockClear(); + Client4.getCalls.mockClear(); + Client4.enableChannelCalls.mockClear(); + Client4.disableChannelCalls.mockClear(); + store = await configureStore(); + }); + + it('joinCall', async () => { + await store.dispatch(addFakeCall('channel-id')); + const response = await store.dispatch(CallsActions.joinCall('channel-id')); + const result = store.getState().entities.calls.joined; + assert.equal('channel-id', result); + assert.equal(response.data, 'channel-id'); + expect(newClient).toBeCalled(); + expect(newClient.mock.calls[0][0]).toBe('channel-id'); + await store.dispatch(CallsActions.leaveCall()); + }); + + it('leaveCall', async () => { + await store.dispatch(addFakeCall('channel-id')); + expect(CallsActions.ws).toBe(null); + + await store.dispatch(CallsActions.joinCall('channel-id')); + let result = store.getState().entities.calls.joined; + assert.equal('channel-id', result); + + expect(CallsActions.ws.disconnect).not.toBeCalled(); + const disconnectMock = CallsActions.ws.disconnect; + await store.dispatch(CallsActions.leaveCall()); + expect(disconnectMock).toBeCalled(); + expect(CallsActions.ws).toBe(null); + + result = store.getState().entities.calls.joined; + assert.equal('', result); + }); + + it('muteMyself', async () => { + await store.dispatch(addFakeCall('channel-id')); + await store.dispatch(CallsActions.joinCall('channel-id')); + await store.dispatch(CallsActions.muteMyself()); + expect(CallsActions.ws.mute).toBeCalled(); + await store.dispatch(CallsActions.leaveCall()); + }); + + it('unmuteMyself', async () => { + await store.dispatch(addFakeCall('channel-id')); + await store.dispatch(CallsActions.joinCall('channel-id')); + await store.dispatch(CallsActions.unmuteMyself()); + expect(CallsActions.ws.unmute).toBeCalled(); + await store.dispatch(CallsActions.leaveCall()); + }); + + it('loadCalls', async () => { + await store.dispatch(CallsActions.loadCalls()); + expect(Client4.getCalls).toBeCalledWith(); + assert.equal(store.getState().entities.calls.calls['channel-1'].channelId, 'channel-1'); + assert.equal(store.getState().entities.calls.enabled['channel-1'], true); + }); + + it('enableChannelCalls', async () => { + assert.equal(store.getState().entities.calls.enabled['channel-1'], undefined); + await store.dispatch(CallsActions.enableChannelCalls('channel-1')); + expect(Client4.enableChannelCalls).toBeCalledWith('channel-1'); + }); + + it('disableChannelCalls', async () => { + assert.equal(store.getState().entities.calls.enabled['channel-1'], undefined); + await store.dispatch(CallsActions.enableChannelCalls('channel-1')); + assert.equal(store.getState().entities.calls.enabled['channel-1'], true); + expect(Client4.disableChannelCalls).not.toBeCalledWith('channel-1'); + await store.dispatch(CallsActions.disableChannelCalls('channel-1')); + expect(Client4.disableChannelCalls).toBeCalledWith('channel-1'); + assert.equal(store.getState().entities.calls.enabled['channel-1'], false); + }); +}); diff --git a/app/products/calls/store/actions/calls.ts b/app/products/calls/store/actions/calls.ts new file mode 100644 index 000000000..0a595bb0f --- /dev/null +++ b/app/products/calls/store/actions/calls.ts @@ -0,0 +1,150 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Client4} from '@client/rest'; +import {logError} from '@mm-redux/actions/errors'; +import {forceLogoutIfNecessary} from '@mm-redux/actions/helpers'; +import {GenericAction, ActionFunc, DispatchFunc, GetStateFunc} from '@mm-redux/types/actions'; +import {Dictionary} from '@mm-redux/types/utilities'; +import {newClient} from '@mmproducts/calls/connection'; +import CallsTypes from '@mmproducts/calls/store/action_types/calls'; + +import type {Call, CallParticipant} from '@mmproducts/calls/store/types/calls'; + +export let ws: any = null; + +export function loadCalls(): ActionFunc { + return async (dispatch: DispatchFunc, getState: GetStateFunc) => { + let resp = []; + try { + resp = await Client4.getCalls(); + } catch (error) { + forceLogoutIfNecessary(error, dispatch, getState); + dispatch(logError(error)); + return {error}; + } + + const callsResults: Dictionary = {}; + const enabledChannels: Dictionary = {}; + for (let i = 0; i < resp.length; i++) { + const channel = resp[i]; + if (channel.call) { + callsResults[channel.channel_id] = { + participants: channel.call.users.reduce((prev: Dictionary, cur: string, curIdx: number) => { + const muted = channel.call.states && channel.call.states[curIdx] ? !channel.call.states[curIdx].unmuted : true; + prev[cur] = {id: cur, muted, isTalking: false}; + return prev; + }, {}), + channelId: channel.channel_id, + startTime: channel.call.start_at, + speakers: [], + screenOn: channel.call.screen_sharing_id, + threadId: channel.call.thread_id, + }; + } + enabledChannels[channel.channel_id] = channel.enabled; + } + + const data = { + calls: callsResults, + enabled: enabledChannels, + }; + + dispatch({type: CallsTypes.RECEIVED_CALLS, data}); + + return {data}; + }; +} + +export function enableChannelCalls(channelId: string): ActionFunc { + return async (dispatch: DispatchFunc, getState: GetStateFunc) => { + try { + await Client4.enableChannelCalls(channelId); + } catch (error) { + forceLogoutIfNecessary(error, dispatch, getState); + dispatch(logError(error)); + return {error}; + } + + dispatch({type: CallsTypes.RECEIVED_CHANNEL_CALL_ENABLED, data: channelId}); + + return {data: channelId}; + }; +} + +export function disableChannelCalls(channelId: string): ActionFunc { + return async (dispatch: DispatchFunc, getState: GetStateFunc) => { + try { + await Client4.disableChannelCalls(channelId); + } catch (error) { + forceLogoutIfNecessary(error, dispatch, getState); + dispatch(logError(error)); + return {error}; + } + + dispatch({type: CallsTypes.RECEIVED_CHANNEL_CALL_DISABLED, data: channelId}); + + return {data: channelId}; + }; +} + +export function joinCall(channelId: string): ActionFunc { + return async (dispatch: DispatchFunc, getState: GetStateFunc) => { + const setScreenShareURL = (url: string) => { + dispatch({ + type: CallsTypes.SET_SCREENSHARE_URL, + data: url, + }); + }; + + if (ws) { + ws.disconnect(); + ws = null; + } + + try { + ws = await newClient(channelId, () => null, setScreenShareURL); + } catch (error) { + forceLogoutIfNecessary(error, dispatch, getState); + dispatch(logError(error)); + return {error}; + } + + try { + await ws.waitForReady(); + dispatch({ + type: CallsTypes.RECEIVED_MYSELF_JOINED_CALL, + data: channelId, + }); + return {data: channelId}; + } catch (e) { + ws.disconnect(); + ws = null; + return {error: 'unable to connect to the voice call'}; + } + }; +} + +export function leaveCall(): GenericAction { + if (ws) { + ws.disconnect(); + ws = null; + } + return { + type: CallsTypes.RECEIVED_MYSELF_LEFT_CALL, + }; +} + +export function muteMyself(): GenericAction { + if (ws) { + ws.mute(); + } + return {type: 'empty'}; +} + +export function unmuteMyself(): GenericAction { + if (ws) { + ws.unmute(); + } + return {type: 'empty'}; +} diff --git a/app/products/calls/store/actions/websockets.ts b/app/products/calls/store/actions/websockets.ts new file mode 100644 index 000000000..d798a58ae --- /dev/null +++ b/app/products/calls/store/actions/websockets.ts @@ -0,0 +1,83 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {GenericAction} from '@mm-redux/types/actions'; +import {WebSocketMessage} from '@mm-redux/types/websocket'; +import CallsTypes from '@mmproducts/calls/store/action_types/calls'; + +export function handleCallUserDisconnected(msg: WebSocketMessage): GenericAction { + return { + type: CallsTypes.RECEIVED_LEFT_CALL, + data: {channelId: msg.broadcast.channel_id, userId: msg.data.userID}, + }; +} + +export function handleCallUserConnected(msg: WebSocketMessage): GenericAction { + return { + type: CallsTypes.RECEIVED_JOINED_CALL, + data: {channelId: msg.broadcast.channel_id, userId: msg.data.userID}, + }; +} + +export function handleCallUserMuted(msg: WebSocketMessage): GenericAction { + return { + type: CallsTypes.RECEIVED_MUTE_USER_CALL, + data: {channelId: msg.broadcast.channel_id, userId: msg.data.userID}, + }; +} + +export function handleCallUserUnmuted(msg: WebSocketMessage): GenericAction { + return { + type: CallsTypes.RECEIVED_UNMUTE_USER_CALL, + data: {channelId: msg.broadcast.channel_id, userId: msg.data.userID}, + }; +} + +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 handleCallUserVoiceOff(msg: WebSocketMessage): GenericAction { + return { + type: CallsTypes.RECEIVED_VOICE_OFF_USER_CALL, + data: {channelId: msg.broadcast.channel_id, userId: msg.data.userID}, + }; +} + +export function handleCallStarted(msg: WebSocketMessage): GenericAction { + return { + type: CallsTypes.RECEIVED_CALL_STARTED, + data: {channelId: msg.data.channelID, startTime: msg.data.start_at, threadId: msg.data.thread_id, participants: {}}, + }; +} + +export function handleCallChannelEnabled(msg: WebSocketMessage): GenericAction { + return { + type: CallsTypes.RECEIVED_CHANNEL_CALL_ENABLED, + data: msg.broadcast.channel_id, + }; +} + +export function handleCallChannelDisabled(msg: WebSocketMessage): GenericAction { + return { + type: CallsTypes.RECEIVED_CHANNEL_CALL_DISABLED, + data: msg.broadcast.channel_id, + }; +} + +export function handleCallScreenOn(msg: WebSocketMessage): GenericAction { + return { + type: CallsTypes.RECEIVED_CHANNEL_CALL_SCREEN_ON, + data: {channelId: msg.broadcast.channel_id, userId: msg.data.userID}, + }; +} + +export function handleCallScreenOff(msg: WebSocketMessage): GenericAction { + return { + type: CallsTypes.RECEIVED_CHANNEL_CALL_SCREEN_OFF, + data: {channelId: msg.broadcast.channel_id, userId: msg.data.userID}, + }; +} diff --git a/app/products/calls/store/reducers/calls.test.js b/app/products/calls/store/reducers/calls.test.js new file mode 100644 index 000000000..b29fe203a --- /dev/null +++ b/app/products/calls/store/reducers/calls.test.js @@ -0,0 +1,362 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import assert from 'assert'; + +import CallsTypes from '@mmproducts/calls/store/action_types/calls'; + +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}, + }, + channelId: 'channel-1', + startTime: 123, + speakers: ['user-2'], + screenOn: '', + threadId: 'thread-1', + }; + const call2 = { + participants: { + 'user-3': {id: 'user-3', muted: false, isTalking: false}, + 'user-4': {id: 'user-4', muted: true, isTalking: true}, + }, + channelId: 'channel-2', + startTime: 123, + speakers: ['user-4'], + screenOn: '', + threadId: 'thread-2', + }; + const call3 = { + participants: { + 'user-5': {id: 'user-5', muted: false, isTalking: false}, + 'user-6': {id: 'user-6', muted: true, isTalking: true}, + }, + channelId: 'channel-3', + startTime: 123, + speakers: ['user-6'], + screenOn: '', + threadId: 'thread-3', + }; + it('initial state', async () => { + let state = {}; + + state = callsReducer(state, {}); + assert.deepEqual(state.calls, {}, 'initial state'); + }); + + it('RECEIVED_CALLS', async () => { + let state = {calls: {calls: {'channel-1': call1}}, enabled: {'channel-1': true}, joined: 'channel-1'}; + const testAction = { + type: CallsTypes.RECEIVED_CALLS, + data: {calls: {'channel-1': call2, 'channel-2': call3}, enabled: {'channel-1': true}}, + }; + + state = callsReducer(state, testAction); + assert.deepEqual(state.calls, {'channel-1': call2, 'channel-2': call3}); + }); + + it('RECEIVED_LEFT_CALL', async () => { + const initialState = {calls: {'channel-1': call1}}; + const testAction = { + type: CallsTypes.RECEIVED_LEFT_CALL, + data: {channelId: 'channel-1', userId: 'user-1'}, + }; + let state = callsReducer(initialState, testAction); + assert.deepEqual( + state.calls, + { + 'channel-1': { + participants: { + 'user-2': {id: 'user-2', muted: true, isTalking: true}, + }, + channelId: 'channel-1', + startTime: 123, + speakers: ['user-2'], + screenOn: false, + threadId: 'thread-1', + }, + }, + ); + + testAction.data = {channelId: 'channel-1', userId: 'not-valid-user'}; + + state = callsReducer(initialState, testAction); + assert.deepEqual(state.calls, {'channel-1': call1}); + + testAction.data = {channelId: 'invalid-channel', userId: 'user-1'}; + + state = callsReducer(initialState, testAction); + assert.deepEqual(state.calls, {'channel-1': call1}); + }); + + it('RECEIVED_JOINED_CALL', async () => { + const initialState = {calls: {'channel-1': call1}}; + const testAction = { + type: CallsTypes.RECEIVED_JOINED_CALL, + data: {channelId: 'channel-1', userId: 'user-3'}, + }; + let state = callsReducer(initialState, testAction); + assert.deepEqual( + state.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}, + }, + channelId: 'channel-1', + startTime: 123, + speakers: ['user-2'], + screenOn: false, + threadId: 'thread-1', + }, + }, + ); + + testAction.data = {channelId: 'invalid-channel', userId: 'user-1'}; + + state = callsReducer(initialState, testAction); + assert.deepEqual(state.calls, {'channel-1': call1}); + }); + + it('RECEIVED_CALL_STARTED', async () => { + const initialState = {calls: {}}; + const testAction = { + type: CallsTypes.RECEIVED_CALL_STARTED, + data: call1, + }; + const state = callsReducer(initialState, testAction); + assert.deepEqual(state.calls, {'channel-1': call1}); + }); + + it('RECEIVED_CALL_FINISHED', async () => { + const initialState = {calls: {'channel-1': call1, 'channel-2': call2}}; + const testAction = { + type: CallsTypes.RECEIVED_CALL_FINISHED, + data: call1, + }; + const state = callsReducer(initialState, testAction); + assert.deepEqual(state.calls, {'channel-2': call2}); + }); + + it('RECEIVED_MUTE_USER_CALL', async () => { + const initialState = {calls: {'channel-1': call1, 'channel-2': call2}}; + const testAction = { + type: CallsTypes.RECEIVED_MUTE_USER_CALL, + data: {channelId: 'channel-1', userId: 'user-1'}, + }; + let state = callsReducer(initialState, testAction); + assert.equal(state.calls['channel-1'].participants['user-1'].muted, true); + + testAction.data = {channelId: 'channel-1', userId: 'invalidUser'}; + state = callsReducer(initialState, testAction); + assert.deepEqual(state.calls, initialState.calls); + + testAction.data = {channelId: 'invalid-channel', userId: 'user-1'}; + state = callsReducer(initialState, testAction); + assert.deepEqual(state.calls, initialState.calls); + }); + + it('RECEIVED_UNMUTE_USER_CALL', async () => { + const initialState = {calls: {'channel-1': call1, 'channel-2': call2}}; + const testAction = { + type: CallsTypes.RECEIVED_UNMUTE_USER_CALL, + data: {channelId: 'channel-1', userId: 'user-2'}, + }; + let state = callsReducer(initialState, testAction); + assert.equal(state.calls['channel-1'].participants['user-2'].muted, false); + + 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_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 = { + type: CallsTypes.RECEIVED_CHANNEL_CALL_SCREEN_ON, + data: {channelId: 'channel-1', userId: 'user-1'}, + }; + let state = callsReducer(initialState, testAction); + assert.equal(state.calls['channel-1'].screenOn, 'user-1'); + + testAction.data = {channelId: 'channel-1', userId: 'invalidUser'}; + state = callsReducer(initialState, testAction); + assert.deepEqual(state.calls, initialState.calls); + + testAction.data = {channelId: 'invalid-channel', userId: 'user-1'}; + state = callsReducer(initialState, testAction); + assert.deepEqual(state.calls, initialState.calls); + }); + it('RECEIVED_CHANNEL_CALL_SCREEN_OFF', async () => { + const initialState = {calls: {'channel-1': call1, 'channel-2': call2}}; + const testAction = { + type: CallsTypes.RECEIVED_CHANNEL_CALL_SCREEN_OFF, + data: {channelId: 'channel-1'}, + }; + let state = callsReducer(initialState, testAction); + assert.equal(state.calls['channel-1'].screenOn, ''); + + testAction.data = {channelId: 'invalid-channel'}; + state = callsReducer(initialState, testAction); + assert.deepEqual(state.calls, initialState.calls); + }); +}); + +describe('Reducers.calls.joined', () => { + it('RECEIVED_CALLS', async () => { + const initialState = {joined: 'test'}; + const testAction = { + type: CallsTypes.RECEIVED_CALLS, + data: {calls: {'channel-1': {}, 'channel-2': {}}, enabled: {'channel-1': true}}, + }; + const state = callsReducer(initialState, testAction); + assert.equal(state.joined, ''); + }); + + it('RECEIVED_MYSELF_JOINED_CALL', async () => { + const initialState = {joined: ''}; + const testAction = { + type: CallsTypes.RECEIVED_MYSELF_JOINED_CALL, + data: 'channel-id', + }; + const state = callsReducer(initialState, testAction); + assert.equal(state.joined, 'channel-id'); + }); + + it('RECEIVED_MYSELF_LEFT_CALL', async () => { + const initialState = {joined: 'test'}; + const testAction = { + type: CallsTypes.RECEIVED_MYSELF_LEFT_CALL, + data: null, + }; + const state = callsReducer(initialState, testAction); + assert.equal(state.joined, ''); + }); +}); + +describe('Reducers.calls.enabled', () => { + it('RECEIVED_CALLS', async () => { + const initialState = {enabled: {}}; + const testAction = { + type: CallsTypes.RECEIVED_CALLS, + data: {calls: {'channel-1': {}, 'channel-2': {}}, enabled: {'channel-1': true}}, + }; + const state = callsReducer(initialState, testAction); + assert.deepEqual(state.enabled, {'channel-1': true}); + }); + + it('RECEIVED_CHANNEL_CALL_ENABLED', async () => { + const initialState = {enabled: {'channel-1': true, 'channel-2': false}}; + const testAction = { + type: CallsTypes.RECEIVED_CHANNEL_CALL_ENABLED, + data: 'channel-3', + }; + let state = callsReducer(initialState, testAction); + assert.deepEqual(state.enabled, {'channel-1': true, 'channel-2': false, 'channel-3': true}); + + testAction.data = 'channel-2'; + state = callsReducer(initialState, testAction); + assert.deepEqual(state.enabled, {'channel-1': true, 'channel-2': true}); + + testAction.data = 'channel-1'; + state = callsReducer(initialState, testAction); + assert.deepEqual(state.enabled, {'channel-1': true, 'channel-2': false}); + }); + + it('RECEIVED_CHANNEL_CALL_DISABLED', async () => { + const initialState = {enabled: {'channel-1': true, 'channel-2': false}}; + const testAction = { + type: CallsTypes.RECEIVED_CHANNEL_CALL_DISABLED, + data: 'channel-3', + }; + let state = callsReducer(initialState, testAction); + assert.deepEqual(state.enabled, {'channel-1': true, 'channel-2': false, 'channel-3': false}); + + testAction.data = 'channel-2'; + state = callsReducer(initialState, testAction); + assert.deepEqual(state.enabled, {'channel-1': true, 'channel-2': false}); + + testAction.data = 'channel-1'; + state = callsReducer(initialState, testAction); + assert.deepEqual(state.enabled, {'channel-1': false, 'channel-2': false}); + }); +}); + +describe('Reducers.calls.screenShareURL', () => { + it('RECEIVED_MYSELF_JOINED_CALL', async () => { + const initialState = {screenShareURL: 'test'}; + const testAction = { + type: CallsTypes.RECEIVED_MYSELF_JOINED_CALL, + data: 'channel-id', + }; + const state = callsReducer(initialState, testAction); + assert.deepEqual(state.screenShareURL, ''); + }); + + it('RECEIVED_MYSELF_LEFT_CALL', async () => { + const initialState = {screenShareURL: 'test'}; + const testAction = { + type: CallsTypes.RECEIVED_MYSELF_LEFT_CALL, + data: 'channel-id', + }; + const state = callsReducer(initialState, testAction); + assert.deepEqual(state.screenShareURL, ''); + }); + + it('SET_SCREENSHARE_URL', async () => { + const initialState = {screenShareURL: 'test'}; + const testAction = { + type: CallsTypes.SET_SCREENSHARE_URL, + data: 'new-url', + }; + const state = callsReducer(initialState, testAction); + assert.deepEqual(state.screenShareURL, 'new-url'); + }); +}); diff --git a/app/products/calls/store/reducers/calls.ts b/app/products/calls/store/reducers/calls.ts new file mode 100644 index 000000000..e568f016e --- /dev/null +++ b/app/products/calls/store/reducers/calls.ts @@ -0,0 +1,210 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import {combineReducers} from 'redux'; + +import {GenericAction} from '@mm-redux/types/actions'; +import {Dictionary} from '@mm-redux/types/utilities'; +import CallsTypes from '@mmproducts/calls/store/action_types/calls'; +import {Call} from '@mmproducts/calls/store/types/calls'; + +function calls(state: Dictionary = {}, action: GenericAction) { + switch (action.type) { + case CallsTypes.RECEIVED_CALLS: { + return action.data.calls; + } + case CallsTypes.RECEIVED_LEFT_CALL: { + const {channelId, userId} = action.data; + if (!state[channelId]) { + return state; + } + if (!state[channelId].participants[userId]) { + return state; + } + const channelUpdate = {...state[channelId], participants: {...state[channelId].participants}}; + delete channelUpdate.participants[userId]; + const nextState = {...state}; + if (Object.keys(channelUpdate.participants).length === 0) { + delete nextState[channelId]; + } else { + nextState[channelId] = channelUpdate; + } + return nextState; + } + case CallsTypes.RECEIVED_JOINED_CALL: { + const {channelId, userId} = action.data; + if (!state[channelId]) { + return state; + } + const channelUpdate = {...state[channelId], participants: {...state[channelId].participants}}; + channelUpdate.participants[userId] = { + id: userId, + muted: true, + isTalking: false, + }; + const nextState = {...state}; + nextState[channelId] = channelUpdate; + return nextState; + } + case CallsTypes.RECEIVED_CALL_STARTED: { + const newCall = action.data; + const nextState = {...state}; + nextState[newCall.channelId] = newCall; + return nextState; + } + case CallsTypes.RECEIVED_CALL_FINISHED: { + const newCall = action.data; + const nextState = {...state}; + delete nextState[newCall.channelId]; + return nextState; + } + case CallsTypes.RECEIVED_MUTE_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], muted: true}; + const channelUpdate = {...state[channelId], participants: {...state[channelId].participants}}; + channelUpdate.participants[userId] = userUpdate; + const nextState = {...state}; + nextState[channelId] = channelUpdate; + return nextState; + } + case CallsTypes.RECEIVED_UNMUTE_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], muted: false}; + const channelUpdate = {...state[channelId], participants: {...state[channelId].participants}}; + channelUpdate.participants[userId] = userUpdate; + const nextState = {...state}; + 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]) { + return state; + } + if (!state[channelId].participants[userId]) { + return state; + } + const channelUpdate = {...state[channelId], screenOn: userId}; + const nextState = {...state}; + nextState[channelId] = channelUpdate; + return nextState; + } + case CallsTypes.RECEIVED_CHANNEL_CALL_SCREEN_OFF: { + const {channelId} = action.data; + if (!state[channelId]) { + return state; + } + const channelUpdate = {...state[channelId], screenOn: ''}; + const nextState = {...state}; + nextState[channelId] = channelUpdate; + return nextState; + } + default: + return state; + } +} + +function joined(state = '', action: GenericAction) { + switch (action.type) { + case CallsTypes.RECEIVED_MYSELF_JOINED_CALL: { + return action.data; + } + case CallsTypes.RECEIVED_CALLS: { + return ''; + } + case CallsTypes.RECEIVED_MYSELF_LEFT_CALL: { + return ''; + } + default: + return state; + } +} + +function enabled(state: Dictionary = {}, action: GenericAction) { + switch (action.type) { + case CallsTypes.RECEIVED_CALLS: { + return action.data.enabled; + } + case CallsTypes.RECEIVED_CHANNEL_CALL_ENABLED: { + const nextState = {...state}; + nextState[action.data] = true; + return nextState; + } + case CallsTypes.RECEIVED_CHANNEL_CALL_DISABLED: { + const nextState = {...state}; + nextState[action.data] = false; + return nextState; + } + default: + return state; + } +} + +function screenShareURL(state = '', action: GenericAction) { + switch (action.type) { + case CallsTypes.RECEIVED_MYSELF_JOINED_CALL: { + return ''; + } + case CallsTypes.RECEIVED_MYSELF_LEFT_CALL: { + return ''; + } + case CallsTypes.SET_SCREENSHARE_URL: { + return action.data; + } + default: + return state; + } +} + +export default combineReducers({ + calls, + enabled, + joined, + screenShareURL, +}); diff --git a/app/products/calls/store/selectors/calls.test.js b/app/products/calls/store/selectors/calls.test.js new file mode 100644 index 000000000..dcfa3030d --- /dev/null +++ b/app/products/calls/store/selectors/calls.test.js @@ -0,0 +1,80 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import assert from 'assert'; + +import deepFreezeAndThrowOnMutation from '@mm-redux/utils/deep_freeze'; + +import * as Selectors from './calls'; + +describe('Selectors.Calls', () => { + const call1 = {id: 'call1'}; + const call2 = {id: 'call2'}; + const testState = deepFreezeAndThrowOnMutation({ + entities: { + channels: { + currentChannelId: 'channel-1', + }, + calls: { + calls: {call1, call2}, + joined: 'call1', + enabled: {'channel-1': true, 'channel-2': false}, + screenShareURL: 'screenshare-url', + }, + }, + }); + + it('getCalls', () => { + assert.deepEqual(Selectors.getCalls(testState), {call1, call2}); + }); + + it('getCurrentCall', () => { + assert.deepEqual(Selectors.getCurrentCall(testState), call1); + let newState = { + ...testState, + entities: { + ...testState.entities, + calls: { + ...testState.entities.calls, + joined: null, + }, + }, + }; + assert.equal(Selectors.getCurrentCall(newState), null); + newState = { + ...testState, + entities: { + ...testState.entities, + calls: { + ...testState.entities.calls, + joined: 'invalid-id', + }, + }, + }; + assert.equal(Selectors.getCurrentCall(newState), null); + }); + + it('isCallsEnabled', () => { + assert.equal(Selectors.isCallsEnabled(testState), true); + let newState = { + ...testState, + entities: { + ...testState.entities, + channels: {currentChannelId: 'channel-2'}, + }, + }; + assert.equal(Selectors.isCallsEnabled(newState), false); + newState = { + ...testState, + entities: { + ...testState.entities, + channels: {currentChannelId: 'not-valid-channel'}, + }, + }; + assert.equal(Selectors.isCallsEnabled(newState), false); + }); + + it('getScreenShareURL', () => { + assert.equal(Selectors.getScreenShareURL(testState), 'screenshare-url'); + }); +}); diff --git a/app/products/calls/store/selectors/calls.ts b/app/products/calls/store/selectors/calls.ts new file mode 100644 index 000000000..f58505511 --- /dev/null +++ b/app/products/calls/store/selectors/calls.ts @@ -0,0 +1,25 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {getCurrentChannelId} from '@mm-redux/selectors/entities/common'; +import {GlobalState} from '@mm-redux/types/store'; + +export function getCalls(state: GlobalState) { + return state.entities.calls.calls; +} + +export function getCurrentCall(state: GlobalState) { + const currentCall = state.entities.calls.joined; + if (!currentCall) { + return null; + } + return state.entities.calls.calls[currentCall]; +} + +export function isCallsEnabled(state: GlobalState) { + return Boolean(state.entities.calls.enabled[getCurrentChannelId(state)]); +} + +export function getScreenShareURL(state: GlobalState) { + return state.entities.calls.screenShareURL; +} diff --git a/app/products/calls/store/types/calls.ts b/app/products/calls/store/types/calls.ts new file mode 100644 index 000000000..a81210a93 --- /dev/null +++ b/app/products/calls/store/types/calls.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Dictionary} from '@mm-redux/types/utilities'; + +export type CallsState = { + calls: Dictionary; + enabled: Dictionary; + joined: string; + screenShareURL: string; +} + +export type Call = { + participants: Dictionary; + channelId: string; + startTime: number; + speakers: string[]; + screenOn: string; + threadId: string; +} + +export type CallParticipant = { + id: string; + muted: boolean; + isTalking: boolean; +} + +export type ServerChannelState = { + channel_id: string; + enabled: boolean; + call: ServerCallState; +} + +export type ServerUserState = { + unmuted: boolean; + raised_hand: number; +} + +export type ServerCallState = { + id: string; + start_at: number; + users: string[]; + states: ServerUserState[]; + thread_id: string; + screen_sharing_id: string; +} diff --git a/app/screens/channel/channel.android.js b/app/screens/channel/channel.android.js index a7d9b38d8..8bf28789c 100644 --- a/app/screens/channel/channel.android.js +++ b/app/screens/channel/channel.android.js @@ -14,6 +14,9 @@ import PostDraft from '@components/post_draft'; import {NavigationTypes} from '@constants'; import {CHANNEL_POST_TEXTBOX_CURSOR_CHANGE, CHANNEL_POST_TEXTBOX_VALUE_CHANGE} from '@constants/post_draft'; import EventEmitter from '@mm-redux/utils/event_emitter'; +import CurrentCall from '@mmproducts/calls/components/current_call'; +import FloatingCallContainer from '@mmproducts/calls/components/floating_call_container'; +import JoinCall from '@mmproducts/calls/components/join_call'; import EphemeralStore from '@store/ephemeral_store'; import ChannelBase from './channel_base'; @@ -61,7 +64,7 @@ export default class ChannelAndroid extends ChannelBase { } render() { - const {theme, viewingGlobalThreads} = this.props; + const {theme, viewingGlobalThreads, callsFeatureEnabled} = this.props; let component; if (viewingGlobalThreads) { @@ -102,6 +105,11 @@ export default class ChannelAndroid extends ChannelBase { {component} + {callsFeatureEnabled && + + + + } ); diff --git a/app/screens/channel/channel.ios.js b/app/screens/channel/channel.ios.js index e36d33f2b..09a72354d 100644 --- a/app/screens/channel/channel.ios.js +++ b/app/screens/channel/channel.ios.js @@ -16,6 +16,9 @@ import SettingsSidebar from '@components/sidebars/settings'; import StatusBar from '@components/status_bar'; import DEVICE from '@constants/device'; import {ACCESSORIES_CONTAINER_NATIVE_ID, CHANNEL_POST_TEXTBOX_CURSOR_CHANGE, CHANNEL_POST_TEXTBOX_VALUE_CHANGE} from '@constants/post_draft'; +import CurrentCall from '@mmproducts/calls/components/current_call'; +import FloatingCallContainer from '@mmproducts/calls/components/floating_call_container'; +import JoinCall from '@mmproducts/calls/components/join_call'; import {makeStyleSheetFromTheme} from '@utils/theme'; import ChannelBase from './channel_base'; @@ -54,7 +57,7 @@ export default class ChannelIOS extends ChannelBase { }; render() { - const {currentChannelId, theme, viewingGlobalThreads} = this.props; + const {currentChannelId, theme, viewingGlobalThreads, callsFeatureEnabled} = this.props; let component; let renderDraftArea = false; @@ -82,6 +85,11 @@ export default class ChannelIOS extends ChannelBase { <> + {callsFeatureEnabled && + + + + } ); const header = ( diff --git a/app/screens/channel/channel_base.js b/app/screens/channel/channel_base.js index e29adb542..141f8c064 100644 --- a/app/screens/channel/channel_base.js +++ b/app/screens/channel/channel_base.js @@ -24,6 +24,7 @@ export default class ChannelBase extends PureComponent { loadChannelsForTeam: PropTypes.func.isRequired, selectDefaultTeam: PropTypes.func.isRequired, selectInitialChannel: PropTypes.func.isRequired, + loadCalls: PropTypes.func.isRequired, }).isRequired, componentId: PropTypes.string.isRequired, currentChannelId: PropTypes.string, @@ -38,6 +39,7 @@ export default class ChannelBase extends PureComponent { skipMetrics: PropTypes.bool, viewingGlobalThreads: PropTypes.bool, collapsedThreadsEnabled: PropTypes.bool.isRequired, + callsFeatureEnabled: PropTypes.bool.isRequired, selectedPost: PropTypes.shape({ id: PropTypes.string.isRequired, channel_id: PropTypes.string.isRequired, @@ -102,6 +104,10 @@ export default class ChannelBase extends PureComponent { // Only display the Alert if the TOS does not need to show first unsupportedServer(isSystemAdmin, this.context.intl.formatMessage); } + + if (this.props.callsFeatureEnabled) { + this.props.actions.loadCalls(); + } } componentDidUpdate(prevProps) { diff --git a/app/screens/channel/channel_base.test.js b/app/screens/channel/channel_base.test.js index 31a4b58a9..fb84f31d6 100644 --- a/app/screens/channel/channel_base.test.js +++ b/app/screens/channel/channel_base.test.js @@ -23,10 +23,12 @@ describe('ChannelBase', () => { markChannelViewedAndRead: jest.fn(), selectDefaultTeam: jest.fn(), selectInitialChannel: jest.fn(), + loadCalls: jest.fn(), }, componentId: channelBaseComponentId, theme: Preferences.THEMES.denim, collapsedThreadsEnabled: false, + callsFeatureEnabled: false, }; const optionsForTheme = (theme) => { return { diff --git a/app/screens/channel/index.js b/app/screens/channel/index.js index 65a4ab8c6..c8a2a841c 100644 --- a/app/screens/channel/index.js +++ b/app/screens/channel/index.js @@ -10,13 +10,14 @@ import {Client4} from '@client/rest'; import {ViewTypes} from '@constants'; import {getChannelStats} from '@mm-redux/actions/channels'; import {getCurrentChannelId} from '@mm-redux/selectors/entities/channels'; -import {getServerVersion} from '@mm-redux/selectors/entities/general'; +import {getServerVersion, getFeatureFlagValue} from '@mm-redux/selectors/entities/general'; import {getSelectedPost} from '@mm-redux/selectors/entities/posts'; import {getTheme, isCollapsedThreadsEnabled} from '@mm-redux/selectors/entities/preferences'; import {getCurrentTeam} from '@mm-redux/selectors/entities/teams'; import {getCurrentUserId, getCurrentUserRoles, shouldShowTermsOfService} from '@mm-redux/selectors/entities/users'; import {isMinimumServerVersion} from '@mm-redux/utils/helpers'; import {isSystemAdmin as checkIsSystemAdmin} from '@mm-redux/utils/user_utils'; +import {loadCalls} from '@mmproducts/calls/store/actions/calls'; import {getViewingGlobalThreads} from '@selectors/threads'; import Channel from './channel'; @@ -41,6 +42,7 @@ function mapStateToProps(state) { const currentTeamId = currentTeam?.delete_at === 0 ? currentTeam?.id : ''; const currentChannelId = currentTeam?.delete_at === 0 ? getCurrentChannelId(state) : ''; const collapsedThreadsEnabled = isCollapsedThreadsEnabled(state); + const callsFeatureEnabled = getFeatureFlagValue(state, 'CallsMobile') === 'true'; return { currentChannelId, @@ -54,6 +56,7 @@ function mapStateToProps(state) { teamName: currentTeam?.display_name, theme: getTheme(state), viewingGlobalThreads: collapsedThreadsEnabled && getViewingGlobalThreads(state), + callsFeatureEnabled, }; } @@ -64,6 +67,7 @@ function mapDispatchToProps(dispatch) { loadChannelsForTeam, selectDefaultTeam, selectInitialChannel, + loadCalls, }, dispatch), }; } diff --git a/app/screens/channel_info/__snapshots__/channel_info.test.js.snap b/app/screens/channel_info/__snapshots__/channel_info.test.js.snap index 015564c33..22cf91134 100644 --- a/app/screens/channel_info/__snapshots__/channel_info.test.js.snap +++ b/app/screens/channel_info/__snapshots__/channel_info.test.js.snap @@ -614,6 +614,2067 @@ exports[`channelInfo should match snapshot 1`] = ` `; +exports[`channelInfo should match snapshot on calls features enabled and calls disabled for channel 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`channelInfo should match snapshot on calls features enabled and calls enabled for channel 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`channelInfo should match snapshot on calls features enabled, user is not admin and calls disabled for channel 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + exports[`channelInfo should not include NotificationPreference for direct message 1`] = ` { + this.props.actions.joinCall(channelId); + this.close(); + } + + toggleCalls = () => { + if (this.props.isCallsEnabled) { + this.props.actions.disableChannelCalls(this.props.currentChannel.id); + } else { + this.props.actions.enableChannelCalls(this.props.currentChannel.id); + } + } + permalinkBadTeam = () => { const {intl} = this.context; const message = { @@ -95,7 +116,7 @@ export default class ChannelInfo extends PureComponent { }; actionsRows = (channelIsArchived) => { - const {currentChannel, currentUserId, isDirectMessage, theme} = this.props; + const {currentChannel, currentUserId, isDirectMessage, theme, isCallsEnabled, callsFeatureEnabled, isChannelAdmin} = this.props; if (channelIsArchived) { return ( @@ -156,6 +177,24 @@ export default class ChannelInfo extends PureComponent { testID='channel_info.edit_channel.action' theme={theme} /> + {callsFeatureEnabled && + <> + + + } diff --git a/app/screens/channel_info/channel_info.test.js b/app/screens/channel_info/channel_info.test.js index dffa6a2ce..9610affb8 100644 --- a/app/screens/channel_info/channel_info.test.js +++ b/app/screens/channel_info/channel_info.test.js @@ -57,7 +57,13 @@ describe('channelInfo', () => { getCustomEmojisInText: jest.fn(), showPermalink: jest.fn(), setChannelDisplayName: jest.fn(), + joinCall: jest.fn(), + enableChannelCalls: jest.fn(), + disableChannelCalls: jest.fn(), }, + isCallsEnabled: false, + callsFeatureEnabled: false, + isChannelAdmin: false, }; test('should match snapshot', async () => { @@ -70,6 +76,39 @@ describe('channelInfo', () => { expect(wrapper.getElement()).toMatchSnapshot(); }); + test('should match snapshot on calls features enabled, user is not admin and calls disabled for channel', async () => { + const props = {...baseProps, isCallsEnabled: false, callsFeatureEnabled: true, isChannelAdmin: false}; + const wrapper = shallow( + , + {context: {intl: intlMock}}, + ); + expect(wrapper.getElement()).toMatchSnapshot(); + }); + + test('should match snapshot on calls features enabled and calls disabled for channel', async () => { + const props = {...baseProps, isCallsEnabled: false, callsFeatureEnabled: true, isChannelAdmin: true}; + const wrapper = shallow( + , + {context: {intl: intlMock}}, + ); + expect(wrapper.getElement()).toMatchSnapshot(); + }); + + test('should match snapshot on calls features enabled and calls enabled for channel', async () => { + const props = {...baseProps, isCallsEnabled: true, callsFeatureEnabled: true, isChannelAdmin: true}; + const wrapper = shallow( + , + {context: {intl: intlMock}}, + ); + expect(wrapper.getElement()).toMatchSnapshot(); + }); + test('should dismiss modal on close', () => { const dismissModal = jest.spyOn(NavigationActions, 'dismissModal'); const wrapper = shallow( diff --git a/app/screens/channel_info/index.js b/app/screens/channel_info/index.js index 854164f5d..9a1c04124 100644 --- a/app/screens/channel_info/index.js +++ b/app/screens/channel_info/index.js @@ -9,10 +9,13 @@ import {getChannelStats} from '@mm-redux/actions/channels'; import {getCustomEmojisInText} from '@mm-redux/actions/emojis'; import {General} from '@mm-redux/constants'; import {getCurrentChannel, getCurrentChannelStats} from '@mm-redux/selectors/entities/channels'; +import {getFeatureFlagValue} from '@mm-redux/selectors/entities/general'; import {getTeammateNameDisplaySetting, getTheme} from '@mm-redux/selectors/entities/preferences'; -import {getCurrentUserId, getUser} from '@mm-redux/selectors/entities/users'; +import {getCurrentUserRoles, getCurrentUserId, getUser} from '@mm-redux/selectors/entities/users'; import {getUserIdFromChannelName} from '@mm-redux/utils/channel_utils'; -import {displayUsername} from '@mm-redux/utils/user_utils'; +import {isAdmin as checkIsAdmin, isChannelAdmin as checkIsChannelAdmin, displayUsername} from '@mm-redux/utils/user_utils'; +import {joinCall, enableChannelCalls, disableChannelCalls} from '@mmproducts/calls/store/actions/calls'; +import {isCallsEnabled} from '@mmproducts/calls/store/selectors/calls'; import {makeGetCustomStatus, isCustomStatusEnabled, isCustomStatusExpired, isCustomStatusExpirySupported} from '@selectors/custom_status'; import {isGuest} from '@utils/users'; @@ -36,6 +39,8 @@ function makeMapStateToProps() { let customStatus; let customStatusExpired = true; let customStatusExpirySupported = false; + const roles = getCurrentUserRoles(state) || ''; + const isChannelAdmin = checkIsAdmin(roles) || checkIsChannelAdmin(roles); const isDirectMessage = currentChannel.type === General.DM_CHANNEL; if (isDirectMessage) { @@ -55,6 +60,8 @@ function makeMapStateToProps() { currentChannelMemberCount = currentChannel.display_name.split(',').length; } + const callsFeatureEnabled = getFeatureFlagValue(state, 'CallsMobile') === 'true'; + return { currentChannel, currentChannelCreatorName, @@ -69,6 +76,9 @@ function makeMapStateToProps() { isCustomStatusEnabled: customStatusEnabled, isCustomStatusExpired: customStatusExpired, isCustomStatusExpirySupported: customStatusExpirySupported, + isCallsEnabled: isCallsEnabled(state), + callsFeatureEnabled, + isChannelAdmin, }; }; } @@ -79,6 +89,9 @@ function mapDispatchToProps(dispatch) { getChannelStats, getCustomEmojisInText, setChannelDisplayName, + joinCall, + enableChannelCalls, + disableChannelCalls, }, dispatch), }; } diff --git a/app/screens/index.js b/app/screens/index.js index 280b02cad..8a85b65a9 100644 --- a/app/screens/index.js +++ b/app/screens/index.js @@ -120,6 +120,9 @@ Navigation.setLazyComponentRegistrator((screenName) => { case 'MainSidebar': screen = require('app/components/sidebars/main').default; break; + case 'Call': + screen = require('@mmproducts/calls/screens/call').default; + break; case 'MFA': screen = require('@screens/mfa').default; break; @@ -169,6 +172,9 @@ Navigation.setLazyComponentRegistrator((screenName) => { case 'PostOptions': screen = require('@screens/post_options').default; break; + case 'CallOtherActions': + screen = require('@mmproducts/calls/screens/call_other_actions').default; + break; case 'ReactionList': screen = require('@screens/reaction_list').default; break; diff --git a/app/screens/participants_list/participants_list.tsx b/app/screens/participants_list/participants_list.tsx index f749fb40d..05485a9bd 100644 --- a/app/screens/participants_list/participants_list.tsx +++ b/app/screens/participants_list/participants_list.tsx @@ -18,16 +18,24 @@ type Props = { currentUserId: $ID; userProfiles: UserProfile[]; teammateNameDisplay: string; + listTitle?: JSX.Element; theme: Theme; } -const ParticipantsList = ({currentUserId, teammateNameDisplay, theme, userProfiles}: Props) => { +const ParticipantsList = ({currentUserId, teammateNameDisplay, theme, userProfiles, listTitle}: Props) => { const close = () => { dismissModal(); }; const renderHeader = () => { const style = getStyleSheet(theme); + if (listTitle) { + return ( + + {listTitle} + + ); + } return ( = 6" - } - }, "node_modules/browserify-sign/node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -9730,14 +9757,26 @@ } }, "node_modules/buffer": { - "version": "4.9.2", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz", - "integrity": "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==", - "dev": true, + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], "dependencies": { - "base64-js": "^1.0.2", - "ieee754": "^1.1.4", - "isarray": "^1.0.0" + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" } }, "node_modules/buffer-from": { @@ -9751,12 +9790,6 @@ "integrity": "sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk=", "dev": true }, - "node_modules/buffer/node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", - "dev": true - }, "node_modules/builtin-status-codes": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz", @@ -10803,6 +10836,27 @@ "typedarray": "^0.0.6" } }, + "node_modules/concat-stream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "node_modules/concat-stream/node_modules/readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/connect": { "version": "3.7.0", "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", @@ -11955,6 +12009,27 @@ "stream-shift": "^1.0.0" } }, + "node_modules/duplexify/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "node_modules/duplexify/node_modules/readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/ecc-jsbn": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", @@ -12100,6 +12175,12 @@ "node": ">=6.9.0" } }, + "node_modules/enhanced-resolve/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, "node_modules/enhanced-resolve/node_modules/memory-fs": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.5.0.tgz", @@ -12113,6 +12194,21 @@ "node": ">=4.3.0 <5.0.0 || >=5.10" } }, + "node_modules/enhanced-resolve/node_modules/readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/enquirer": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz", @@ -14401,6 +14497,27 @@ "readable-stream": "^2.3.6" } }, + "node_modules/flush-write-stream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "node_modules/flush-write-stream/node_modules/readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/focus-lock": { "version": "0.9.2", "resolved": "https://registry.npmjs.org/focus-lock/-/focus-lock-0.9.2.tgz", @@ -14724,6 +14841,12 @@ "node": ">=0.10.0" } }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, "node_modules/fork-ts-checker-webpack-plugin/node_modules/isobject": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", @@ -14757,6 +14880,21 @@ "node": ">=0.10.0" } }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/fork-ts-checker-webpack-plugin/node_modules/readdirp": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-2.2.1.tgz", @@ -14853,6 +14991,27 @@ "readable-stream": "^2.0.0" } }, + "node_modules/from2/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "node_modules/from2/node_modules/readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/fromentries": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/fromentries/-/fromentries-1.3.2.tgz", @@ -14916,6 +15075,27 @@ "readable-stream": "1 || 2" } }, + "node_modules/fs-write-stream-atomic/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "node_modules/fs-write-stream-atomic/node_modules/readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -15636,20 +15816,6 @@ "node": ">=4" } }, - "node_modules/hash-base/node_modules/readable-stream": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", - "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", - "dev": true, - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/hash-base/node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -16036,7 +16202,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, "funding": [ { "type": "github", @@ -20992,6 +21157,27 @@ "readable-stream": "^2.0.1" } }, + "node_modules/memory-fs/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "node_modules/memory-fs/node_modules/readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/merge-descriptors": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", @@ -23047,18 +23233,50 @@ "vm-browserify": "^1.0.1" } }, + "node_modules/node-libs-browser/node_modules/buffer": { + "version": "4.9.2", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz", + "integrity": "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==", + "dev": true, + "dependencies": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4", + "isarray": "^1.0.0" + } + }, "node_modules/node-libs-browser/node_modules/inherits": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", "dev": true }, + "node_modules/node-libs-browser/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, "node_modules/node-libs-browser/node_modules/punycode": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=", "dev": true }, + "node_modules/node-libs-browser/node_modules/readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/node-libs-browser/node_modules/util": { "version": "0.11.1", "resolved": "https://registry.npmjs.org/util/-/util-0.11.1.tgz", @@ -24106,6 +24324,27 @@ "readable-stream": "^2.1.5" } }, + "node_modules/parallel-transform/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "node_modules/parallel-transform/node_modules/readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/param-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", @@ -26477,6 +26716,14 @@ "react-native": "*" } }, + "node_modules/react-native-incall-manager": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/react-native-incall-manager/-/react-native-incall-manager-3.3.0.tgz", + "integrity": "sha512-SvomgHUoKqVso/BGv02b4ndBqenlqYqW3pptZz5qHwteidKFNWI6ny+PPw5X55MdK0lyTbzBoiT3DwS06Qu0lg==", + "peerDependencies": { + "react-native": ">=0.40.0" + } + }, "node_modules/react-native-keyboard-aware-scrollview": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/react-native-keyboard-aware-scrollview/-/react-native-keyboard-aware-scrollview-2.1.0.tgz", @@ -26953,6 +27200,11 @@ "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-webview": { "version": "11.14.2", "resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-11.14.2.tgz", @@ -27509,24 +27761,18 @@ } }, "node_modules/readable-stream": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", - "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" } }, - "node_modules/readable-stream/node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" - }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -29867,6 +30113,27 @@ "readable-stream": "^2.0.2" } }, + "node_modules/stream-browserify/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "node_modules/stream-browserify/node_modules/readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/stream-buffers": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-2.2.0.tgz", @@ -29898,6 +30165,27 @@ "xtend": "^4.0.0" } }, + "node_modules/stream-http/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "node_modules/stream-http/node_modules/readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/stream-shift": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.1.tgz", @@ -30646,6 +30934,25 @@ "xtend": "~4.0.1" } }, + "node_modules/through2/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" + }, + "node_modules/through2/node_modules/readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/timers-browserify": { "version": "2.0.12", "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-2.0.12.tgz", @@ -31961,6 +32268,13 @@ "node": ">=0.10.0" } }, + "node_modules/watchpack-chokidar2/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true, + "optional": true + }, "node_modules/watchpack-chokidar2/node_modules/isobject": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", @@ -31996,6 +32310,22 @@ "node": ">=0.10.0" } }, + "node_modules/watchpack-chokidar2/node_modules/readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "optional": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/watchpack-chokidar2/node_modules/readdirp": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-2.2.1.tgz", @@ -35927,8 +36257,7 @@ "eslint-plugin-prettier": "3.1.2", "eslint-plugin-react": "^7.20.0", "eslint-plugin-react-hooks": "^4.0.7", - "eslint-plugin-react-native": "^3.10.0", - "prettier": "^2.0.2" + "eslint-plugin-react-native": "^3.10.0" }, "dependencies": { "eslint-plugin-jest": { @@ -38077,8 +38406,6 @@ "polished": "^3.3.1", "popper.js": "^1.14.7", "prop-types": "^15.7.2", - "react": "^16.8.3", - "react-dom": "^16.8.3", "react-focus-lock": "^2.1.0", "react-helmet-async": "^1.0.2", "react-popper-tooltip": "^2.8.3", @@ -38680,6 +39007,12 @@ "@types/react": "*" } }, + "@types/react-native-incall-manager": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@types/react-native-incall-manager/-/react-native-incall-manager-3.2.1.tgz", + "integrity": "sha512-M+Ffgw1A7nPZSxvEdK/enCdfHhS8/oQ1QDZ6w9+6IwR3RHE3EDywMuHy8BiDJ7oM3q6yU6uSWSvhsCI/hrxGlQ==", + "dev": true + }, "@types/react-native-share": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/@types/react-native-share/-/react-native-share-3.3.3.tgz", @@ -38748,6 +39081,16 @@ "@types/react": "*" } }, + "@types/readable-stream": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-2.3.11.tgz", + "integrity": "sha512-0z+/apYJwKFz/RHp6mOMxz/y7xOvWPYPevuCEyAY3gXsjtaac02E26RvxA+I96rfvmVH/dEMGXNvyJfViR1FSQ==", + "dev": true, + "requires": { + "@types/node": "*", + "safe-buffer": "*" + } + }, "@types/redux-mock-store": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@types/redux-mock-store/-/redux-mock-store-1.0.3.tgz", @@ -39499,6 +39842,27 @@ "requires": { "delegates": "^1.0.0", "readable-stream": "^2.0.6" + }, + "dependencies": { + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" + }, + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + } } }, "argparse": { @@ -40778,17 +41142,6 @@ "safe-buffer": "^5.2.0" }, "dependencies": { - "readable-stream": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", - "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", - "dev": true, - "requires": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - } - }, "safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -40843,22 +41196,12 @@ } }, "buffer": { - "version": "4.9.2", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz", - "integrity": "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==", - "dev": true, + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", "requires": { - "base64-js": "^1.0.2", - "ieee754": "^1.1.4", - "isarray": "^1.0.0" - }, - "dependencies": { - "isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", - "dev": true - } + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" } }, "buffer-from": { @@ -41708,6 +42051,29 @@ "inherits": "^2.0.3", "readable-stream": "^2.2.2", "typedarray": "^0.0.6" + }, + "dependencies": { + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + } } }, "connect": { @@ -42681,6 +43047,29 @@ "inherits": "^2.0.1", "readable-stream": "^2.0.0", "stream-shift": "^1.0.0" + }, + "dependencies": { + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + } } }, "ecc-jsbn": { @@ -42807,6 +43196,12 @@ "tapable": "^1.0.0" }, "dependencies": { + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, "memory-fs": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.5.0.tgz", @@ -42816,6 +43211,21 @@ "errno": "^0.1.3", "readable-stream": "^2.0.1" } + }, + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } } } }, @@ -44623,6 +45033,29 @@ "requires": { "inherits": "^2.0.3", "readable-stream": "^2.3.6" + }, + "dependencies": { + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + } } }, "focus-lock": { @@ -44887,6 +45320,12 @@ } } }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, "isobject": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", @@ -44914,6 +45353,21 @@ "to-regex": "^3.0.2" } }, + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "readdirp": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-2.2.1.tgz", @@ -44986,6 +45440,29 @@ "requires": { "inherits": "^2.0.1", "readable-stream": "^2.0.0" + }, + "dependencies": { + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + } } }, "fromentries": { @@ -45029,6 +45506,29 @@ "iferr": "^0.1.5", "imurmurhash": "^0.1.4", "readable-stream": "1 || 2" + }, + "dependencies": { + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + } } }, "fs.realpath": { @@ -45579,17 +46079,6 @@ "safe-buffer": "^5.2.0" }, "dependencies": { - "readable-stream": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", - "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", - "dev": true, - "requires": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - } - }, "safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -45882,8 +46371,7 @@ "ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" }, "iferr": { "version": "0.1.5", @@ -49710,6 +50198,29 @@ "requires": { "errno": "^0.1.3", "readable-stream": "^2.0.1" + }, + "dependencies": { + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + } } }, "merge-descriptors": { @@ -50456,7 +50967,6 @@ "resolved": "https://registry.npmjs.org/metro-react-native-babel-preset/-/metro-react-native-babel-preset-0.66.2.tgz", "integrity": "sha512-H/nLBAz0MgfDloSe1FjyH4EnbokHFdncyERvLPXDACY3ROVRCeUyFNo70ywRGXW2NMbrV4H7KUyU4zkfWhC2HQ==", "requires": { - "@babel/core": "^7.14.0", "@babel/plugin-proposal-class-properties": "^7.0.0", "@babel/plugin-proposal-export-default-from": "^7.0.0", "@babel/plugin-proposal-nullish-coalescing-operator": "^7.0.0", @@ -50503,7 +51013,6 @@ "resolved": "https://registry.npmjs.org/metro-react-native-babel-transformer/-/metro-react-native-babel-transformer-0.66.2.tgz", "integrity": "sha512-z1ab7ihIT0pJrwgi9q2IH+LcW/xUWMQ0hH+Mrk7wbKQB0RnJdXFoxphrfoVHBHMUu+TBPetUcEkKawkK1e7Cng==", "requires": { - "@babel/core": "^7.14.0", "babel-preset-fbjs": "^3.4.0", "hermes-parser": "0.4.7", "metro-babel-transformer": "0.66.2", @@ -51398,18 +51907,50 @@ "vm-browserify": "^1.0.1" }, "dependencies": { + "buffer": { + "version": "4.9.2", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz", + "integrity": "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==", + "dev": true, + "requires": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4", + "isarray": "^1.0.0" + } + }, "inherits": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", "dev": true }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, "punycode": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=", "dev": true }, + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "util": { "version": "0.11.1", "resolved": "https://registry.npmjs.org/util/-/util-0.11.1.tgz", @@ -52210,6 +52751,29 @@ "cyclist": "^1.0.1", "inherits": "^2.0.3", "readable-stream": "^2.1.5" + }, + "dependencies": { + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + } } }, "param-case": { @@ -54182,6 +54746,12 @@ "integrity": "sha512-e4frAekSJkOgEmL9UOLukGhjtPwHSD7qSf3Rmwk+850ofxKqZFfAIfWc9MO3UmCb6G7oB6PkckyTOGOXybrN5A==", "requires": {} }, + "react-native-incall-manager": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/react-native-incall-manager/-/react-native-incall-manager-3.3.0.tgz", + "integrity": "sha512-SvomgHUoKqVso/BGv02b4ndBqenlqYqW3pptZz5qHwteidKFNWI6ny+PPw5X55MdK0lyTbzBoiT3DwS06Qu0lg==", + "requires": {} + }, "react-native-keyboard-aware-scrollview": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/react-native-keyboard-aware-scrollview/-/react-native-keyboard-aware-scrollview-2.1.0.tgz", @@ -54563,6 +55133,11 @@ "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-webview": { "version": "11.14.2", "resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-11.14.2.tgz", @@ -54925,24 +55500,13 @@ } }, "readable-stream": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", - "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", "requires": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - }, - "dependencies": { - "isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" - } + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" } }, "readdirp": { @@ -56833,6 +57397,29 @@ "requires": { "inherits": "~2.0.1", "readable-stream": "^2.0.2" + }, + "dependencies": { + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + } } }, "stream-buffers": { @@ -56861,6 +57448,29 @@ "readable-stream": "^2.3.6", "to-arraybuffer": "^1.0.0", "xtend": "^4.0.0" + }, + "dependencies": { + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + } } }, "stream-shift": { @@ -57421,6 +58031,27 @@ "requires": { "readable-stream": "~2.3.6", "xtend": "~4.0.1" + }, + "dependencies": { + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" + }, + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + } } }, "timers-browserify": { @@ -58451,6 +59082,13 @@ } } }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true, + "optional": true + }, "isobject": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", @@ -58480,6 +59118,22 @@ "to-regex": "^3.0.2" } }, + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "optional": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "readdirp": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-2.2.1.tgz", diff --git a/package.json b/package.json index 7575bdd4b..ea1261b63 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "analytics-react-native": "1.2.0", "array.prototype.flat": "1.2.5", "base-64": "1.0.0", + "buffer": "6.0.3", "commonmark": "0.30.0", "commonmark-react-renderer": "4.3.5", "deep-equal": "2.0.5", @@ -53,6 +54,7 @@ "react-native-haptic-feedback": "1.13.0", "react-native-hw-keyboard-event": "0.0.4", "react-native-image-picker": "4.1.2", + "react-native-incall-manager": "3.3.0", "react-native-keyboard-aware-scrollview": "2.1.0", "react-native-keyboard-tracking-view": "5.7.0", "react-native-keychain": "8.0.0", @@ -76,9 +78,11 @@ "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-webview": "11.14.2", "react-native-youtube": "2.0.2", "react-redux": "7.2.6", + "readable-stream": "3.6.0", "redux": "4.1.2", "redux-action-buffer": "1.2.0", "redux-batched-actions": "0.5.0", @@ -114,10 +118,12 @@ "@types/moment-timezone": "0.5.30", "@types/react": "17.0.33", "@types/react-native": "0.66.1", + "@types/react-native-incall-manager": "3.2.1", "@types/react-native-share": "3.3.3", "@types/react-native-video": "5.0.10", "@types/react-redux": "7.1.20", "@types/react-test-renderer": "17.0.1", + "@types/readable-stream": "2.3.11", "@types/semver": "7.3.9", "@types/shallow-equals": "1.0.0", "@types/tinycolor2": "1.4.3", diff --git a/tsconfig.json b/tsconfig.json index b9ac0342d..d8e9a04cc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -44,6 +44,7 @@ "@init/*": ["app/init/*"], "@mattermost-managed": ["app/mattermost_managed/index"], "@mm-redux/*": ["app/mm-redux/*"], + "@mmproducts/*": ["app/products/*"], "@screens/*": ["app/screens/*"], "@selectors/*": ["app/selectors/*"], "@share/*": ["share_extension/*"], diff --git a/types/modules/react-native-webrtc2.d.ts b/types/modules/react-native-webrtc2.d.ts new file mode 100644 index 000000000..f5e71102d --- /dev/null +++ b/types/modules/react-native-webrtc2.d.ts @@ -0,0 +1,229 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +// +// Based on react-native-webrtc types from +// https://github.com/DefinitelyTyped/DefinitelyTyped +// +// Definitions by: Carlos Quiroga +// Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped +// TypeScript Version: 2.8 + +declare module 'react-native-webrtc2' { + export const RTCView: any; + export type RTCSignalingState = + | 'stable' + | 'have-local-offer' + | 'have-remote-offer' + | 'have-local-pranswer' + | 'have-remote-pranswer' + | 'closed'; + + export type RTCIceGatheringState = 'new' | 'gathering' | 'complete'; + + export type RTCIceConnectionState = + | 'new' + | 'checking' + | 'connected' + | 'completed' + | 'failed' + | 'disconnected' + | 'closed'; + + export type RTCPeerConnectionState = 'new' | 'connecting' | 'connected' | 'disconnected' | 'failed' | 'closed'; + + export class MediaStreamTrack { + private _enabled: boolean; + + enabled: boolean; + id: string; + kind: string; + label: string; + muted: boolean; + readonly: boolean; + readyState: MediaStreamTrackState; + remote: boolean; + onended: () => void | undefined; + onmute: () => void | undefined; + onunmute: () => void | undefined; + overconstrained: () => void | undefined; + + constructor(); + + stop(): void; + applyConstraints(): void; + clone(): void; + getCapabilities(): void; + getConstraints(): void; + getSettings(): void; + release(): void; + + private _switchCamera(): void; + } + + export class MediaStream { + id: string; + active: boolean; + onactive: () => void | undefined; + oninactive: () => void | undefined; + onaddtrack: () => void | undefined; + onremovetrack: () => void | undefined; + + _tracks: MediaStreamTrack[]; + private _reactTag: string; + + constructor(arg: any); + + addTrack(track: MediaStreamTrack): void; + removeTrack(track: MediaStreamTrack): void; + getTracks(): MediaStreamTrack[]; + getTrackById(trackId: string): MediaStreamTrack | undefined; + getAudioTracks(): MediaStreamTrack[]; + getVideoTracks(): MediaStreamTrack[]; + clone(): void; + toURL(): string; + release(): void; + } + + export class RTCDataChannel { + _peerConnectionId: number; + + binaryType: 'arraybuffer'; + bufferedAmount: number; + bufferedAmountLowThreshold: number; + id: number; + label: string; + maxPacketLifeTime: ?number; + maxRetransmits: ?number; + negotiated: boolean; + ordered: boolean; + protocol: string; + readyState: 'connecting' | 'open' | 'closing' | 'closed'; + + onopen: ?Function; + onmessage: ?Function; + onbufferedamountlow: ?Function; + onerror: ?Function; + onclose: ?Function; + + constructor(peerConnectionId: number, label: string, dataChannelDict: RTCDataChannelInit) + send(data: string | ArrayBuffer | ArrayBufferView): void + close(): void + _unregisterEvents(): void + _registerEvents(): void + } + + export class MessageEvent { + type: string; + data: string | ArrayBuffer | Blob; + origin: string; + constructor(type: any, eventInitDict: any) + } + + export interface EventOnCandidate { + candidate: RTCIceCandidateType; + } + + export interface EventOnAddStream { + stream: MediaStream; + target: RTCPeerConnection; + track?: MediaStreamTrack; + } + + export class RTCPeerConnection { + localDescription: RTCSessionDescriptionType; + remoteDescription: RTCSessionDescriptionType; + connectionState: RTCPeerConnectionState; + iceConnectionState: RTCIceConnectionState; + iceGatheringState: RTCIceGatheringState; + + signalingState: RTCSignalingState; + private privateiceGatheringState: RTCIceGatheringState; + private privateiceConnectionState: RTCIceConnectionState; + + onconnectionstatechange: (event: Event) => void | undefined; + onicecandidate: (event: EventOnCandidate) => void | undefined; + onicecandidateerror: (error: Error) => void | undefined; + oniceconnectionstatechange: (event: EventOnConnectionStateChange) => void | undefined; + onicegatheringstatechange: () => void | undefined; + onnegotiationneeded: () => void | undefined; + onsignalingstatechange: () => void | undefined; + + onaddstream: (event: EventOnAddStream) => void | undefined; + onremovestream: () => void | undefined; + + private _peerConnectionId: number; + private _localStreams: MediaStream[]; + _remoteStreams: MediaStream[]; + private _subscriptions: any[]; + + private _dataChannelIds: any; + + constructor(configuration: RTCPeerConnectionConfiguration); + + addStream(stream: MediaStream): void; + + addTransceiver(kind: 'audio'|'video'|MediaStreamTrack, init: any): void; + + removeStream(stream: MediaStream): void; + + removeTrack(sender: RtpSender): Promise + + createOffer(options?: RTCOfferOptions): Promise; + + createAnswer(options?: RTCAnswerOptions): Promise; + + setConfiguration(configuration: RTCPeerConnectionConfiguration): void; + + setLocalDescription(sessionDescription: RTCSessionDescriptionType): Promise; + + setRemoteDescription(sessionDescription: RTCSessionDescriptionType): Promise; + + addIceCandidate(candidate: RTCIceCandidateType): Promise; + + getStats(selector?: MediaStreamTrack | null): Promise; + + getLocalStreams(): MediaStream[]; + + getRemoteStreams(): MediaStream[]; + + close(): void; + + private _getTrack(streamReactTag: string, trackId: string): MediaStreamTrack; + + private _unregisterEvents(): void; + + private _registerEvents(): void; + + createDataChannel(label: string, dataChannelDict?: any): RTCDataChannel; + } + + export class RTCIceCandidateType { + candidate: string; + sdpMLineIndex: number; + sdpMid: string; + } + + export class RTCIceCandidate extends RTCIceCandidateType { + constructor(info: RTCIceCandidateType); + + toJSON(): RTCIceCandidateType; + } + + export class RTCSessionDescriptionType { + sdp: string; + type: string; + } + + export class RTCSessionDescription extends RTCSessionDescriptionType { + constructor(info: RTCSessionDescriptionType); + toJSON(): RTCSessionDescriptionType; + } + + export class mediaDevices { + ondevicechange: () => void | undefined; + + static enumerateDevices(): Promise; + + static getUserMedia(constraints: MediaStreamConstraints): Promise; + } +}