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;
+ }
+}