diff --git a/.eslintrc.json b/.eslintrc.json
index c89e2035d0..90d0f8dd2b 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -68,7 +68,7 @@
"newlines-between": "always",
"pathGroups": [
{
- "pattern": "{@(@actions|@app|@assets|@client|@components|@constants|@context|@database|@helpers|@hooks|@init|@managers|@queries|@screens|@selectors|@share|@store|@telemetry|@typings|@test|@utils)/**,@(@constants|@i18n|@notifications|@store|@websocket)}",
+ "pattern": "{@(@actions|@app|@assets|@calls|@client|@components|@constants|@context|@database|@helpers|@hooks|@init|@managers|@queries|@screens|@selectors|@share|@store|@telemetry|@typings|@test|@utils)/**,@(@constants|@i18n|@notifications|@store|@websocket)}",
"group": "external",
"position": "after"
},
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index b32be15f96..5ed62affbc 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -8,6 +8,7 @@
+
diff --git a/app/actions/websocket/index.ts b/app/actions/websocket/index.ts
index a256ccfaa2..2eeb072c68 100644
--- a/app/actions/websocket/index.ts
+++ b/app/actions/websocket/index.ts
@@ -6,12 +6,29 @@ import {DeviceEventEmitter} from 'react-native';
import {switchToChannelById} from '@actions/remote/channel';
import {deferredAppEntryActions, entry} from '@actions/remote/entry/common';
import {fetchStatusByIds} from '@actions/remote/user';
+import {loadConfigAndCalls} from '@calls/actions/calls';
+import {
+ handleCallChannelDisabled,
+ handleCallChannelEnabled, handleCallScreenOff, handleCallScreenOn, handleCallStarted,
+ handleCallUserConnected,
+ handleCallUserDisconnected,
+ handleCallUserMuted, handleCallUserRaiseHand,
+ handleCallUserUnmuted, handleCallUserUnraiseHand, handleCallUserVoiceOff, handleCallUserVoiceOn,
+} from '@calls/connection/websocket_event_handlers';
+import {isSupportedServerCalls} from '@calls/utils';
import {Events, Screens, WebsocketEvents} from '@constants';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import {getActiveServerUrl, queryActiveServer} from '@queries/app/servers';
import {getCurrentChannel} from '@queries/servers/channel';
-import {getCommonSystemValues, getConfig, getWebSocketLastDisconnected, resetWebSocketLastDisconnected, setCurrentTeamAndChannelId} from '@queries/servers/system';
+import {
+ getCommonSystemValues,
+ getConfig,
+ getCurrentUserId,
+ getWebSocketLastDisconnected,
+ resetWebSocketLastDisconnected,
+ setCurrentTeamAndChannelId,
+} from '@queries/servers/system';
import {getCurrentTeam} from '@queries/servers/team';
import {getCurrentUser} from '@queries/servers/user';
import {dismissAllModals, popToRoot} from '@screens/navigation';
@@ -60,6 +77,11 @@ export async function handleFirstConnect(serverUrl: string) {
alreadyConnected.add(serverUrl);
resetWebSocketLastDisconnected(operator);
fetchStatusByIds(serverUrl, ['me']);
+
+ if (isSupportedServerCalls(config?.Version)) {
+ const currentUserId = await getCurrentUserId(database);
+ loadConfigAndCalls(serverUrl, currentUserId);
+ }
}
export function handleReconnect(serverUrl: string) {
@@ -149,6 +171,10 @@ async function doReconnect(serverUrl: string) {
const {config, license} = await getCommonSystemValues(database);
await deferredAppEntryActions(serverUrl, lastDisconnectedAt, currentUserId, currentUserLocale, prefData.preferences, config, license, teamData, chData, initialTeamId, switchedToChannel ? initialChannelId : undefined);
+ if (isSupportedServerCalls(config?.Version)) {
+ loadConfigAndCalls(serverUrl, currentUserId);
+ }
+
// https://mattermost.atlassian.net/browse/MM-41520
}
@@ -310,6 +336,49 @@ export async function handleEvent(serverUrl: string, msg: WebSocketMessage) {
case WebsocketEvents.APPS_FRAMEWORK_REFRESH_BINDINGS:
break;
- // return dispatch(handleRefreshAppsBindings());
+ // return dispatch(handleRefreshAppsBindings());
+
+ // Calls ws events:
+ case WebsocketEvents.CALLS_CHANNEL_ENABLED:
+ handleCallChannelEnabled(serverUrl, msg);
+ break;
+ case WebsocketEvents.CALLS_CHANNEL_DISABLED:
+ handleCallChannelDisabled(serverUrl, msg);
+ break;
+ case WebsocketEvents.CALLS_USER_CONNECTED:
+ handleCallUserConnected(serverUrl, msg);
+ break;
+ case WebsocketEvents.CALLS_USER_DISCONNECTED:
+ handleCallUserDisconnected(serverUrl, msg);
+ break;
+ case WebsocketEvents.CALLS_USER_MUTED:
+ handleCallUserMuted(serverUrl, msg);
+ break;
+ case WebsocketEvents.CALLS_USER_UNMUTED:
+ handleCallUserUnmuted(serverUrl, msg);
+ break;
+ case WebsocketEvents.CALLS_USER_VOICE_ON:
+ handleCallUserVoiceOn(msg);
+ break;
+ case WebsocketEvents.CALLS_USER_VOICE_OFF:
+ handleCallUserVoiceOff(msg);
+ break;
+ case WebsocketEvents.CALLS_CALL_START:
+ handleCallStarted(serverUrl, msg);
+ break;
+ case WebsocketEvents.CALLS_SCREEN_ON:
+ handleCallScreenOn(serverUrl, msg);
+ break;
+ case WebsocketEvents.CALLS_SCREEN_OFF:
+ handleCallScreenOff(serverUrl, msg);
+ break;
+ case WebsocketEvents.CALLS_USER_RAISE_HAND:
+ handleCallUserRaiseHand(serverUrl, msg);
+ break;
+ case WebsocketEvents.CALLS_USER_UNRAISE_HAND:
+ handleCallUserUnraiseHand(serverUrl, msg);
+ break;
}
+
+ return {};
}
diff --git a/app/client/rest/base.ts b/app/client/rest/base.ts
index 2f769db583..cda97ec367 100644
--- a/app/client/rest/base.ts
+++ b/app/client/rest/base.ts
@@ -4,6 +4,7 @@
import {DeviceEventEmitter} from 'react-native';
import {Events} from '@constants';
+import Calls from '@constants/calls';
import {t} from '@i18n';
import {setServerCredentials} from '@init/credentials';
import {Analytics, create} from '@managers/analytics';
@@ -201,10 +202,18 @@ export default class ClientBase {
return `${this.getThreadsRoute(userId, teamId)}/${threadId}`;
}
+ getPluginsRoute() {
+ return `${this.urlVersion}/plugins`;
+ }
+
getAppsProxyRoute() {
return '/plugins/com.mattermost.apps';
}
+ getCallsRoute() {
+ return `/plugins/${Calls.PluginId}`;
+ }
+
doFetch = async (url: string, options: ClientOptions, returnDataOnly = true) => {
let request;
const method = options.method?.toLowerCase();
diff --git a/app/client/rest/index.ts b/app/client/rest/index.ts
index fc8e2dce68..9c4ada9014 100644
--- a/app/client/rest/index.ts
+++ b/app/client/rest/index.ts
@@ -1,6 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
+import ClientCalls, {ClientCallsMix} from '@calls/client/rest';
+import ClientPlugins, {ClientPluginsMix} from '@client/rest/plugins';
import mix from '@utils/mix';
import ClientApps, {ClientAppsMix} from './apps';
@@ -36,7 +38,9 @@ interface Client extends ClientBase,
ClientTeamsMix,
ClientThreadsMix,
ClientTosMix,
- ClientUsersMix
+ ClientUsersMix,
+ ClientCallsMix,
+ ClientPluginsMix
{}
class Client extends mix(ClientBase).with(
@@ -54,6 +58,8 @@ class Client extends mix(ClientBase).with(
ClientThreads,
ClientTos,
ClientUsers,
+ ClientCalls,
+ ClientPlugins,
) {
// eslint-disable-next-line no-useless-constructor
constructor(apiClient: APIClientInterface, serverUrl: string, bearerToken?: string, csrfToken?: string) {
diff --git a/app/client/rest/plugins.ts b/app/client/rest/plugins.ts
new file mode 100644
index 0000000000..7f876c8799
--- /dev/null
+++ b/app/client/rest/plugins.ts
@@ -0,0 +1,17 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+export interface ClientPluginsMix {
+ getPluginsManifests: () => Promise;
+}
+
+const ClientPlugins = (superclass: any) => class extends superclass {
+ getPluginsManifests = async () => {
+ return this.doFetch(
+ `${this.getPluginsRoute()}/webapp`,
+ {method: 'get'},
+ );
+ };
+};
+
+export default ClientPlugins;
diff --git a/app/components/channel_item/__snapshots__/channel_item.test.tsx.snap b/app/components/channel_item/__snapshots__/channel_item.test.tsx.snap
index 409c3438e1..81ad599183 100644
--- a/app/components/channel_item/__snapshots__/channel_item.test.tsx.snap
+++ b/app/components/channel_item/__snapshots__/channel_item.test.tsx.snap
@@ -113,6 +113,131 @@ exports[`components/channel_list/categories/body/channel_item should match snaps
`;
+exports[`components/channel_list/categories/body/channel_item should match snapshot when it has a call 1`] = `
+
+
+
+
+
+
+
+
+ Hello!
+
+
+
+
+
+
+`;
+
exports[`components/channel_list/categories/body/channel_item should match snapshot when it has a draft 1`] = `
{
isUnread={myChannel.isUnread}
mentionsCount={myChannel.mentionsCount}
hasMember={Boolean(myChannel)}
+ hasCall={false}
/>,
);
@@ -62,6 +63,28 @@ describe('components/channel_list/categories/body/channel_item', () => {
isUnread={myChannel.isUnread}
mentionsCount={myChannel.mentionsCount}
hasMember={Boolean(myChannel)}
+ hasCall={false}
+ />,
+ );
+
+ expect(wrapper.toJSON()).toMatchSnapshot();
+ });
+
+ it('should match snapshot when it has a call', () => {
+ const wrapper = renderWithIntlAndTheme(
+ undefined}
+ isUnread={myChannel.isUnread}
+ mentionsCount={myChannel.mentionsCount}
+ hasMember={Boolean(myChannel)}
+ hasCall={true}
/>,
);
diff --git a/app/components/channel_item/channel_item.tsx b/app/components/channel_item/channel_item.tsx
index 31ff43daec..1404ad9304 100644
--- a/app/components/channel_item/channel_item.tsx
+++ b/app/components/channel_item/channel_item.tsx
@@ -7,6 +7,7 @@ import {StyleSheet, Text, TouchableOpacity, View} from 'react-native';
import Badge from '@components/badge';
import ChannelIcon from '@components/channel_icon';
+import CompassIcon from '@components/compass_icon';
import {General} from '@constants';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
@@ -32,6 +33,7 @@ type Props = {
hasMember: boolean;
teamDisplayName?: string;
testID?: string;
+ hasCall: boolean;
}
export const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
@@ -113,6 +115,12 @@ export const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
paddingBottom: 0,
top: 5,
},
+ hasCall: {
+ color: theme.sidebarText,
+ flex: 1,
+ textAlign: 'right',
+ marginRight: 20,
+ },
}));
export const textStyle = StyleSheet.create({
@@ -123,7 +131,7 @@ export const textStyle = StyleSheet.create({
const ChannelListItem = ({
channel, currentUserId, hasDraft,
isActive, isInfo, isMuted, membersCount, hasMember,
- isUnread, mentionsCount, onPress, teamDisplayName, testID}: Props) => {
+ isUnread, mentionsCount, onPress, teamDisplayName, testID, hasCall}: Props) => {
const {formatMessage} = useIntl();
const theme = useTheme();
const isTablet = useIsTablet();
@@ -237,6 +245,13 @@ const ChannelListItem = ({
value={mentionsCount}
style={[styles.badge, isMuted && styles.mutedBadge, isInfo && styles.infoBadge]}
/>
+ {hasCall &&
+
+ }
>
diff --git a/app/components/channel_item/index.ts b/app/components/channel_item/index.ts
index 0904afb297..45021803d9 100644
--- a/app/components/channel_item/index.ts
+++ b/app/components/channel_item/index.ts
@@ -7,7 +7,9 @@ import React from 'react';
import {of as of$} from 'rxjs';
import {switchMap, distinctUntilChanged} from 'rxjs/operators';
+import {observeChannelsWithCalls} from '@calls/state';
import {General} from '@constants';
+import {withServerUrl} from '@context/server';
import {observeMyChannel} from '@queries/servers/channel';
import {queryDraft} from '@queries/servers/drafts';
import {observeCurrentChannelId, observeCurrentUserId} from '@queries/servers/system';
@@ -21,11 +23,12 @@ import type {WithDatabaseArgs} from '@typings/database/database';
type EnhanceProps = WithDatabaseArgs & {
channel: ChannelModel;
showTeamName?: boolean;
+ serverUrl?: string;
}
const observeIsMutedSetting = (mc: MyChannelModel) => mc.settings.observe().pipe(switchMap((s) => of$(s?.notifyProps?.mark_unread === General.MENTION)));
-const enhance = withObservables(['channel', 'showTeamName'], ({channel, database, showTeamName}: EnhanceProps) => {
+const enhance = withObservables(['channel', 'showTeamName'], ({channel, database, showTeamName, serverUrl}: EnhanceProps) => {
const currentUserId = observeCurrentUserId(database);
const myChannel = observeMyChannel(database, channel.id);
@@ -76,6 +79,9 @@ const enhance = withObservables(['channel', 'showTeamName'], ({channel, database
distinctUntilChanged(),
);
+ const hasCall = observeChannelsWithCalls(serverUrl || '').pipe(
+ switchMap((calls) => of$(Boolean(calls[channel.id]))));
+
return {
channel: channel.observe(),
currentUserId,
@@ -87,7 +93,8 @@ const enhance = withObservables(['channel', 'showTeamName'], ({channel, database
mentionsCount,
teamDisplayName,
hasMember,
+ hasCall,
};
});
-export default React.memo(withDatabase(enhance(ChannelItem)));
+export default React.memo(withDatabase(withServerUrl(enhance(ChannelItem))));
diff --git a/app/components/formatted_relative_time/index.tsx b/app/components/formatted_relative_time/index.tsx
new file mode 100644
index 0000000000..4c6573dd8e
--- /dev/null
+++ b/app/components/formatted_relative_time/index.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';
+
+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 function cleanup() {
+ return clearInterval(interval);
+ };
+ }
+ return function cleanup() {
+ return null;
+ };
+ }, [updateIntervalInSeconds]);
+
+ return (
+
+ {formattedTime}
+
+ );
+};
+
+export default FormattedRelativeTime;
diff --git a/app/components/post_list/post/post.tsx b/app/components/post_list/post/post.tsx
index 89e6ba11b9..7b908ca7f9 100644
--- a/app/components/post_list/post/post.tsx
+++ b/app/components/post_list/post/post.tsx
@@ -8,6 +8,8 @@ import {Keyboard, Platform, StyleProp, View, ViewStyle, TouchableHighlight} from
import {removePost} from '@actions/local/post';
import {showPermalink} from '@actions/remote/permalink';
import {fetchAndSwitchToThread} from '@actions/remote/thread';
+import CallsCustomMessage from '@calls/components/calls_custom_message';
+import {isCallsCustomMessage} from '@calls/utils';
import SystemAvatar from '@components/system_avatar';
import SystemHeader from '@components/system_header';
import {POST_TIME_TO_FAIL} from '@constants/post';
@@ -119,6 +121,8 @@ const Post = ({
const isPendingOrFailed = isPostPendingOrFailed(post);
const isFailed = isPostFailed(post);
const isSystemPost = isSystemMessage(post);
+ const isCallsPost = isCallsCustomMessage(post);
+ const hasBeenDeleted = (post.deleteAt !== 0);
const isWebHook = isFromWebhook(post);
const hasSameRoot = useMemo(() => {
if (isFirstReply) {
@@ -139,12 +143,12 @@ const Post = ({
}
const isValidSystemMessage = isAutoResponder || !isSystemPost;
- if (post.deleteAt === 0 && isValidSystemMessage && !isPendingOrFailed) {
+ if (isValidSystemMessage && !hasBeenDeleted && !isPendingOrFailed) {
if ([Screens.CHANNEL, Screens.PERMALINK].includes(location)) {
const postRootId = post.rootId || post.id;
fetchAndSwitchToThread(serverUrl, postRootId);
}
- } else if ((isEphemeral || post.deleteAt > 0)) {
+ } else if ((isEphemeral || hasBeenDeleted)) {
removePost(serverUrl, post);
}
@@ -168,7 +172,6 @@ const Post = ({
return;
}
- const hasBeenDeleted = (post.deleteAt !== 0);
if (isSystemPost && (!canDelete || hasBeenDeleted)) {
return;
}
@@ -271,6 +274,12 @@ const Post = ({
post={post}
/>
);
+ } else if (isCallsPost && !hasBeenDeleted) {
+ body = (
+
+ );
} else {
body = (
{
};
});
-const Image = ({author, forwardRef, iconSize, size, source}: Props) => {
+const Image = ({author, forwardRef, iconSize, size, source, url}: Props) => {
const theme = useTheme();
- const serverUrl = useServerUrl();
+ let serverUrl = useServerUrl();
+ serverUrl = url || serverUrl;
+
const style = getStyleSheet(theme);
const fIStyle = useMemo(() => ({
borderRadius: size / 2,
diff --git a/app/components/profile_picture/index.tsx b/app/components/profile_picture/index.tsx
index b0383c9acc..7babec4556 100644
--- a/app/components/profile_picture/index.tsx
+++ b/app/components/profile_picture/index.tsx
@@ -30,6 +30,7 @@ type ProfilePictureProps = {
statusStyle?: StyleProp;
testID?: string;
source?: Source | string;
+ url?: string;
};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
@@ -69,9 +70,11 @@ const ProfilePicture = ({
statusStyle,
testID,
source,
+ url,
}: ProfilePictureProps) => {
const theme = useTheme();
- const serverUrl = useServerUrl();
+ let serverUrl = useServerUrl();
+ serverUrl = url || serverUrl;
const style = getStyleSheet(theme);
const buffer = showStatus ? STATUS_BUFFER || 0 : 0;
@@ -111,6 +114,7 @@ const ProfilePicture = ({
iconSize={iconSize}
size={size}
source={source}
+ url={serverUrl}
/>
{showStatus && !isBot &&
= {
ADD_BOT_TEAMS_CHANNELS: 'add_bot_teams_channels',
SYSTEM_AUTO_RESPONDER: 'system_auto_responder',
+ CUSTOM_CALLS: 'custom_calls',
};
export const POST_TIME_TO_FAIL = 10000;
diff --git a/app/constants/screens.ts b/app/constants/screens.ts
index afc5ff1f99..422dc767bd 100644
--- a/app/constants/screens.ts
+++ b/app/constants/screens.ts
@@ -59,6 +59,7 @@ export const THREAD = 'Thread';
export const THREAD_FOLLOW_BUTTON = 'ThreadFollowButton';
export const THREAD_OPTIONS = 'ThreadOptions';
export const USER_PROFILE = 'UserProfile';
+export const CALL = 'Call';
export default {
ABOUT,
@@ -119,6 +120,7 @@ export default {
THREAD_FOLLOW_BUTTON,
THREAD_OPTIONS,
USER_PROFILE,
+ CALL,
};
export const MODAL_SCREENS_WITHOUT_BACK = new Set([
diff --git a/app/constants/view.ts b/app/constants/view.ts
index 5a792e87ab..bf4f9a83ac 100644
--- a/app/constants/view.ts
+++ b/app/constants/view.ts
@@ -23,6 +23,8 @@ export const HEADER_SEARCH_HEIGHT = SEARCH_INPUT_HEIGHT + 5;
export const HEADER_SEARCH_BOTTOM_MARGIN = 10;
export const INDICATOR_BAR_HEIGHT = 38;
+export const JOIN_CALL_BAR_HEIGHT = 38;
+export const CURRENT_CALL_BAR_HEIGHT = 74;
export const QUICK_OPTIONS_HEIGHT = 270;
diff --git a/app/constants/websocket.ts b/app/constants/websocket.ts
index 339c83f0a8..50b639078d 100644
--- a/app/constants/websocket.ts
+++ b/app/constants/websocket.ts
@@ -1,5 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
+
+import Calls from '@constants/calls';
+
const WebsocketEvents = {
POSTED: 'posted',
POST_EDITED: 'post_edited',
@@ -49,5 +52,19 @@ const WebsocketEvents = {
THREAD_FOLLOW_CHANGED: 'thread_follow_changed',
THREAD_READ_CHANGED: 'thread_read_changed',
APPS_FRAMEWORK_REFRESH_BINDINGS: 'custom_com.mattermost.apps_refresh_bindings',
+ CALLS_CHANNEL_ENABLED: `custom_${Calls.PluginId}_channel_enable_voice`,
+ CALLS_CHANNEL_DISABLED: `custom_${Calls.PluginId}_channel_disable_voice`,
+ CALLS_USER_CONNECTED: `custom_${Calls.PluginId}_user_connected`,
+ CALLS_USER_DISCONNECTED: `custom_${Calls.PluginId}_user_disconnected`,
+ CALLS_USER_MUTED: `custom_${Calls.PluginId}_user_muted`,
+ CALLS_USER_UNMUTED: `custom_${Calls.PluginId}_user_unmuted`,
+ CALLS_USER_VOICE_ON: `custom_${Calls.PluginId}_user_voice_on`,
+ CALLS_USER_VOICE_OFF: `custom_${Calls.PluginId}_user_voice_off`,
+ CALLS_CALL_START: `custom_${Calls.PluginId}_call_start`,
+ CALLS_CALL_END: `custom_${Calls.PluginId}_call_end`,
+ CALLS_SCREEN_ON: `custom_${Calls.PluginId}_user_screen_on`,
+ CALLS_SCREEN_OFF: `custom_${Calls.PluginId}_user_screen_off`,
+ CALLS_USER_RAISE_HAND: `custom_${Calls.PluginId}_user_raise_hand`,
+ CALLS_USER_UNRAISE_HAND: `custom_${Calls.PluginId}_user_unraise_hand`,
};
export default WebsocketEvents;
diff --git a/app/context/server/index.tsx b/app/context/server/index.tsx
index f44ae1d66a..ae4b47cc37 100644
--- a/app/context/server/index.tsx
+++ b/app/context/server/index.tsx
@@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
-import React, {ComponentType, createContext} from 'react';
+import React, {createContext} from 'react';
type Props = {
server: ServerContext;
@@ -12,6 +12,8 @@ type WithServerUrlProps = {
serverUrl: string;
}
+type GetProps = C extends React.ComponentType ? P : never
+
type ServerContext = {
displayName: string;
url: string;
@@ -26,8 +28,8 @@ function ServerUrlProvider({server, children}: Props) {
);
}
-export function withServerUrl(Component: ComponentType): ComponentType {
- return function ServerUrlComponent(props) {
+export function withServerUrl, P = GetProps>(Component: C) {
+ return function ServerUrlComponent(props: JSX.LibraryManagedAttributes) {
return (
{(server: ServerContext) => (
diff --git a/app/products/calls/actions/calls.test.ts b/app/products/calls/actions/calls.test.ts
new file mode 100644
index 0000000000..48c82dbd0c
--- /dev/null
+++ b/app/products/calls/actions/calls.test.ts
@@ -0,0 +1,287 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import assert from 'assert';
+
+import {act, renderHook} from '@testing-library/react-hooks';
+import InCallManager from 'react-native-incall-manager';
+
+import * as CallsActions from '@calls/actions';
+import {getConnectionForTesting} from '@calls/actions/calls';
+import * as Permissions from '@calls/actions/permissions';
+import * as State from '@calls/state';
+import {exportedForInternalUse as callsConfigTesting} from '@calls/state/calls_config';
+import {exportedForInternalUse as callsStateTesting} from '@calls/state/calls_state';
+import {exportedForInternalUse as channelsWithCallsTesting} from '@calls/state/channels_with_calls';
+import {exportedForInternalUse as currentCallTesting} from '@calls/state/current_call';
+import {
+ Call,
+ CallsState,
+ ChannelsWithCalls,
+ CurrentCall,
+ DefaultCallsConfig,
+ DefaultCallsState,
+} from '@calls/types/calls';
+import NetworkManager from '@managers/network_manager';
+import {getIntlShape} from '@test/intl-test-helper';
+
+const {setCallsConfig, useCallsConfig} = callsConfigTesting;
+const {setCallsState, useCallsState} = callsStateTesting;
+const {setChannelsWithCalls, useChannelsWithCalls} = channelsWithCallsTesting;
+const {useCurrentCall, setCurrentCall} = currentCallTesting;
+
+const mockClient = {
+ 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,
+ },
+ ]),
+ getCallsConfig: jest.fn(() => ({
+ ICEServers: ['mattermost.com'],
+ AllowEnableCalls: true,
+ DefaultEnabled: true,
+ last_retrieved_at: 1234,
+ })),
+ getPluginsManifests: jest.fn(() => (
+ [
+ {id: 'playbooks'},
+ {id: 'com.mattermost.calls'},
+ ]
+ )),
+ enableChannelCalls: jest.fn(),
+ disableChannelCalls: jest.fn(),
+};
+
+jest.mock('@calls/connection/connection', () => ({
+ newConnection: jest.fn(() => Promise.resolve({
+ disconnect: jest.fn(),
+ mute: jest.fn(),
+ unmute: jest.fn(),
+ waitForReady: jest.fn(() => Promise.resolve()),
+ })),
+}));
+
+const addFakeCall = (serverUrl: string, channelId: string) => {
+ const call = {
+ participants: {
+ xohi8cki9787fgiryne716u84o: {id: 'xohi8cki9787fgiryne716u84o', muted: false, raisedHand: 0},
+ xohi8cki9787fgiryne716u841: {id: 'xohi8cki9787fgiryne716u84o', muted: true, raisedHand: 0},
+ xohi8cki9787fgiryne716u842: {id: 'xohi8cki9787fgiryne716u84o', muted: false, raisedHand: 0},
+ xohi8cki9787fgiryne716u843: {id: 'xohi8cki9787fgiryne716u84o', muted: true, raisedHand: 0},
+ xohi8cki9787fgiryne716u844: {id: 'xohi8cki9787fgiryne716u84o', muted: false, raisedHand: 0},
+ xohi8cki9787fgiryne716u845: {id: 'xohi8cki9787fgiryne716u84o', muted: true, raisedHand: 0},
+ },
+ channelId,
+ startTime: (new Date()).getTime(),
+ screenOn: '',
+ threadId: 'abcd1234567',
+ } as Call;
+ act(() => {
+ State.callStarted(serverUrl, call);
+ });
+};
+
+describe('Actions.Calls', () => {
+ const {newConnection} = require('@calls/connection/connection');
+ InCallManager.setSpeakerphoneOn = jest.fn();
+ // eslint-disable-next-line
+ // @ts-ignore
+ NetworkManager.getClient = () => mockClient;
+ const intl = getIntlShape();
+ jest.spyOn(Permissions, 'hasMicrophonePermission').mockReturnValue(Promise.resolve(true));
+
+ beforeAll(() => {
+ // create subjects
+ const {result} = renderHook(() => {
+ return [useCallsState('server1'), useChannelsWithCalls('server1'), useCurrentCall(), useCallsConfig('server1')];
+ });
+
+ assert.deepEqual(result.current[0], DefaultCallsState);
+ assert.deepEqual(result.current[1], {});
+ assert.deepEqual(result.current[2], null);
+ assert.deepEqual(result.current[3], DefaultCallsConfig);
+ });
+
+ beforeEach(() => {
+ newConnection.mockClear();
+ mockClient.getCalls.mockClear();
+ mockClient.getCallsConfig.mockClear();
+ mockClient.getPluginsManifests.mockClear();
+ mockClient.enableChannelCalls.mockClear();
+ mockClient.disableChannelCalls.mockClear();
+
+ // reset to default state for each test
+ act(() => {
+ setCallsState('server1', DefaultCallsState);
+ setChannelsWithCalls('server1', {});
+ setCurrentCall(null);
+ setCallsConfig('server1', DefaultCallsConfig);
+ });
+ });
+
+ it('joinCall', async () => {
+ // setup
+ const {result} = renderHook(() => {
+ return [useCallsState('server1'), useCurrentCall()];
+ });
+ addFakeCall('server1', 'channel-id');
+
+ let response: { data?: string };
+ await act(async () => {
+ response = await CallsActions.joinCall('server1', 'channel-id', intl);
+ });
+
+ assert.equal(response!.data, 'channel-id');
+ assert.equal((result.current[1] as CurrentCall).channelId, 'channel-id');
+ expect(newConnection).toBeCalled();
+ expect(newConnection.mock.calls[0][1]).toBe('channel-id');
+
+ await act(async () => {
+ CallsActions.leaveCall();
+ });
+ });
+
+ it('leaveCall', async () => {
+ // setup
+ const {result} = renderHook(() => {
+ return [useCallsState('server1'), useCurrentCall()];
+ });
+ addFakeCall('server1', 'channel-id');
+ expect(getConnectionForTesting()).toBe(null);
+
+ let response: { data?: string };
+ await act(async () => {
+ response = await CallsActions.joinCall('server1', 'channel-id', intl);
+ });
+ assert.equal(response!.data, 'channel-id');
+ assert.equal((result.current[1] as CurrentCall | null)?.channelId, 'channel-id');
+
+ expect(getConnectionForTesting()!.disconnect).not.toBeCalled();
+ const disconnectMock = getConnectionForTesting()!.disconnect;
+
+ await act(async () => {
+ CallsActions.leaveCall();
+ });
+
+ expect(disconnectMock).toBeCalled();
+ expect(getConnectionForTesting()).toBe(null);
+ assert.equal((result.current[1] as CurrentCall | null), null);
+ });
+
+ it('muteMyself', async () => {
+ // setup
+ const {result} = renderHook(() => {
+ return [useCallsState('server1'), useCurrentCall()];
+ });
+ addFakeCall('server1', 'channel-id');
+ expect(getConnectionForTesting()).toBe(null);
+
+ let response: { data?: string };
+ await act(async () => {
+ response = await CallsActions.joinCall('server1', 'channel-id', intl);
+ });
+ assert.equal(response!.data, 'channel-id');
+ assert.equal((result.current[1] as CurrentCall | null)?.channelId, 'channel-id');
+
+ await act(async () => {
+ CallsActions.muteMyself();
+ });
+
+ expect(getConnectionForTesting()!.mute).toBeCalled();
+
+ await act(async () => {
+ CallsActions.leaveCall();
+ });
+ });
+
+ it('unmuteMyself', async () => {
+ // setup
+ const {result} = renderHook(() => {
+ return [useCallsState('server1'), useCurrentCall()];
+ });
+ addFakeCall('server1', 'channel-id');
+ expect(getConnectionForTesting()).toBe(null);
+
+ let response: { data?: string };
+ await act(async () => {
+ response = await CallsActions.joinCall('server1', 'channel-id', intl);
+ });
+ assert.equal(response!.data, 'channel-id');
+ assert.equal((result.current[1] as CurrentCall | null)?.channelId, 'channel-id');
+
+ await act(async () => {
+ CallsActions.unmuteMyself();
+ });
+
+ expect(getConnectionForTesting()!.unmute).toBeCalled();
+
+ await act(async () => {
+ CallsActions.leaveCall();
+ });
+ });
+
+ it('loadCalls', async () => {
+ // setup
+ const {result} = renderHook(() => {
+ return [useCallsState('server1'), useChannelsWithCalls('server1'), useCurrentCall()];
+ });
+
+ await act(async () => {
+ await CallsActions.loadCalls('server1', 'userId1');
+ });
+ expect(mockClient.getCalls).toBeCalled();
+ assert.equal((result.current[0] as CallsState).calls['channel-1'].channelId, 'channel-1');
+ assert.equal((result.current[0] as CallsState).enabled['channel-1'], true);
+ assert.equal((result.current[1] as ChannelsWithCalls)['channel-1'], true);
+ assert.equal((result.current[2] as CurrentCall | null), null);
+ });
+
+ it('loadConfig', async () => {
+ // setup
+ const {result} = renderHook(() => useCallsConfig('server1'));
+
+ await act(async () => {
+ await CallsActions.loadConfig('server1');
+ });
+ expect(mockClient.getCallsConfig).toBeCalledWith();
+ assert.equal(result.current.DefaultEnabled, true);
+ assert.equal(result.current.AllowEnableCalls, true);
+ });
+
+ it('enableChannelCalls', async () => {
+ const {result} = renderHook(() => useCallsState('server1'));
+ assert.equal(result.current.enabled['channel-1'], undefined);
+ await act(async () => {
+ await CallsActions.enableChannelCalls('server1', 'channel-1');
+ });
+ expect(mockClient.enableChannelCalls).toBeCalledWith('channel-1');
+ assert.equal(result.current.enabled['channel-1'], true);
+ });
+
+ it('disableChannelCalls', async () => {
+ const {result} = renderHook(() => useCallsState('server1'));
+ assert.equal(result.current.enabled['channel-1'], undefined);
+ await act(async () => {
+ await CallsActions.enableChannelCalls('server1', 'channel-1');
+ });
+ expect(mockClient.enableChannelCalls).toBeCalledWith('channel-1');
+ expect(mockClient.disableChannelCalls).not.toBeCalledWith('channel-1');
+ assert.equal(result.current.enabled['channel-1'], true);
+ await act(async () => {
+ await CallsActions.disableChannelCalls('server1', 'channel-1');
+ });
+ expect(mockClient.disableChannelCalls).toBeCalledWith('channel-1');
+ assert.equal(result.current.enabled['channel-1'], false);
+ });
+});
diff --git a/app/products/calls/actions/calls.ts b/app/products/calls/actions/calls.ts
new file mode 100644
index 0000000000..8d73a85a58
--- /dev/null
+++ b/app/products/calls/actions/calls.ts
@@ -0,0 +1,265 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import InCallManager from 'react-native-incall-manager';
+
+import {forceLogoutIfNecessary} from '@actions/remote/session';
+import {fetchUsersByIds} from '@actions/remote/user';
+import {hasMicrophonePermission} from '@calls/actions/permissions';
+import {
+ getCallsConfig,
+ myselfJoinedCall,
+ myselfLeftCall,
+ setCalls,
+ setChannelEnabled,
+ setConfig,
+ setPluginEnabled,
+ setScreenShareURL,
+ setSpeakerPhone,
+} from '@calls/state';
+import {
+ Call,
+ CallParticipant,
+ CallsConnection,
+ DefaultCallsConfig,
+ ServerChannelState,
+} from '@calls/types/calls';
+import Calls from '@constants/calls';
+import NetworkManager from '@managers/network_manager';
+
+import {newConnection} from '../connection/connection';
+
+import type {Client} from '@client/rest';
+import type ClientError from '@client/rest/error';
+import type {IntlShape} from 'react-intl';
+
+let connection: CallsConnection | null = null;
+export const getConnectionForTesting = () => connection;
+
+export const loadConfig = async (serverUrl: string, force = false) => {
+ const now = Date.now();
+ const config = getCallsConfig(serverUrl);
+
+ if (!force) {
+ const lastRetrievedAt = config.last_retrieved_at || 0;
+ if ((now - lastRetrievedAt) < Calls.RefreshConfigMillis) {
+ return {data: config};
+ }
+ }
+
+ let client: Client;
+ try {
+ client = NetworkManager.getClient(serverUrl);
+ } catch (error) {
+ return {error};
+ }
+
+ let data;
+ try {
+ data = await client.getCallsConfig();
+ } catch (error) {
+ await forceLogoutIfNecessary(serverUrl, error as ClientError);
+
+ // Reset the config to the default (off) since it looks like Calls is not enabled.
+ setConfig(serverUrl, {...DefaultCallsConfig, last_retrieved_at: now});
+
+ return {error};
+ }
+
+ const nextConfig = {...config, ...data, last_retrieved_at: now};
+ setConfig(serverUrl, nextConfig);
+ return {data: nextConfig};
+};
+
+export const loadCalls = async (serverUrl: string, userId: string) => {
+ let client: Client;
+ try {
+ client = NetworkManager.getClient(serverUrl);
+ } catch (error) {
+ return {error};
+ }
+ let resp: ServerChannelState[] = [];
+ try {
+ resp = await client.getCalls();
+ } catch (error) {
+ await forceLogoutIfNecessary(serverUrl, error as ClientError);
+ return {error};
+ }
+ const callsResults: Dictionary = {};
+ const enabledChannels: Dictionary = {};
+
+ // Batch load userModels async because we'll need them later
+ const ids = new Set();
+ resp.forEach((channel) => {
+ channel.call?.users.forEach((id) => ids.add(id));
+ });
+ if (ids.size > 0) {
+ fetchUsersByIds(serverUrl, Array.from(ids));
+ }
+
+ for (const channel of resp) {
+ if (channel.call) {
+ const call = channel.call;
+ callsResults[channel.channel_id] = {
+ participants: channel.call.users.reduce((accum, cur, curIdx) => {
+ const muted = call.states && call.states[curIdx] ? !call.states[curIdx].unmuted : true;
+ const raisedHand = call.states && call.states[curIdx] ? call.states[curIdx].raised_hand : 0;
+ accum[cur] = {id: cur, muted, raisedHand};
+ return accum;
+ }, {} as Dictionary),
+ channelId: channel.channel_id,
+ startTime: call.start_at,
+ screenOn: call.screen_sharing_id,
+ threadId: call.thread_id,
+ };
+ }
+ enabledChannels[channel.channel_id] = channel.enabled;
+ }
+
+ setCalls(serverUrl, userId, callsResults, enabledChannels);
+
+ return {data: {calls: callsResults, enabled: enabledChannels}};
+};
+
+export const loadConfigAndCalls = async (serverUrl: string, userId: string) => {
+ const res = await checkIsCallsPluginEnabled(serverUrl);
+ if (res.data) {
+ loadConfig(serverUrl, true);
+ loadCalls(serverUrl, userId);
+ }
+};
+
+export const checkIsCallsPluginEnabled = async (serverUrl: string) => {
+ let client: Client;
+ try {
+ client = NetworkManager.getClient(serverUrl);
+ } catch (error) {
+ return {error};
+ }
+
+ let data: ClientPluginManifest[] = [];
+ try {
+ data = await client.getPluginsManifests();
+ } catch (error) {
+ await forceLogoutIfNecessary(serverUrl, error as ClientError);
+ return {error};
+ }
+
+ const enabled = data.findIndex((m) => m.id === Calls.PluginId) !== -1;
+ setPluginEnabled(serverUrl, enabled);
+
+ return {data: enabled};
+};
+
+export const enableChannelCalls = async (serverUrl: string, channelId: string) => {
+ let client: Client;
+ try {
+ client = NetworkManager.getClient(serverUrl);
+ } catch (error) {
+ return {error};
+ }
+
+ try {
+ await client.enableChannelCalls(channelId);
+ } catch (error) {
+ await forceLogoutIfNecessary(serverUrl, error as ClientError);
+ return {error};
+ }
+
+ setChannelEnabled(serverUrl, channelId, true);
+ return {};
+};
+
+export const disableChannelCalls = async (serverUrl: string, channelId: string) => {
+ let client: Client;
+ try {
+ client = NetworkManager.getClient(serverUrl);
+ } catch (error) {
+ return {error};
+ }
+
+ try {
+ await client.disableChannelCalls(channelId);
+ } catch (error) {
+ await forceLogoutIfNecessary(serverUrl, error as ClientError);
+ return {error};
+ }
+
+ setChannelEnabled(serverUrl, channelId, false);
+ return {};
+};
+
+export const joinCall = async (serverUrl: string, channelId: string, intl: IntlShape) => {
+ // Edge case: calls was disabled when app loaded, and then enabled, but app hasn't
+ // reconnected its websocket since then (i.e., hasn't called batchLoadCalls yet)
+ const {data: enabled} = await checkIsCallsPluginEnabled(serverUrl);
+ if (!enabled) {
+ return {error: 'calls plugin not enabled'};
+ }
+
+ const hasPermission = await hasMicrophonePermission(intl);
+ if (!hasPermission) {
+ return {error: 'no permissions to microphone, unable to start call'};
+ }
+
+ if (connection) {
+ connection.disconnect();
+ connection = null;
+ }
+ setSpeakerphoneOn(false);
+
+ try {
+ connection = await newConnection(serverUrl, channelId, () => null, setScreenShareURL);
+ } catch (error) {
+ await forceLogoutIfNecessary(serverUrl, error as ClientError);
+ return {error};
+ }
+
+ try {
+ await connection.waitForReady();
+ myselfJoinedCall(serverUrl, channelId);
+ return {data: channelId};
+ } catch (e) {
+ connection.disconnect();
+ connection = null;
+ return {error: 'unable to connect to the voice call'};
+ }
+};
+
+export const leaveCall = () => {
+ if (connection) {
+ connection.disconnect();
+ connection = null;
+ }
+ setSpeakerphoneOn(false);
+ myselfLeftCall();
+};
+
+export const muteMyself = () => {
+ if (connection) {
+ connection.mute();
+ }
+};
+
+export const unmuteMyself = () => {
+ if (connection) {
+ connection.unmute();
+ }
+};
+
+export const raiseHand = () => {
+ if (connection) {
+ connection.raiseHand();
+ }
+};
+
+export const unraiseHand = () => {
+ if (connection) {
+ connection.unraiseHand();
+ }
+};
+
+export const setSpeakerphoneOn = (speakerphoneOn: boolean) => {
+ InCallManager.setSpeakerphoneOn(speakerphoneOn);
+ setSpeakerPhone(speakerphoneOn);
+};
diff --git a/app/products/calls/actions/index.ts b/app/products/calls/actions/index.ts
new file mode 100644
index 0000000000..773996cb6c
--- /dev/null
+++ b/app/products/calls/actions/index.ts
@@ -0,0 +1,18 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+export {
+ loadConfig,
+ loadCalls,
+ enableChannelCalls,
+ disableChannelCalls,
+ joinCall,
+ leaveCall,
+ muteMyself,
+ unmuteMyself,
+ raiseHand,
+ unraiseHand,
+ setSpeakerphoneOn,
+} from './calls';
+
+export {hasMicrophonePermission} from './permissions';
diff --git a/app/products/calls/actions/permissions.ts b/app/products/calls/actions/permissions.ts
new file mode 100644
index 0000000000..a64931fa0c
--- /dev/null
+++ b/app/products/calls/actions/permissions.ts
@@ -0,0 +1,66 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {Alert, Platform} from 'react-native';
+import DeviceInfo from 'react-native-device-info';
+import Permissions from 'react-native-permissions';
+
+import type {IntlShape} from 'react-intl';
+
+const getMicrophonePermissionDeniedMessage = (intl: IntlShape) => {
+ const {formatMessage} = intl;
+ const applicationName = DeviceInfo.getApplicationName();
+ return {
+ title: formatMessage({
+ id: 'mobile.microphone_permission_denied_title',
+ defaultMessage: '{applicationName} would like to access your microphone',
+ }, {applicationName}),
+ text: formatMessage({
+ id: 'mobile.microphone_permission_denied_description',
+ defaultMessage: 'To participate in this call, open Settings to grant Mattermost access to your microphone.',
+ }),
+ };
+};
+
+export const hasMicrophonePermission = async (intl: IntlShape) => {
+ const targetSource = Platform.OS === 'ios' ? Permissions.PERMISSIONS.IOS.MICROPHONE : Permissions.PERMISSIONS.ANDROID.RECORD_AUDIO;
+ const hasPermission = await Permissions.check(targetSource);
+
+ switch (hasPermission) {
+ case Permissions.RESULTS.DENIED:
+ case Permissions.RESULTS.UNAVAILABLE: {
+ const permissionRequest = await Permissions.request(targetSource);
+
+ return permissionRequest === Permissions.RESULTS.GRANTED;
+ }
+ case Permissions.RESULTS.BLOCKED: {
+ const grantOption = {
+ text: intl.formatMessage({
+ id: 'mobile.permission_denied_retry',
+ defaultMessage: 'Settings',
+ }),
+ onPress: () => Permissions.openSettings(),
+ };
+
+ const {title, text} = getMicrophonePermissionDeniedMessage(intl);
+
+ Alert.alert(
+ title,
+ text,
+ [
+ grantOption,
+ {
+ text: intl.formatMessage({
+ id: 'mobile.permission_denied_dismiss',
+ defaultMessage: 'Don\'t Allow',
+ }),
+ },
+ ],
+ );
+ return false;
+ }
+ }
+
+ return true;
+};
+
diff --git a/app/products/calls/client/rest.ts b/app/products/calls/client/rest.ts
new file mode 100644
index 0000000000..6d3342c3eb
--- /dev/null
+++ b/app/products/calls/client/rest.ts
@@ -0,0 +1,56 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {ServerChannelState, ServerConfig} from '@calls/types/calls';
+
+export interface ClientCallsMix {
+ getEnabled: () => Promise;
+ getCalls: () => Promise;
+ getCallsConfig: () => Promise;
+ enableChannelCalls: (channelId: string) => Promise;
+ disableChannelCalls: (channelId: string) => Promise;
+}
+
+const ClientCalls = (superclass: any) => class extends superclass {
+ getEnabled = async () => {
+ try {
+ await this.doFetch(
+ `${this.getCallsRoute()}/version`,
+ {method: 'get'},
+ );
+ return true;
+ } catch (e) {
+ return false;
+ }
+ };
+
+ getCalls = async () => {
+ return this.doFetch(
+ `${this.getCallsRoute()}/channels`,
+ {method: 'get'},
+ );
+ };
+
+ getCallsConfig = async () => {
+ return this.doFetch(
+ `${this.getCallsRoute()}/config`,
+ {method: 'get'},
+ ) as ServerConfig;
+ };
+
+ 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/call_avatar.tsx b/app/products/calls/components/call_avatar.tsx
new file mode 100644
index 0000000000..d2c3992b95
--- /dev/null
+++ b/app/products/calls/components/call_avatar.tsx
@@ -0,0 +1,188 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React, {useMemo} from 'react';
+import {View, StyleSheet, Text, Platform} from 'react-native';
+
+import CompassIcon from '@components/compass_icon';
+import ProfilePicture from '@components/profile_picture';
+
+import type UserModel from '@typings/database/models/servers/user';
+
+type Props = {
+ userModel?: UserModel;
+ volume: number;
+ serverUrl: string;
+ muted?: boolean;
+ sharingScreen?: boolean;
+ raisedHand?: boolean;
+ size?: 'm' | 'l';
+}
+
+const getStyleSheet = ({volume, muted, size}: { volume: number; muted?: boolean; size?: 'm' | 'l' }) => {
+ const baseSize = size === 'm' || !size ? 40 : 72;
+ const smallIcon = size === 'm' || !size;
+ const widthHeight = smallIcon ? 20 : 24;
+ const borderRadius = smallIcon ? 10 : 12;
+ const padding = smallIcon ? 1 : 2;
+
+ return StyleSheet.create({
+ pictureHalo: {
+ backgroundColor: 'rgba(61, 184, 135,' + (0.24 * volume) + ')',
+ height: baseSize + 16,
+ width: baseSize + 16,
+ padding: 4,
+ marginRight: 4,
+ borderRadius: (baseSize + 16) / 2,
+ },
+ pictureHalo2: {
+ backgroundColor: 'rgba(61, 184, 135,' + (0.32 * volume) + ')',
+ height: baseSize + 8,
+ width: baseSize + 8,
+ padding: 3,
+ borderRadius: (baseSize + 8) / 2,
+ },
+ picture: {
+ borderRadius: baseSize / 2,
+ height: baseSize,
+ width: baseSize,
+ marginBottom: 5,
+ },
+ voiceShadow: {
+ shadowColor: 'rgb(61, 184, 135)',
+ shadowOffset: {width: 0, height: 0},
+ shadowOpacity: 1,
+ shadowRadius: 10,
+ },
+ mute: {
+ position: 'absolute',
+ bottom: -5,
+ right: -5,
+ width: widthHeight,
+ height: widthHeight,
+ borderRadius,
+ padding,
+ backgroundColor: muted ? 'black' : '#3DB887',
+ borderColor: 'black',
+ borderWidth: 2,
+ color: 'white',
+ textAlign: 'center',
+ textAlignVertical: 'center',
+ overflow: 'hidden',
+ ...Platform.select(
+ {
+ ios: {
+ padding: 2,
+ },
+ },
+ ),
+ },
+ raisedHand: {
+ position: 'absolute',
+ overflow: 'hidden',
+ top: 0,
+ right: -5,
+ backgroundColor: 'black',
+ borderColor: 'black',
+ borderRadius,
+ padding,
+ borderWidth: 2,
+ width: widthHeight,
+ height: widthHeight,
+ fontSize: smallIcon ? 10 : 12,
+ ...Platform.select(
+ {
+ android: {
+ paddingLeft: 4,
+ paddingTop: 2,
+ color: 'rgb(255, 188, 66)',
+ },
+ },
+ ),
+ },
+ screenSharing: {
+ position: 'absolute',
+ top: 0,
+ right: -5,
+ width: widthHeight,
+ height: widthHeight,
+ borderRadius,
+ padding: padding + 1,
+ backgroundColor: '#D24B4E',
+ borderColor: 'black',
+ borderWidth: 2,
+ color: 'white',
+ textAlign: 'center',
+ textAlignVertical: 'center',
+ overflow: 'hidden',
+ },
+ });
+};
+
+const CallAvatar = ({userModel, volume, serverUrl, sharingScreen, size, muted, raisedHand}: Props) => {
+ const style = useMemo(() => getStyleSheet({volume, muted, size}), [volume, muted, size]);
+ const profileSize = size === 'm' || !size ? 40 : 72;
+ const iconSize = size === 'm' || !size ? 12 : 16;
+ const styleShadow = volume > 0 ? style.voiceShadow : undefined;
+
+ // Only show one or the other.
+ let topRightIcon: JSX.Element | null = null;
+ if (sharingScreen) {
+ topRightIcon = (
+
+ );
+ } else if (raisedHand) {
+ topRightIcon = (
+
+ {'✋'}
+
+ );
+ }
+
+ const profile = userModel ? (
+
+ ) : (
+
+ );
+
+ const view = (
+
+ {profile}
+ {
+ muted !== undefined &&
+
+ }
+ {topRightIcon}
+
+ );
+
+ if (Platform.OS === 'android') {
+ return (
+
+
+ {view}
+
+
+ );
+ }
+
+ return view;
+};
+
+export default CallAvatar;
diff --git a/app/products/calls/components/call_duration.tsx b/app/products/calls/components/call_duration.tsx
new file mode 100644
index 0000000000..bfc3c06a17
--- /dev/null
+++ b/app/products/calls/components/call_duration.tsx
@@ -0,0 +1,54 @@
+// 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 function cleanup() {
+ clearInterval(interval);
+ };
+ }
+ return function cleanup() {
+ return null;
+ };
+ }, [updateIntervalInSeconds]);
+
+ return (
+
+ {formattedTime}
+
+ );
+};
+
+export default CallDuration;
diff --git a/app/products/calls/components/calls_custom_message/calls_custom_message.tsx b/app/products/calls/components/calls_custom_message/calls_custom_message.tsx
new file mode 100644
index 0000000000..95f663c2ab
--- /dev/null
+++ b/app/products/calls/components/calls_custom_message/calls_custom_message.tsx
@@ -0,0 +1,186 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import moment from 'moment-timezone';
+import React from 'react';
+import {useIntl} from 'react-intl';
+import {Text, TouchableOpacity, View} from 'react-native';
+
+import {joinCall} from '@calls/actions';
+import leaveAndJoinWithAlert from '@calls/components/leave_and_join_alert';
+import CompassIcon from '@components/compass_icon';
+import FormattedRelativeTime from '@components/formatted_relative_time';
+import FormattedTime from '@components/formatted_time';
+import {useServerUrl} from '@context/server';
+import {useTheme} from '@context/theme';
+import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
+import {displayUsername, getUserTimezone} from '@utils/user';
+
+import type PostModel from '@typings/database/models/servers/post';
+import type UserModel from '@typings/database/models/servers/user';
+
+type Props = {
+ post: PostModel;
+ currentUser: UserModel;
+ author?: UserModel;
+ isMilitaryTime: boolean;
+ teammateNameDisplay?: string;
+ currentCallChannelId?: string;
+ leaveChannelName?: string;
+ joinChannelName?: string;
+}
+
+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,
+ },
+ };
+});
+
+export const CallsCustomMessage = ({
+ post, currentUser, author, isMilitaryTime, teammateNameDisplay,
+ currentCallChannelId, leaveChannelName, joinChannelName,
+}: Props) => {
+ const intl = useIntl();
+ const theme = useTheme();
+ const style = getStyleSheet(theme);
+ const serverUrl = useServerUrl();
+ const timezone = getUserTimezone(currentUser);
+
+ const confirmToJoin = Boolean(currentCallChannelId && currentCallChannelId !== post.channelId);
+ const alreadyInTheCall = Boolean(currentCallChannelId && currentCallChannelId === post.channelId);
+
+ const joinHandler = () => {
+ if (alreadyInTheCall) {
+ return;
+ }
+
+ leaveAndJoinWithAlert(intl, serverUrl, post.channelId, leaveChannelName || '', joinChannelName || '', confirmToJoin, joinCall);
+ };
+
+ if (post.props.end_at) {
+ return (
+
+
+
+ {'Call ended'}
+
+ {'Ended at '}
+ {
+
+ }
+ {'•'}
+
+ {`Lasted ${moment.duration(post.props.end_at - post.props.start_at).humanize(false)}`}
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ {`${displayUsername(author, intl.locale, teammateNameDisplay)} started a call`}
+
+
+
+
+
+
+ {
+ alreadyInTheCall &&
+ {'Current call'}
+ }
+ {
+ !alreadyInTheCall &&
+ {'Join call'}
+ }
+
+
+ );
+};
+
diff --git a/app/products/calls/components/calls_custom_message/index.ts b/app/products/calls/components/calls_custom_message/index.ts
new file mode 100644
index 0000000000..2c3b8cb9c6
--- /dev/null
+++ b/app/products/calls/components/calls_custom_message/index.ts
@@ -0,0 +1,63 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
+import withObservables from '@nozbe/with-observables';
+import {combineLatest, of as of$} from 'rxjs';
+import {switchMap} from 'rxjs/operators';
+
+import {CallsCustomMessage} from '@calls/components/calls_custom_message/calls_custom_message';
+import {observeCurrentCall} from '@calls/state';
+import {Preferences} from '@constants';
+import DatabaseManager from '@database/manager';
+import {getPreferenceAsBool} from '@helpers/api/preference';
+import {observeChannel} from '@queries/servers/channel';
+import {queryPreferencesByCategoryAndName} from '@queries/servers/preference';
+import {observeCurrentUser, observeTeammateNameDisplay, observeUser} from '@queries/servers/user';
+import {WithDatabaseArgs} from '@typings/database/database';
+
+import type PostModel from '@typings/database/models/servers/post';
+
+const enhanced = withObservables(['post'], ({post, database}: { post: PostModel } & WithDatabaseArgs) => {
+ const currentUser = observeCurrentUser(database);
+ const author = observeUser(database, post.userId);
+ const isMilitaryTime = queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_DISPLAY_SETTINGS).observeWithColumns(['value']).pipe(
+ switchMap(
+ (preferences) => of$(getPreferenceAsBool(preferences, Preferences.CATEGORY_DISPLAY_SETTINGS, 'use_military_time', false)),
+ ),
+ );
+
+ // The call is not active, so return early with what we need to render the post.
+ if (post.props.end_at) {
+ return {
+ currentUser,
+ author,
+ isMilitaryTime,
+ };
+ }
+
+ const currentCall = observeCurrentCall();
+ const ccDatabase = currentCall.pipe(
+ switchMap((call) => of$(call ? call.serverUrl : '')),
+ switchMap((url) => of$(DatabaseManager.serverDatabases[url]?.database)),
+ );
+
+ return {
+ currentUser,
+ author,
+ isMilitaryTime,
+ teammateNameDisplay: observeTeammateNameDisplay(database),
+ currentCallChannelId: currentCall.pipe(
+ switchMap((cc) => of$(cc?.channelId || '')),
+ ),
+ leaveChannelName: combineLatest([ccDatabase, currentCall]).pipe(
+ switchMap(([db, call]) => (db && call ? observeChannel(db, call.channelId) : of$(undefined))),
+ switchMap((c) => of$(c ? c.displayName : '')),
+ ),
+ joinChannelName: observeChannel(database, post.channelId).pipe(
+ switchMap((chan) => of$(chan?.displayName || '')),
+ ),
+ };
+});
+
+export default withDatabase(enhanced(CallsCustomMessage));
diff --git a/app/products/calls/components/current_call_bar/current_call_bar.tsx b/app/products/calls/components/current_call_bar/current_call_bar.tsx
new file mode 100644
index 0000000000..142ab76d55
--- /dev/null
+++ b/app/products/calls/components/current_call_bar/current_call_bar.tsx
@@ -0,0 +1,184 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React, {useCallback, useEffect, useState} from 'react';
+import {View, Text, TouchableOpacity, Pressable, Platform, DeviceEventEmitter} from 'react-native';
+import {Options} from 'react-native-navigation';
+
+import {muteMyself, unmuteMyself} from '@calls/actions';
+import CallAvatar from '@calls/components/call_avatar';
+import {CurrentCall, VoiceEventData} from '@calls/types/calls';
+import CompassIcon from '@components/compass_icon';
+import {Events, WebsocketEvents} from '@constants';
+import {CURRENT_CALL_BAR_HEIGHT} from '@constants/view';
+import {useTheme} from '@context/theme';
+import {goToScreen} from '@screens/navigation';
+import {makeStyleSheetFromTheme} from '@utils/theme';
+import {displayUsername} from '@utils/user';
+
+import type UserModel from '@typings/database/models/servers/user';
+
+type Props = {
+ displayName: string;
+ currentCall: CurrentCall | null;
+ userModelsDict: Dictionary;
+ 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 CurrentCallBar = ({
+ displayName,
+ currentCall,
+ userModelsDict,
+ teammateNameDisplay,
+}: Props) => {
+ const theme = useTheme();
+ const isCurrentCall = Boolean(currentCall);
+ const [speaker, setSpeaker] = useState(null);
+ const handleVoiceOn = (data: VoiceEventData) => {
+ if (data.channelId === currentCall?.channelId) {
+ setSpeaker(data.userId);
+ }
+ };
+ const handleVoiceOff = (data: VoiceEventData) => {
+ if (data.channelId === currentCall?.channelId && ((speaker === data.userId) || !speaker)) {
+ setSpeaker(null);
+ }
+ };
+
+ useEffect(() => {
+ const onVoiceOn = DeviceEventEmitter.addListener(WebsocketEvents.CALLS_USER_VOICE_ON, handleVoiceOn);
+ const onVoiceOff = DeviceEventEmitter.addListener(WebsocketEvents.CALLS_USER_VOICE_OFF, handleVoiceOff);
+ DeviceEventEmitter.emit(Events.CURRENT_CALL_BAR_VISIBLE, isCurrentCall);
+ return () => {
+ DeviceEventEmitter.emit(Events.CURRENT_CALL_BAR_VISIBLE, Boolean(false));
+ onVoiceOn.remove();
+ onVoiceOff.remove();
+ };
+ }, [isCurrentCall]);
+
+ 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 myParticipant = currentCall?.participants[currentCall.myUserId];
+ if (!currentCall || !myParticipant) {
+ return null;
+ }
+
+ const muteUnmute = () => {
+ if (myParticipant?.muted) {
+ unmuteMyself();
+ } else {
+ muteMyself();
+ }
+ };
+
+ const style = getStyleSheet(theme);
+ return (
+
+
+
+
+
+ {speaker && `${displayUsername(userModelsDict[speaker], teammateNameDisplay)} is talking`}
+ {!speaker && 'No one is talking'}
+
+
+ {`~${displayName}`}
+
+
+
+
+
+
+
+
+
+
+ );
+};
+export default CurrentCallBar;
diff --git a/app/products/calls/components/current_call_bar/index.ts b/app/products/calls/components/current_call_bar/index.ts
new file mode 100644
index 0000000000..238f714d5b
--- /dev/null
+++ b/app/products/calls/components/current_call_bar/index.ts
@@ -0,0 +1,48 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import withObservables from '@nozbe/with-observables';
+import {combineLatest, of as of$} from 'rxjs';
+import {switchMap} from 'rxjs/operators';
+
+import {observeCurrentCall} from '@calls/state';
+import DatabaseManager from '@database/manager';
+import {observeChannel} from '@queries/servers/channel';
+import {observeTeammateNameDisplay, observeUsersById} from '@queries/servers/user';
+
+import CurrentCallBar from './current_call_bar';
+
+import type UserModel from '@typings/database/models/servers/user';
+
+const enhanced = withObservables([], () => {
+ const currentCall = observeCurrentCall();
+ const database = currentCall.pipe(
+ switchMap((call) => of$(call ? call.serverUrl : '')),
+ switchMap((url) => of$(DatabaseManager.serverDatabases[url]?.database)),
+ );
+ const displayName = combineLatest([database, currentCall]).pipe(
+ switchMap(([db, call]) => (db && call ? observeChannel(db, call.channelId) : of$(undefined))),
+ switchMap((c) => of$(c ? c.displayName : '')),
+ );
+ const userModelsDict = combineLatest([database, currentCall]).pipe(
+ switchMap(([db, call]) => (db && call ? observeUsersById(db, Object.keys(call.participants)) : of$([]))),
+ switchMap((ps) => of$(
+ ps.reduce((accum, cur) => { // eslint-disable-line max-nested-callbacks
+ accum[cur.id] = cur;
+ return accum;
+ }, {} as Dictionary)),
+ ),
+ );
+ const teammateNameDisplay = database.pipe(
+ switchMap((db) => (db ? observeTeammateNameDisplay(db) : of$(''))),
+ );
+
+ return {
+ displayName,
+ currentCall,
+ userModelsDict,
+ teammateNameDisplay,
+ };
+});
+
+export default enhanced(CurrentCallBar);
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 0000000000..8a2d52205b
--- /dev/null
+++ b/app/products/calls/components/floating_call_container.tsx
@@ -0,0 +1,47 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react';
+import {View, Platform, StyleSheet} from 'react-native';
+import {useSafeAreaInsets} from 'react-native-safe-area-context';
+
+import {ANDROID_DEFAULT_HEADER_HEIGHT, IOS_DEFAULT_HEADER_HEIGHT} from '@constants/view';
+
+let topBarHeight = ANDROID_DEFAULT_HEADER_HEIGHT;
+if (Platform.OS === 'ios') {
+ topBarHeight = IOS_DEFAULT_HEADER_HEIGHT;
+}
+
+const style = StyleSheet.create({
+ wrapper: {
+ position: 'absolute',
+ width: '100%',
+ ...Platform.select({
+ android: {
+ elevation: 9,
+ },
+ ios: {
+ zIndex: 9,
+ },
+ }),
+ },
+});
+
+type Props = {
+ children: React.ReactNode;
+}
+
+const FloatingCallContainer = (props: Props) => {
+ const insets = useSafeAreaInsets();
+ const wrapperTop = {
+ top: topBarHeight + insets.top,
+ };
+
+ return (
+
+ {props.children}
+
+ );
+};
+
+export default FloatingCallContainer;
diff --git a/app/products/calls/components/join_call_banner/index.ts b/app/products/calls/components/join_call_banner/index.ts
new file mode 100644
index 0000000000..f416229b9f
--- /dev/null
+++ b/app/products/calls/components/join_call_banner/index.ts
@@ -0,0 +1,49 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
+import withObservables from '@nozbe/with-observables';
+import {of as of$} from 'rxjs';
+import {switchMap} from 'rxjs/operators';
+
+import JoinCallBanner from '@calls/components/join_call_banner/join_call_banner';
+import {observeCallsState, observeChannelsWithCalls, observeCurrentCall} from '@calls/state';
+import {observeChannel} from '@queries/servers/channel';
+import {observeUsersById} from '@queries/servers/user';
+import {WithDatabaseArgs} from '@typings/database/database';
+
+type OwnProps = {
+ serverUrl: string;
+ channelId: string;
+}
+
+const enhanced = withObservables(['serverUrl', 'channelId'], ({serverUrl, channelId, database}: OwnProps & WithDatabaseArgs) => {
+ const displayName = observeChannel(database, channelId).pipe(
+ switchMap((c) => of$(c?.displayName)),
+ );
+ const currentCall = observeCurrentCall();
+ const participants = currentCall.pipe(
+ switchMap((call) => (call ? observeUsersById(database, Object.keys(call.participants)) : of$([]))),
+ );
+ const currentCallChannelName = currentCall.pipe(
+ switchMap((call) => observeChannel(database, call ? call.channelId : '')),
+ switchMap((channel) => of$(channel ? channel.displayName : '')),
+ );
+ const isCallInCurrentChannel = observeChannelsWithCalls(serverUrl).pipe(
+ switchMap((calls) => of$(Boolean(calls[channelId]))),
+ );
+ const channelCallStartTime = observeCallsState(serverUrl).pipe(
+ switchMap((callsState) => of$(callsState.calls[channelId]?.startTime || 0)),
+ );
+
+ return {
+ displayName,
+ currentCall,
+ participants,
+ currentCallChannelName,
+ isCallInCurrentChannel,
+ channelCallStartTime,
+ };
+});
+
+export default withDatabase(enhanced(JoinCallBanner));
diff --git a/app/products/calls/components/join_call_banner/join_call_banner.tsx b/app/products/calls/components/join_call_banner/join_call_banner.tsx
new file mode 100644
index 0000000000..fcf5936881
--- /dev/null
+++ b/app/products/calls/components/join_call_banner/join_call_banner.tsx
@@ -0,0 +1,139 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react';
+import {useIntl} from 'react-intl';
+import {View, Text, Pressable} from 'react-native';
+
+import {joinCall} from '@calls/actions';
+import leaveAndJoinWithAlert from '@calls/components/leave_and_join_alert';
+import {CurrentCall} from '@calls/types/calls';
+import CompassIcon from '@components/compass_icon';
+import FormattedRelativeTime from '@components/formatted_relative_time';
+import UserAvatarsStack from '@components/user_avatars_stack';
+import Screens from '@constants/screens';
+import {JOIN_CALL_BAR_HEIGHT} from '@constants/view';
+import {useTheme} from '@context/theme';
+import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
+
+import type UserModel from '@typings/database/models/servers/user';
+
+type Props = {
+ channelId: string;
+ serverUrl: string;
+ displayName: string;
+ currentCall: CurrentCall | null;
+ participants: UserModel[];
+ currentCallChannelName: string;
+ isCallInCurrentChannel: boolean;
+ channelCallStartTime: number;
+}
+
+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 JoinCallBanner = ({
+ channelId,
+ serverUrl,
+ displayName,
+ currentCall,
+ participants,
+ currentCallChannelName,
+ isCallInCurrentChannel,
+ channelCallStartTime,
+}: Props) => {
+ const intl = useIntl();
+ const theme = useTheme();
+ const style = getStyleSheet(theme);
+
+ if (!isCallInCurrentChannel) {
+ return null;
+ }
+
+ const confirmToJoin = Boolean(currentCall && currentCall.channelId !== channelId);
+ const alreadyInTheCall = Boolean(currentCall && currentCall.channelId === channelId);
+
+ if (alreadyInTheCall) {
+ return null;
+ }
+
+ // TODO: implement join_call_banner/more_messages spacing: https://mattermost.atlassian.net/browse/MM-45744
+ // 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 = async () => {
+ leaveAndJoinWithAlert(intl, serverUrl, channelId, currentCallChannelName, displayName, confirmToJoin, joinCall);
+ };
+
+ return (
+
+
+ {'Join Call'}
+
+
+
+
+
+
+
+ );
+};
+
+export default JoinCallBanner;
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 0000000000..ca969b5c30
--- /dev/null
+++ b/app/products/calls/components/leave_and_join_alert.tsx
@@ -0,0 +1,47 @@
+// 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: IntlShape,
+ serverUrl: string,
+ channelId: string,
+ leaveChannelName: string,
+ joinChannelName: string,
+ confirmToJoin: boolean,
+ joinCall: (serverUrl: string, channelId: string, intl: IntlShape) => void,
+) {
+ if (confirmToJoin) {
+ const {formatMessage} = intl;
+ Alert.alert(
+ formatMessage({
+ id: 'mobile.leave_and_join_title',
+ defaultMessage: 'Are you sure you want to switch to a different call?',
+ }),
+ formatMessage({
+ id: 'mobile.leave_and_join_message',
+ defaultMessage: 'You are already on a channel call in ~{leaveChannelName}. Do you want to leave your current call and join the call in ~{joinChannelName}?',
+ }, {leaveChannelName, joinChannelName}),
+ [
+ {
+ text: formatMessage({
+ id: 'mobile.post.cancel',
+ defaultMessage: 'Cancel',
+ }),
+ },
+ {
+ text: formatMessage({
+ id: 'mobile.leave_and_join_confirmation',
+ defaultMessage: 'Leave & Join',
+ }),
+ onPress: () => joinCall(serverUrl, channelId, intl),
+ style: 'cancel',
+ },
+ ],
+ );
+ } else {
+ joinCall(serverUrl, channelId, intl);
+ }
+}
diff --git a/app/products/calls/connection/connection.ts b/app/products/calls/connection/connection.ts
new file mode 100644
index 0000000000..4404f5cff4
--- /dev/null
+++ b/app/products/calls/connection/connection.ts
@@ -0,0 +1,219 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-ignore
+import {deflate} from 'pako/lib/deflate.js';
+import InCallManager from 'react-native-incall-manager';
+import {
+ MediaStream,
+ MediaStreamTrack,
+ mediaDevices,
+} from 'react-native-webrtc';
+
+import {CallsConnection} from '@calls/types/calls';
+import NetworkManager from '@managers/network_manager';
+
+import Peer from './simple-peer';
+import WebSocketClient from './websocket_client';
+
+const websocketConnectTimeout = 3000;
+
+export async function newConnection(serverUrl: string, channelID: string, closeCb: () => void, setScreenShareURL: (url: string) => void) {
+ let peer: Peer | null = null;
+ let stream: MediaStream;
+ let voiceTrackAdded = false;
+ let voiceTrack: MediaStreamTrack | null = null;
+ let isClosed = false;
+ const streams: MediaStream[] = [];
+
+ try {
+ stream = await mediaDevices.getUserMedia({
+ video: false,
+ audio: true,
+ }) as MediaStream;
+ voiceTrack = stream.getAudioTracks()[0];
+ voiceTrack.enabled = false;
+ streams.push(stream);
+ } catch (err) {
+ console.log('Unable to get media device:', err); // eslint-disable-line no-console
+ }
+
+ // getClient can throw an error, which will be handled by the caller.
+ const client = NetworkManager.getClient(serverUrl);
+
+ const ws = new WebSocketClient(serverUrl, client.getWebSocketUrl());
+ await ws.initialize();
+
+ const disconnect = () => {
+ if (!isClosed) {
+ ws.close();
+ }
+
+ streams.forEach((s) => {
+ s.getTracks().forEach((track: MediaStreamTrack) => {
+ track.stop();
+ track.release();
+ });
+ });
+
+ peer?.destroy(undefined, undefined, () => {
+ // Wait until the peer connection is closed, which avoids the following racy error that can cause problems with accessing the audio system in the future:
+ // AVAudioSession_iOS.mm:1243 Deactivating an audio session that has running I/O. All I/O should be stopped or paused prior to deactivating the audio session.
+ InCallManager.stop();
+ });
+
+ if (closeCb) {
+ closeCb();
+ }
+ };
+
+ const mute = () => {
+ if (!peer || peer.destroyed) {
+ return;
+ }
+
+ try {
+ if (voiceTrackAdded && voiceTrack) {
+ peer.replaceTrack(voiceTrack, null, stream);
+ }
+ } catch (e) {
+ console.log('Error from simple-peer:', e); //eslint-disable-line no-console
+ return;
+ }
+
+ if (voiceTrack) {
+ voiceTrack.enabled = false;
+ }
+ if (ws) {
+ ws.send('mute');
+ }
+ };
+
+ const unmute = () => {
+ if (!peer || !voiceTrack || peer.destroyed) {
+ return;
+ }
+
+ try {
+ if (voiceTrackAdded) {
+ peer.replaceTrack(voiceTrack, voiceTrack, stream);
+ } else {
+ peer.addStream(stream);
+ voiceTrackAdded = true;
+ }
+ } catch (e) {
+ console.log('Error from simple-peer:', e); //eslint-disable-line no-console
+ return;
+ }
+
+ voiceTrack.enabled = true;
+ if (ws) {
+ ws.send('unmute');
+ }
+ };
+
+ const raiseHand = () => {
+ if (ws) {
+ ws.send('raise_hand');
+ }
+ };
+
+ const unraiseHand = () => {
+ if (ws) {
+ ws.send('unraise_hand');
+ }
+ };
+
+ ws.on('error', (err: Event) => {
+ console.log('WS (CALLS) ERROR', err); // eslint-disable-line no-console
+ ws.close();
+ });
+
+ ws.on('close', () => {
+ isClosed = true;
+ disconnect();
+ });
+
+ ws.on('join', async () => {
+ let config;
+ try {
+ config = await client.getCallsConfig();
+ } catch (err) {
+ console.log('ERROR FETCHING CALLS CONFIG:', err); // eslint-disable-line no-console
+ return;
+ }
+
+ InCallManager.start({media: 'audio'});
+ InCallManager.stopProximitySensor();
+ peer = new Peer(null, config.ICEServers);
+ peer.on('signal', (data: any) => {
+ if (data.type === 'offer' || data.type === 'answer') {
+ ws.send('sdp', {
+ data: deflate(JSON.stringify(data)),
+ }, true);
+ } else if (data.type === 'candidate') {
+ ws.send('ice', {
+ data: JSON.stringify(data.candidate),
+ });
+ }
+ });
+
+ 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.on('open', async () => {
+ ws.send('join', {
+ channelID,
+ });
+ });
+
+ ws.on('message', ({data}: { data: string }) => {
+ 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.state() === WebSocket.OPEN) {
+ callback();
+ } else {
+ waitForReadyImpl(callback, fail, timeout - 10);
+ }
+ }, 10);
+ };
+
+ const promise = new Promise((resolve, reject) => {
+ waitForReadyImpl(resolve, reject, websocketConnectTimeout);
+ });
+
+ return promise;
+ };
+
+ const connection = {
+ disconnect,
+ mute,
+ unmute,
+ waitForReady,
+ raiseHand,
+ unraiseHand,
+ } as CallsConnection;
+
+ return connection;
+}
diff --git a/app/products/calls/connection/simple-peer.ts b/app/products/calls/connection/simple-peer.ts
new file mode 100644
index 0000000000..ed2c38ec1d
--- /dev/null
+++ b/app/products/calls/connection/simple-peer.ts
@@ -0,0 +1,1043 @@
+// 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-webrtc';
+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 | null, iceServers?: string[]) {
+ super({allowHalfOpen: false});
+
+ this.streams = localStream ? [localStream] : [];
+
+ this.onFinishBound = () => {
+ this.onFinish();
+ };
+
+ const connConfig = {
+ iceServers: [
+ {
+ urls: [
+ 'stun:stun.l.google.com:19302',
+ 'stun:global.stun.twilio.com:3478',
+ ],
+ },
+ ],
+ sdpSemantics: 'unified-plan',
+ };
+
+ if (iceServers && iceServers.length > 0) {
+ connConfig.iceServers[0].urls = iceServers;
+ }
+
+ try {
+ this.pc = new RTCPeerConnection(connConfig);
+ } catch (err) {
+ this.destroy(errCode(err as Error, '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 as Error, '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
+ */
+ async addTrack(track: MediaStreamTrack, s: MediaStream) {
+ if (this.destroying) {
+ return;
+ }
+ if (this.destroyed || !this.pc) {
+ 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
+ const sender = submap.get(s);
+ if (!sender) {
+ const transceiver = await this.pc.addTransceiver(track, {direction: 'sendrecv'}) as any;
+ /* eslint-disable no-underscore-dangle */
+ submap.set(s, transceiver._sender);
+ this.senderMap.set(track, submap);
+ this.needsNegotiation();
+ } else if (sender.removed) {
+ 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',
+ );
+ }
+ }
+
+ /**
+ * Replace a MediaStreamTrack by another in the connection.
+ * @param {MediaStreamTrack} oldTrack
+ * @param {MediaStreamTrack} newTrack
+ * @param {MediaStream} stream
+ */
+ replaceTrack(oldTrack: MediaStreamTrack, newTrack: MediaStreamTrack | null, s: MediaStream) {
+ if (this.destroying) {
+ return;
+ }
+ if (this.destroyed) {
+ throw errCode(new Error('cannot replaceTrack after peer is destroyed'), 'ERR_DESTROYED');
+ }
+
+ const submap = this.senderMap.get(oldTrack);
+ const sender = submap ? submap.get(s) : null;
+ if (!sender) {
+ throw errCode(new Error('Cannot replace track that was never added.'), 'ERR_TRACK_NOT_ADDED');
+ }
+ if (newTrack) {
+ this.senderMap.set(newTrack, submap);
+ }
+
+ if (sender.replaceTrack == null) {
+ this.destroy(errCode(new Error('replaceTrack is not supported in this browser'), 'ERR_UNSUPPORTED_REPLACETRACK'));
+ } else {
+ sender.replaceTrack(newTrack);
+ }
+ }
+
+ needsNegotiation() {
+ if (this.batchedNegotiation) {
+ return;
+ } // 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, cb?: (error: Error | null) => void, cbPCClose?: () => void): this {
+ this._destroy(err, cb, cbPCClose);
+ return this;
+ }
+
+ _destroy(err?: Error | null, cb?: (error: Error | null) => void, cbPcClose?: () => 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 = undefined;
+ this.channel.onopen = undefined;
+ this.channel.onclose = undefined;
+ this.channel.onerror = undefined;
+ }
+ if (this.pc) {
+ try {
+ this.pc.close(cbPcClose);
+ } 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 as Error, '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 as Error, '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/connection/websocket_client.ts b/app/products/calls/connection/websocket_client.ts
new file mode 100644
index 0000000000..bac8f91299
--- /dev/null
+++ b/app/products/calls/connection/websocket_client.ts
@@ -0,0 +1,120 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {EventEmitter} from 'events';
+
+import {encode} from '@msgpack/msgpack/dist';
+
+import Calls from '@constants/calls';
+import DatabaseManager from '@database/manager';
+import {getCommonSystemValues} from '@queries/servers/system';
+
+export default class WebSocketClient extends EventEmitter {
+ private readonly serverUrl: string;
+ private readonly wsPath: string;
+ private ws: WebSocket | null = null;
+ private seqNo = 0;
+ private connID = '';
+ private eventPrefix = `custom_${Calls.PluginId}`;
+
+ constructor(serverUrl: string, wsPath: string) {
+ super();
+ this.serverUrl = serverUrl;
+ this.wsPath = wsPath;
+ }
+
+ async initialize() {
+ const database = DatabaseManager.serverDatabases[this.serverUrl]?.database;
+ if (!database) {
+ return;
+ }
+
+ const system = await getCommonSystemValues(database);
+ const connectionUrl = (system.config.WebsocketURL || this.serverUrl) + this.wsPath;
+
+ this.ws = new WebSocket(connectionUrl);
+
+ this.ws.onerror = (err) => {
+ this.emit('error', err);
+ this.ws = null;
+ this.close();
+ };
+
+ this.ws.onclose = () => {
+ this.ws = null;
+ this.close();
+ };
+
+ this.ws.onmessage = ({data}) => {
+ if (!data) {
+ return;
+ }
+ let msg;
+ try {
+ msg = JSON.parse(data);
+ } catch (err) {
+ console.log(err); // eslint-disable-line no-console
+ }
+
+ if (!msg || !msg.event || !msg.data) {
+ return;
+ }
+
+ if (msg.event === 'hello') {
+ this.connID = msg.data.connection_id;
+ this.emit('open');
+ return;
+ } else if (!this.connID) {
+ return;
+ }
+
+ if (msg.data.connID !== this.connID) {
+ return;
+ }
+
+ if (msg.event === this.eventPrefix + '_join') {
+ this.emit('join');
+ }
+
+ if (msg.event === this.eventPrefix + '_error') {
+ this.emit('error', msg.data);
+ }
+
+ if (msg.event === this.eventPrefix + '_signal') {
+ this.emit('message', msg.data);
+ }
+ };
+ }
+
+ send(action: string, data?: Object, binary?: boolean) {
+ const msg = {
+ action: `${this.eventPrefix}_${action}`,
+ seq: this.seqNo++,
+ data,
+ };
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
+ if (binary) {
+ this.ws.send(encode(msg));
+ } else {
+ this.ws.send(JSON.stringify(msg));
+ }
+ }
+ }
+
+ close() {
+ if (this.ws) {
+ this.ws.close();
+ this.ws = null;
+ }
+ this.seqNo = 0;
+ this.connID = '';
+ this.emit('close');
+ }
+
+ state() {
+ if (!this.ws) {
+ return WebSocket.CLOSED;
+ }
+ return this.ws.readyState;
+ }
+}
diff --git a/app/products/calls/connection/websocket_event_handlers.ts b/app/products/calls/connection/websocket_event_handlers.ts
new file mode 100644
index 0000000000..48b3de96c2
--- /dev/null
+++ b/app/products/calls/connection/websocket_event_handlers.ts
@@ -0,0 +1,84 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {DeviceEventEmitter} from 'react-native';
+
+import {
+ callStarted, setCallScreenOff,
+ setCallScreenOn,
+ setChannelEnabled, setRaisedHand,
+ setUserMuted,
+ userJoinedCall,
+ userLeftCall,
+} from '@calls/state';
+import {WebsocketEvents} from '@constants';
+import DatabaseManager from '@database/manager';
+
+export const handleCallUserConnected = (serverUrl: string, msg: WebSocketMessage) => {
+ userJoinedCall(serverUrl, msg.broadcast.channel_id, msg.data.userID);
+};
+
+export const handleCallUserDisconnected = (serverUrl: string, msg: WebSocketMessage) => {
+ userLeftCall(serverUrl, msg.broadcast.channel_id, msg.data.userID);
+};
+
+export const handleCallUserMuted = (serverUrl: string, msg: WebSocketMessage) => {
+ setUserMuted(serverUrl, msg.broadcast.channel_id, msg.data.userID, true);
+};
+
+export const handleCallUserUnmuted = (serverUrl: string, msg: WebSocketMessage) => {
+ setUserMuted(serverUrl, msg.broadcast.channel_id, msg.data.userID, false);
+};
+
+export const handleCallUserVoiceOn = (msg: WebSocketMessage) => {
+ DeviceEventEmitter.emit(WebsocketEvents.CALLS_USER_VOICE_ON, {
+ channelId: msg.broadcast.channel_id,
+ userId: msg.data.userID,
+ });
+};
+
+export const handleCallUserVoiceOff = (msg: WebSocketMessage) => {
+ DeviceEventEmitter.emit(WebsocketEvents.CALLS_USER_VOICE_OFF, {
+ channelId: msg.broadcast.channel_id,
+ userId: msg.data.userID,
+ });
+};
+
+export const handleCallStarted = (serverUrl: string, msg: WebSocketMessage) => {
+ const database = DatabaseManager.serverDatabases[serverUrl]?.database;
+ if (!database) {
+ return;
+ }
+
+ callStarted(serverUrl, {
+ channelId: msg.data.channelID,
+ startTime: msg.data.start_at,
+ threadId: msg.data.thread_id,
+ screenOn: '',
+ participants: {},
+ });
+};
+
+export const handleCallChannelEnabled = (serverUrl: string, msg: WebSocketMessage) => {
+ setChannelEnabled(serverUrl, msg.broadcast.channel_id, true);
+};
+
+export const handleCallChannelDisabled = (serverUrl: string, msg: WebSocketMessage) => {
+ setChannelEnabled(serverUrl, msg.broadcast.channel_id, false);
+};
+
+export const handleCallScreenOn = (serverUrl: string, msg: WebSocketMessage) => {
+ setCallScreenOn(serverUrl, msg.broadcast.channel_id, msg.data.userID);
+};
+
+export const handleCallScreenOff = (serverUrl: string, msg: WebSocketMessage) => {
+ setCallScreenOff(serverUrl, msg.broadcast.channel_id);
+};
+
+export const handleCallUserRaiseHand = (serverUrl: string, msg: WebSocketMessage) => {
+ setRaisedHand(serverUrl, msg.broadcast.channel_id, msg.data.userID, msg.data.raised_hand);
+};
+
+export const handleCallUserUnraiseHand = (serverUrl: string, msg: WebSocketMessage) => {
+ setRaisedHand(serverUrl, msg.broadcast.channel_id, msg.data.userID, msg.data.raised_hand);
+};
diff --git a/app/products/calls/icons/raised_hand_icon.tsx b/app/products/calls/icons/raised_hand_icon.tsx
new file mode 100644
index 0000000000..fcd5d6fda9
--- /dev/null
+++ b/app/products/calls/icons/raised_hand_icon.tsx
@@ -0,0 +1,35 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react';
+import {StyleProp, View, ViewStyle} from 'react-native';
+import Svg, {Path} from 'react-native-svg';
+
+type Props = {
+ className?: string;
+ width?: number;
+ height?: number;
+ fill?: string;
+ style?: StyleProp;
+ svgStyle?: StyleProp;
+}
+
+export default function RaisedHandIcon({width = 25, height = 27, style, svgStyle, ...props}: Props) {
+ return (
+
+
+
+ );
+}
+
diff --git a/app/products/calls/icons/unraised_hand_icon.tsx b/app/products/calls/icons/unraised_hand_icon.tsx
new file mode 100644
index 0000000000..4544bcb682
--- /dev/null
+++ b/app/products/calls/icons/unraised_hand_icon.tsx
@@ -0,0 +1,35 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react';
+import {StyleProp, View, ViewStyle} from 'react-native';
+import Svg, {Path} from 'react-native-svg';
+
+type Props = {
+ className?: string;
+ width?: number;
+ height?: number;
+ fill?: string;
+ style?: StyleProp;
+ svgStyle?: StyleProp;
+}
+
+export default function UnraisedHandIcon({width = 24, height = 24, style, svgStyle, ...props}: Props) {
+ return (
+
+
+
+ );
+}
+
diff --git a/app/products/calls/screens/call_screen/call_screen.tsx b/app/products/calls/screens/call_screen/call_screen.tsx
new file mode 100644
index 0000000000..d219736e3f
--- /dev/null
+++ b/app/products/calls/screens/call_screen/call_screen.tsx
@@ -0,0 +1,545 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React, {useEffect, useCallback, useState} from 'react';
+import {useIntl} from 'react-intl';
+import {
+ View,
+ Text,
+ Platform,
+ Pressable,
+ SafeAreaView,
+ ScrollView,
+ useWindowDimensions,
+ DeviceEventEmitter, Keyboard,
+} from 'react-native';
+import {useSafeAreaInsets} from 'react-native-safe-area-context';
+import {RTCView} from 'react-native-webrtc';
+
+import {appEntry} from '@actions/remote/entry';
+import {
+ leaveCall,
+ muteMyself,
+ raiseHand,
+ setSpeakerphoneOn,
+ unmuteMyself,
+ unraiseHand,
+} from '@calls/actions';
+import CallAvatar from '@calls/components/call_avatar';
+import CallDuration from '@calls/components/call_duration';
+import RaisedHandIcon from '@calls/icons/raised_hand_icon';
+import UnraisedHandIcon from '@calls/icons/unraised_hand_icon';
+import {CallParticipant, CurrentCall, VoiceEventData} from '@calls/types/calls';
+import {sortParticipants} from '@calls/utils';
+import CompassIcon from '@components/compass_icon';
+import SlideUpPanelItem, {ITEM_HEIGHT} from '@components/slide_up_panel_item';
+import {WebsocketEvents} from '@constants';
+import Screens from '@constants/screens';
+import {useTheme} from '@context/theme';
+import DatabaseManager from '@database/manager';
+import {bottomSheet, dismissBottomSheet, goToScreen, popTopScreen} from '@screens/navigation';
+import {bottomSheetSnapPoint} from '@utils/helpers';
+import {mergeNavigationOptions} from '@utils/navigation';
+import {makeStyleSheetFromTheme} from '@utils/theme';
+import {displayUsername} from '@utils/user';
+
+export type Props = {
+ currentCall: CurrentCall | null;
+ participantsDict: Dictionary;
+ teammateNameDisplay: string;
+}
+
+const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
+ 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: {
+ flexDirection: 'row',
+ width: '100%',
+ paddingTop: 10,
+ paddingLeft: 14,
+ paddingRight: 14,
+ ...Platform.select({
+ android: {
+ elevation: 4,
+ },
+ ios: {
+ zIndex: 4,
+ },
+ }),
+ },
+ headerLandscape: {
+ position: 'absolute',
+ top: 0,
+ backgroundColor: 'rgba(0,0,0,0.64)',
+ height: 64,
+ padding: 0,
+ },
+ headerLandscapeNoControls: {
+ top: -1000,
+ },
+ time: {
+ flex: 1,
+ color: theme.sidebarText,
+ margin: 10,
+ padding: 10,
+ },
+ users: {
+ flex: 1,
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ width: '100%',
+ height: '100%',
+ alignContent: 'center',
+ alignItems: 'center',
+ },
+ usersScrollLandscapeScreenOn: {
+ position: 'absolute',
+ height: 0,
+ },
+ user: {
+ flexGrow: 1,
+ flexDirection: 'column',
+ alignItems: 'center',
+ marginTop: 10,
+ marginBottom: 10,
+ marginLeft: 10,
+ marginRight: 10,
+ },
+ userScreenOn: {
+ marginTop: 0,
+ marginBottom: 0,
+ },
+ username: {
+ color: theme.sidebarText,
+ },
+ buttons: {
+ flexDirection: 'column',
+ backgroundColor: 'rgba(255,255,255,0.16)',
+ width: '100%',
+ paddingBottom: 10,
+ ...Platform.select({
+ android: {
+ elevation: 4,
+ },
+ ios: {
+ zIndex: 4,
+ },
+ }),
+ },
+ buttonsLandscape: {
+ height: 128,
+ position: 'absolute',
+ backgroundColor: 'rgba(0,0,0,0.64)',
+ bottom: 0,
+ },
+ buttonsLandscapeNoControls: {
+ bottom: 1000,
+ },
+ button: {
+ flexDirection: 'column',
+ alignItems: 'center',
+ flex: 1,
+ },
+ mute: {
+ flexDirection: 'column',
+ alignItems: 'center',
+ padding: 30,
+ backgroundColor: '#3DB887',
+ borderRadius: 20,
+ marginBottom: 10,
+ marginTop: 20,
+ marginLeft: 10,
+ marginRight: 10,
+ },
+ muteMuted: {
+ backgroundColor: 'rgba(255,255,255,0.16)',
+ },
+ handIcon: {
+ borderRadius: 34,
+ padding: 34,
+ margin: 10,
+ overflow: 'hidden',
+ backgroundColor: 'rgba(255,255,255,0.12)',
+ },
+ handIconRaisedHand: {
+ backgroundColor: 'rgba(255, 188, 66, 0.16)',
+ },
+ handIconSvgStyle: {
+ position: 'relative',
+ top: -12,
+ right: 13,
+ },
+ speakerphoneIcon: {
+ color: theme.sidebarText,
+ backgroundColor: 'rgba(255,255,255,0.12)',
+ },
+ speakerphoneIconOn: {
+ color: 'black',
+ backgroundColor: 'white',
+ },
+ otherButtons: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ alignContent: 'space-between',
+ },
+ collapseIcon: {
+ color: theme.sidebarText,
+ margin: 10,
+ padding: 10,
+ backgroundColor: 'rgba(255,255,255,0.12)',
+ borderRadius: 4,
+ overflow: 'hidden',
+ },
+ muteIcon: {
+ color: theme.sidebarText,
+ },
+ muteIconLandscape: {
+ backgroundColor: '#3DB887',
+ },
+ muteIconLandscapeMuted: {
+ backgroundColor: 'rgba(255,255,255,0.16)',
+ },
+ buttonText: {
+ color: theme.sidebarText,
+ },
+ buttonIcon: {
+ color: theme.sidebarText,
+ backgroundColor: 'rgba(255,255,255,0.12)',
+ borderRadius: 34,
+ padding: 22,
+ width: 68,
+ height: 68,
+ margin: 10,
+ overflow: 'hidden',
+ },
+ hangUpIcon: {
+ backgroundColor: '#D24B4E',
+ },
+ screenShareImage: {
+ flex: 7,
+ width: '100%',
+ height: '100%',
+ alignItems: 'center',
+ },
+ screenShareText: {
+ color: 'white',
+ margin: 3,
+ },
+}));
+
+const CallScreen = ({currentCall, participantsDict, teammateNameDisplay}: Props) => {
+ const intl = useIntl();
+ const theme = useTheme();
+ const insets = useSafeAreaInsets();
+ const {width, height} = useWindowDimensions();
+ const isLandscape = width > height;
+ const [showControlsInLandscape, setShowControlsInLandscape] = useState(false);
+ const myParticipant = currentCall?.participants[currentCall.myUserId];
+ const style = getStyleSheet(theme);
+ const showControls = !isLandscape || showControlsInLandscape;
+
+ useEffect(() => {
+ mergeNavigationOptions('Call', {
+ layout: {
+ componentBackgroundColor: 'black',
+ },
+ topBar: {
+ visible: false,
+ },
+ });
+ }, []);
+
+ const [speaker, setSpeaker] = useState('');
+ useEffect(() => {
+ const handleVoiceOn = (data: VoiceEventData) => {
+ if (data.channelId === currentCall?.channelId) {
+ setSpeaker(data.userId);
+ }
+ };
+ const handleVoiceOff = (data: VoiceEventData) => {
+ if (data.channelId === currentCall?.channelId && ((speaker === data.userId) || !speaker)) {
+ setSpeaker('');
+ }
+ };
+
+ const onVoiceOn = DeviceEventEmitter.addListener(WebsocketEvents.CALLS_USER_VOICE_ON, handleVoiceOn);
+ const onVoiceOff = DeviceEventEmitter.addListener(WebsocketEvents.CALLS_USER_VOICE_OFF, handleVoiceOff);
+ return () => {
+ onVoiceOn.remove();
+ onVoiceOff.remove();
+ };
+ }, []);
+
+ const leaveCallHandler = useCallback(() => {
+ popTopScreen();
+ leaveCall();
+ }, []);
+
+ const muteUnmuteHandler = useCallback(() => {
+ if (myParticipant?.muted) {
+ unmuteMyself();
+ } else {
+ muteMyself();
+ }
+ }, [myParticipant?.muted]);
+
+ const toggleRaiseHand = useCallback(() => {
+ const raisedHand = myParticipant?.raisedHand || 0;
+ if (raisedHand > 0) {
+ unraiseHand();
+ } else {
+ raiseHand();
+ }
+ }, [myParticipant?.raisedHand]);
+
+ const toggleControlsInLandscape = useCallback(() => {
+ setShowControlsInLandscape(!showControlsInLandscape);
+ }, [showControlsInLandscape]);
+
+ const switchToThread = useCallback(async () => {
+ Keyboard.dismiss();
+ await dismissBottomSheet();
+ if (!currentCall) {
+ return;
+ }
+
+ const activeUrl = await DatabaseManager.getActiveServerUrl();
+ if (activeUrl === currentCall.serverUrl) {
+ goToScreen(Screens.THREAD, '', {rootId: currentCall.threadId});
+ return;
+ }
+
+ // TODO: this is a temporary solution until we have a proper cross-team thread view.
+ // https://mattermost.atlassian.net/browse/MM-45752
+ popTopScreen();
+ await DatabaseManager.setActiveServerDatabase(currentCall.serverUrl);
+ await appEntry(currentCall.serverUrl, Date.now());
+ goToScreen(Screens.THREAD, '', {rootId: currentCall.threadId});
+ }, [currentCall?.serverUrl, currentCall?.threadId]);
+
+ const showOtherActions = useCallback(() => {
+ const renderContent = () => {
+ return (
+
+
+
+ );
+ };
+
+ bottomSheet({
+ closeButtonId: 'close-other-actions',
+ renderContent,
+ snapPoints: [bottomSheetSnapPoint(2, ITEM_HEIGHT, insets.bottom), 10],
+ title: intl.formatMessage({id: 'post.options.title', defaultMessage: 'Options'}),
+ theme,
+ });
+ }, [insets, intl, theme]);
+
+ if (!currentCall || !myParticipant) {
+ return null;
+ }
+
+ let screenShareView = null;
+ if (currentCall.screenShareURL && currentCall.screenOn) {
+ screenShareView = (
+
+
+
+ {`You are viewing ${displayUsername(participantsDict[currentCall.screenOn].userModel, teammateNameDisplay)}'s screen`}
+
+
+ );
+ }
+
+ const participants = sortParticipants(teammateNameDisplay, participantsDict, currentCall.screenOn);
+ let usersList = null;
+ if (!currentCall.screenOn || !isLandscape) {
+ usersList = (
+
+
+ {participants.map((user) => {
+ return (
+
+
+
+ {displayUsername(user.userModel, teammateNameDisplay)}
+ {user.id === myParticipant.id && ' (you)'}
+
+
+ );
+ })}
+
+
+ );
+ }
+
+ const HandIcon = myParticipant.raisedHand ? UnraisedHandIcon : RaisedHandIcon;
+
+ return (
+
+
+
+
+ popTopScreen()}>
+
+
+
+ {usersList}
+ {screenShareView}
+
+ {!isLandscape &&
+
+
+ {myParticipant.muted &&
+ {'Unmute'}}
+ {!myParticipant.muted &&
+ {'Mute'}}
+ }
+
+
+
+ {'Leave'}
+
+ setSpeakerphoneOn(!currentCall?.speakerphoneOn)}
+ >
+
+ {'Speaker'}
+
+
+
+
+ {myParticipant.raisedHand ? 'Lower hand' : 'Raise hand'}
+
+
+
+
+ {'More'}
+
+ {isLandscape &&
+
+
+ {myParticipant.muted &&
+ {'Unmute'}}
+ {!myParticipant.muted &&
+ {'Mute'}}
+ }
+
+
+
+
+ );
+};
+
+export default CallScreen;
diff --git a/app/products/calls/screens/call_screen/index.ts b/app/products/calls/screens/call_screen/index.ts
new file mode 100644
index 0000000000..22d23d5edf
--- /dev/null
+++ b/app/products/calls/screens/call_screen/index.ts
@@ -0,0 +1,43 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import withObservables from '@nozbe/with-observables';
+import {combineLatest, of as of$} from 'rxjs';
+import {switchMap} from 'rxjs/operators';
+
+import CallScreen from '@calls/screens/call_screen/call_screen';
+import {observeCurrentCall} from '@calls/state';
+import {CallParticipant} from '@calls/types/calls';
+import DatabaseManager from '@database/manager';
+import {observeTeammateNameDisplay, observeUsersById} from '@queries/servers/user';
+
+const enhanced = withObservables([], () => {
+ const currentCall = observeCurrentCall();
+ const database = currentCall.pipe(
+ switchMap((call) => of$(call ? call.serverUrl : '')),
+ switchMap((url) => of$(DatabaseManager.serverDatabases[url]?.database)),
+ );
+ const participantsDict = combineLatest([database, currentCall]).pipe(
+ switchMap(([db, call]) => (db && call ? observeUsersById(db, Object.keys(call.participants)) : of$([])).pipe(
+ // eslint-disable-next-line max-nested-callbacks
+ switchMap((ps) => of$(ps.reduce((accum, cur) => {
+ accum[cur.id] = {
+ ...call!.participants[cur.id],
+ userModel: cur,
+ };
+ return accum;
+ }, {} as Dictionary))),
+ )),
+ );
+ const teammateNameDisplay = database.pipe(
+ switchMap((db) => (db ? observeTeammateNameDisplay(db) : of$(''))),
+ );
+
+ return {
+ currentCall,
+ participantsDict,
+ teammateNameDisplay,
+ };
+});
+
+export default enhanced(CallScreen);
diff --git a/app/products/calls/state/actions.test.ts b/app/products/calls/state/actions.test.ts
new file mode 100644
index 0000000000..c2cf475889
--- /dev/null
+++ b/app/products/calls/state/actions.test.ts
@@ -0,0 +1,603 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import assert from 'assert';
+
+import {act, renderHook} from '@testing-library/react-hooks';
+
+import {
+ setCalls,
+ userJoinedCall,
+ userLeftCall,
+ callStarted,
+ callFinished,
+ setUserMuted,
+ setCallScreenOn,
+ setCallScreenOff,
+ setRaisedHand,
+ myselfJoinedCall,
+ myselfLeftCall,
+ setChannelEnabled,
+ setScreenShareURL,
+ setSpeakerPhone, setConfig, setPluginEnabled,
+} from '@calls/state/actions';
+import {exportedForInternalUse as callsConfigTesting} from '@calls/state/calls_config';
+import {exportedForInternalUse as callsStateTesting} from '@calls/state/calls_state';
+import {exportedForInternalUse as channelsWithCallsTesting} from '@calls/state/channels_with_calls';
+import {exportedForInternalUse as currentCallTesting} from '@calls/state/current_call';
+
+import {CallsState, CurrentCall, DefaultCallsConfig, DefaultCallsState} from '../types/calls';
+
+const {useCallsConfig} = callsConfigTesting;
+const {setCallsState, useCallsState} = callsStateTesting;
+const {setChannelsWithCalls, useChannelsWithCalls} = channelsWithCallsTesting;
+const {setCurrentCall, useCurrentCall} = currentCallTesting;
+
+const call1 = {
+ participants: {
+ 'user-1': {id: 'user-1', muted: false, raisedHand: 0},
+ 'user-2': {id: 'user-2', muted: true, raisedHand: 0},
+ },
+ channelId: 'channel-1',
+ startTime: 123,
+ screenOn: '',
+ threadId: 'thread-1',
+};
+const call2 = {
+ participants: {
+ 'user-3': {id: 'user-3', muted: false, raisedHand: 0},
+ 'user-4': {id: 'user-4', muted: true, raisedHand: 0},
+ },
+ channelId: 'channel-2',
+ startTime: 123,
+ screenOn: '',
+ threadId: 'thread-2',
+};
+const call3 = {
+ participants: {
+ 'user-5': {id: 'user-5', muted: false, raisedHand: 0},
+ 'user-6': {id: 'user-6', muted: true, raisedHand: 0},
+ },
+ channelId: 'channel-3',
+ startTime: 123,
+ screenOn: '',
+ threadId: 'thread-3',
+};
+
+describe('useCallsState', () => {
+ beforeAll(() => {
+ // create subjects
+ const {result} = renderHook(() => {
+ return [useCallsState('server1'), useChannelsWithCalls('server1'), useCurrentCall()];
+ });
+
+ assert.deepEqual(result.current[0], DefaultCallsState);
+ assert.deepEqual(result.current[1], {});
+ assert.deepEqual(result.current[2], null);
+ });
+
+ beforeEach(() => {
+ // reset to default state for each test
+ act(() => {
+ setCallsState('server1', DefaultCallsState);
+ setChannelsWithCalls('server1', {});
+ setCurrentCall(null);
+ });
+ });
+
+ it('default state', () => {
+ const {result} = renderHook(() => {
+ return [useCallsState('server1'), useChannelsWithCalls('server1')];
+ });
+ assert.deepEqual(result.current[0], DefaultCallsState);
+ assert.deepEqual(result.current[1], {});
+ });
+
+ it('setCalls, two callsState hooks, channelsWithCalls hook, ', () => {
+ const initialCallsState = {
+ ...DefaultCallsState,
+ calls: {'channel-1': call1},
+ enabled: {'channel-1': true},
+ };
+ const initialChannelsWithCallsState = {
+ 'channel-1': true,
+ };
+ const test = {
+ calls: {'channel-1': call2, 'channel-2': call3},
+ enabled: {'channel-2': true},
+ };
+ const expectedCallsState = {
+ ...initialCallsState,
+ serverUrl: 'server1',
+ myUserId: 'myId',
+ calls: {'channel-1': call2, 'channel-2': call3},
+ enabled: {'channel-2': true},
+ };
+ const expectedChannelsWithCallsState = {
+ ...initialChannelsWithCallsState,
+ 'channel-2': true,
+ };
+
+ // setup
+ const {result} = renderHook(() => {
+ return [useCallsState('server1'), useCallsState('server1'), useChannelsWithCalls('server1')];
+ });
+ act(() => {
+ setCallsState('server1', initialCallsState);
+ setChannelsWithCalls('server1', initialChannelsWithCallsState);
+ });
+ assert.deepEqual(result.current[0], initialCallsState);
+ assert.deepEqual(result.current[1], initialCallsState);
+ assert.deepEqual(result.current[2], initialChannelsWithCallsState);
+
+ // test
+ act(() => setCalls('server1', 'myId', test.calls, test.enabled));
+ assert.deepEqual(result.current[0], expectedCallsState);
+ assert.deepEqual(result.current[1], expectedCallsState);
+ assert.deepEqual(result.current[2], expectedChannelsWithCallsState);
+ });
+
+ it('joinedCall', () => {
+ const initialCallsState = {
+ ...DefaultCallsState,
+ calls: {'channel-1': call1},
+ };
+ const initialChannelsWithCallsState = {
+ 'channel-1': true,
+ };
+ const initialCurrentCallState = {
+ serverUrl: 'server1',
+ myUserId: 'myUserId',
+ ...call1,
+ screenShareURL: '',
+ speakerphoneOn: false,
+ } as CurrentCall;
+ const expectedCallsState = {
+ 'channel-1': {
+ participants: {
+ 'user-1': {id: 'user-1', muted: false, raisedHand: 0},
+ 'user-2': {id: 'user-2', muted: true, raisedHand: 0},
+ 'user-3': {id: 'user-3', muted: true, raisedHand: 0},
+ },
+ channelId: 'channel-1',
+ startTime: 123,
+ screenOn: '',
+ threadId: 'thread-1',
+ },
+ };
+ const expectedChannelsWithCallsState = initialChannelsWithCallsState;
+ const expectedCurrentCallState = {
+ ...initialCurrentCallState,
+ ...expectedCallsState['channel-1'],
+ } as CurrentCall;
+
+ // setup
+ const {result} = renderHook(() => {
+ return [useCallsState('server1'), useChannelsWithCalls('server1'), useCurrentCall()];
+ });
+ act(() => {
+ setCallsState('server1', initialCallsState);
+ setChannelsWithCalls('server1', initialChannelsWithCallsState);
+ setCurrentCall(initialCurrentCallState);
+ });
+ assert.deepEqual(result.current[0], initialCallsState);
+ assert.deepEqual(result.current[1], initialChannelsWithCallsState);
+ assert.deepEqual(result.current[2], initialCurrentCallState);
+
+ // test
+ act(() => userJoinedCall('server1', 'channel-1', 'user-3'));
+ assert.deepEqual((result.current[0] as CallsState).calls, expectedCallsState);
+ assert.deepEqual(result.current[1], expectedChannelsWithCallsState);
+ assert.deepEqual(result.current[2], expectedCurrentCallState);
+ act(() => userJoinedCall('server1', 'invalid-channel', 'user-1'));
+ assert.deepEqual((result.current[0] as CallsState).calls, expectedCallsState);
+ assert.deepEqual(result.current[1], expectedChannelsWithCallsState);
+ assert.deepEqual(result.current[2], expectedCurrentCallState);
+ });
+
+ it('leftCall', () => {
+ const initialCallsState = {
+ ...DefaultCallsState,
+ calls: {'channel-1': call1},
+ };
+ const initialChannelsWithCallsState = {
+ 'channel-1': true,
+ };
+ const initialCurrentCallState = {
+ serverUrl: 'server1',
+ myUserId: 'myUserId',
+ ...call1,
+ screenShareURL: '',
+ speakerphoneOn: false,
+ } as CurrentCall;
+ const expectedCallsState = {
+ 'channel-1': {
+ participants: {
+ 'user-2': {id: 'user-2', muted: true, raisedHand: 0},
+ },
+ channelId: 'channel-1',
+ startTime: 123,
+ screenOn: '',
+ threadId: 'thread-1',
+ },
+ };
+ const expectedChannelsWithCallsState = initialChannelsWithCallsState;
+ const expectedCurrentCallState = {
+ ...initialCurrentCallState,
+ ...expectedCallsState['channel-1'],
+ } as CurrentCall;
+
+ // setup
+ const {result} = renderHook(() => {
+ return [useCallsState('server1'), useChannelsWithCalls('server1'), useCurrentCall()];
+ });
+ act(() => {
+ setCallsState('server1', initialCallsState);
+ setChannelsWithCalls('server1', initialChannelsWithCallsState);
+ setCurrentCall(initialCurrentCallState);
+ });
+ assert.deepEqual(result.current[0], initialCallsState);
+ assert.deepEqual(result.current[1], initialChannelsWithCallsState);
+ assert.deepEqual(result.current[2], initialCurrentCallState);
+
+ // test
+ act(() => userLeftCall('server1', 'channel-1', 'user-1'));
+ assert.deepEqual((result.current[0] as CallsState).calls, expectedCallsState);
+ assert.deepEqual(result.current[1], expectedChannelsWithCallsState);
+ assert.deepEqual(result.current[2], expectedCurrentCallState);
+ act(() => userLeftCall('server1', 'invalid-channel', 'user-2'));
+ assert.deepEqual((result.current[0] as CallsState).calls, expectedCallsState);
+ assert.deepEqual(result.current[1], expectedChannelsWithCallsState);
+ assert.deepEqual(result.current[2], expectedCurrentCallState);
+ });
+
+ it('callStarted', () => {
+ // setup
+ const {result} = renderHook(() => {
+ return [useCallsState('server1'), useChannelsWithCalls('server1'), useCurrentCall()];
+ });
+ assert.deepEqual(result.current[0], DefaultCallsState);
+ assert.deepEqual(result.current[1], {});
+ assert.deepEqual(result.current[2], null);
+
+ // test
+ act(() => callStarted('server1', call1));
+ assert.deepEqual((result.current[0] as CallsState).calls, {'channel-1': call1});
+ assert.deepEqual(result.current[1], {'channel-1': true});
+ assert.deepEqual(result.current[2], null);
+ });
+
+ // TODO: needs to be changed to callEnd when that ws event is implemented
+ it('callFinished', () => {
+ const initialCallsState = {
+ ...DefaultCallsState,
+ calls: {'channel-1': call1, 'channel-2': call2},
+ };
+ const initialChannelsWithCallsState = {'channel-1': true, 'channel-2': true};
+ const expectedCallsState = {
+ ...DefaultCallsState,
+ calls: {'channel-2': call2},
+ };
+ const expectedChannelsWithCallsState = {'channel-2': true};
+
+ // setup
+ const {result} = renderHook(() => {
+ return [useCallsState('server1'), useChannelsWithCalls('server1'), useCurrentCall()];
+ });
+ act(() => {
+ setCallsState('server1', initialCallsState);
+ setChannelsWithCalls('server1', initialChannelsWithCallsState);
+ });
+ assert.deepEqual(result.current[0], initialCallsState);
+ assert.deepEqual(result.current[1], initialChannelsWithCallsState);
+ assert.deepEqual(result.current[2], null);
+
+ // test
+ act(() => callFinished('server1', 'channel-1'));
+ assert.deepEqual(result.current[0], expectedCallsState);
+ assert.deepEqual(result.current[1], expectedChannelsWithCallsState);
+ assert.deepEqual(result.current[2], null);
+ });
+
+ it('setUserMuted', () => {
+ const initialCallsState = {
+ ...DefaultCallsState,
+ calls: {'channel-1': call1, 'channel-2': call2},
+ };
+ const initialChannelsWithCallsState = {'channel-1': true, 'channel-2': true};
+ const initialCurrentCallState = {
+ serverUrl: 'server1',
+ myUserId: 'myUserId',
+ ...call1,
+ screenShareURL: '',
+ speakerphoneOn: false,
+ } as CurrentCall;
+
+ // setup
+ const {result} = renderHook(() => {
+ return [useCallsState('server1'), useChannelsWithCalls('server1'), useCurrentCall()];
+ });
+ act(() => {
+ setCallsState('server1', initialCallsState);
+ setChannelsWithCalls('server1', initialChannelsWithCallsState);
+ setCurrentCall(initialCurrentCallState);
+ });
+ assert.deepEqual(result.current[0], initialCallsState);
+ assert.deepEqual(result.current[1], initialChannelsWithCallsState);
+ assert.deepEqual(result.current[2], initialCurrentCallState);
+
+ // test
+ act(() => setUserMuted('server1', 'channel-1', 'user-1', true));
+ assert.deepEqual((result.current[0] as CallsState).calls['channel-1'].participants['user-1'].muted, true);
+ assert.deepEqual((result.current[2] as CurrentCall | null)?.participants['user-1'].muted, true);
+ act(() => {
+ setUserMuted('server1', 'channel-1', 'user-1', false);
+ setUserMuted('server1', 'channel-1', 'user-2', false);
+ });
+ assert.deepEqual((result.current[0] as CallsState).calls['channel-1'].participants['user-1'].muted, false);
+ assert.deepEqual((result.current[0] as CallsState).calls['channel-1'].participants['user-2'].muted, false);
+ assert.deepEqual((result.current[2] as CurrentCall | null)?.participants['user-1'].muted, false);
+ assert.deepEqual((result.current[2] as CurrentCall | null)?.participants['user-2'].muted, false);
+ act(() => setUserMuted('server1', 'channel-1', 'user-2', true));
+ assert.deepEqual((result.current[0] as CallsState).calls['channel-1'].participants['user-2'].muted, true);
+ assert.deepEqual((result.current[2] as CurrentCall | null)?.participants['user-2'].muted, true);
+ assert.deepEqual(result.current[0], initialCallsState);
+ act(() => setUserMuted('server1', 'invalid-channel', 'user-1', true));
+ assert.deepEqual(result.current[0], initialCallsState);
+ });
+
+ it('setCallScreenOn/Off', () => {
+ const initialCallsState = {
+ ...DefaultCallsState,
+ calls: {'channel-1': call1, 'channel-2': call2},
+ };
+ const initialChannelsWithCallsState = {'channel-1': true, 'channel-2': true};
+ const initialCurrentCallState = {
+ serverUrl: 'server1',
+ myUserId: 'myUserId',
+ ...call1,
+ screenShareURL: '',
+ speakerphoneOn: false,
+ } as CurrentCall;
+
+ // setup
+ const {result} = renderHook(() => {
+ return [useCallsState('server1'), useChannelsWithCalls('server1'), useCurrentCall()];
+ });
+ act(() => {
+ setCallsState('server1', initialCallsState);
+ setChannelsWithCalls('server1', initialChannelsWithCallsState);
+ setCurrentCall(initialCurrentCallState);
+ });
+ assert.deepEqual(result.current[0], initialCallsState);
+ assert.deepEqual(result.current[1], initialChannelsWithCallsState);
+ assert.deepEqual(result.current[2], initialCurrentCallState);
+
+ // test
+ act(() => setCallScreenOn('server1', 'channel-1', 'user-1'));
+ assert.deepEqual((result.current[0] as CallsState).calls['channel-1'].screenOn, 'user-1');
+ assert.deepEqual(result.current[1], initialChannelsWithCallsState);
+ assert.deepEqual((result.current[2] as CurrentCall).screenOn, 'user-1');
+ act(() => setCallScreenOff('server1', 'channel-1'));
+ assert.deepEqual(result.current[0], initialCallsState);
+ assert.deepEqual(result.current[1], initialChannelsWithCallsState);
+ assert.deepEqual(result.current[2], initialCurrentCallState);
+ act(() => setCallScreenOn('server1', 'channel-1', 'invalid-user'));
+ assert.deepEqual(result.current[0], initialCallsState);
+ assert.deepEqual(result.current[1], initialChannelsWithCallsState);
+ assert.deepEqual(result.current[2], initialCurrentCallState);
+ act(() => setCallScreenOff('server1', 'invalid-channel'));
+ assert.deepEqual(result.current[0], initialCallsState);
+ assert.deepEqual(result.current[1], initialChannelsWithCallsState);
+ assert.deepEqual(result.current[2], initialCurrentCallState);
+ });
+
+ it('setRaisedHand', () => {
+ const initialCallsState = {
+ ...DefaultCallsState,
+ calls: {'channel-1': call1},
+ };
+ const expectedCalls = {
+ 'channel-1': {
+ participants: {
+ 'user-1': {id: 'user-1', muted: false, raisedHand: 0},
+ 'user-2': {id: 'user-2', muted: true, raisedHand: 345},
+ },
+ channelId: 'channel-1',
+ startTime: 123,
+ screenOn: false,
+ threadId: 'thread-1',
+ },
+ };
+ const initialCurrentCallState = {
+ serverUrl: 'server1',
+ myUserId: 'myUserId',
+ ...call1,
+ screenShareURL: '',
+ speakerphoneOn: false,
+ } as CurrentCall;
+ const expectedCurrentCallState = {
+ ...initialCurrentCallState,
+ ...expectedCalls['channel-1'],
+ };
+
+ // setup
+ const {result} = renderHook(() => {
+ return [useCallsState('server1'), useCurrentCall()];
+ });
+ act(() => {
+ setCallsState('server1', initialCallsState);
+ setCurrentCall(initialCurrentCallState);
+ });
+ assert.deepEqual(result.current[0], initialCallsState);
+ assert.deepEqual(result.current[1], initialCurrentCallState);
+
+ // test
+ act(() => setRaisedHand('server1', 'channel-1', 'user-2', 345));
+ assert.deepEqual((result.current[0] as CallsState).calls, expectedCalls);
+ assert.deepEqual((result.current[1] as CurrentCall | null), expectedCurrentCallState);
+
+ act(() => setRaisedHand('server1', 'invalid-channel', 'user-1', 345));
+ assert.deepEqual((result.current[0] as CallsState).calls, expectedCalls);
+ assert.deepEqual((result.current[1] as CurrentCall | null), expectedCurrentCallState);
+
+ // unraise hand:
+ act(() => setRaisedHand('server1', 'channel-1', 'user-2', 0));
+ assert.deepEqual(result.current[0], initialCallsState);
+ assert.deepEqual(result.current[1], initialCurrentCallState);
+ });
+
+ it('myselfJoinedCall / LeftCall', () => {
+ const initialCallsState = {
+ ...DefaultCallsState,
+ myUserId: 'myUserId',
+ calls: {'channel-1': call1, 'channel-2': call2},
+ };
+ const expectedCurrentCallState = {
+ serverUrl: 'server1',
+ myUserId: 'myUserId',
+ screenShareURL: '',
+ speakerphoneOn: false,
+ ...call1,
+ } as CurrentCall;
+
+ // setup
+ const {result} = renderHook(() => {
+ return [useCallsState('server1'), useCurrentCall()];
+ });
+ act(() => setCallsState('server1', initialCallsState));
+ assert.deepEqual(result.current[0], initialCallsState);
+ assert.deepEqual(result.current[1], null);
+
+ // test
+ act(() => myselfJoinedCall('server1', 'channel-1'));
+ assert.deepEqual(result.current[0], initialCallsState);
+ assert.deepEqual(result.current[1], expectedCurrentCallState);
+ act(() => myselfLeftCall());
+ assert.deepEqual(result.current[0], initialCallsState);
+ assert.deepEqual(result.current[1], null);
+ });
+
+ it('setChannelEnabled', () => {
+ const initialState = {
+ ...DefaultCallsState,
+ enabled: {'channel-1': true, 'channel-2': false},
+ };
+
+ // setup
+ const {result} = renderHook(() => useCallsState('server1'));
+ act(() => setCallsState('server1', initialState));
+ assert.deepEqual(result.current, initialState);
+
+ // test setCalls affects enabled:
+ act(() => setCalls('server1', 'myUserId', {}, {'channel-1': true}));
+ assert.deepEqual(result.current.enabled, {'channel-1': true});
+
+ // re-setup:
+ act(() => setCallsState('server1', initialState));
+ assert.deepEqual(result.current, initialState);
+
+ // test setChannelEnabled affects enabled:
+ act(() => setChannelEnabled('server1', 'channel-3', true));
+ assert.deepEqual(result.current.enabled, {'channel-1': true, 'channel-2': false, 'channel-3': true});
+ act(() => setChannelEnabled('server1', 'channel-3', false));
+ assert.deepEqual(result.current.enabled, {
+ 'channel-1': true,
+ 'channel-2': false,
+ 'channel-3': false,
+ });
+ act(() => setChannelEnabled('server1', 'channel-1', true));
+ assert.deepEqual(result.current.enabled, {
+ 'channel-1': true,
+ 'channel-2': false,
+ 'channel-3': false,
+ });
+ act(() => setChannelEnabled('server1', 'channel-1', false));
+ assert.deepEqual(result.current.enabled, {
+ 'channel-1': false,
+ 'channel-2': false,
+ 'channel-3': false,
+ });
+ });
+
+ it('setScreenShareURL', () => {
+ const initialCallsState = {
+ ...DefaultCallsState,
+ myUserId: 'myUserId',
+ calls: {'channel-1': call1, 'channel-2': call2},
+ };
+
+ // setup
+ const {result} = renderHook(() => {
+ return [useCallsState('server1'), useCurrentCall()];
+ });
+ act(() => setCallsState('server1', initialCallsState));
+ assert.deepEqual(result.current[0], initialCallsState);
+ assert.deepEqual(result.current[1], null);
+
+ // test joining a call and setting url:
+ act(() => myselfJoinedCall('server1', 'channel-1'));
+ assert.deepEqual((result.current[1] as CurrentCall | null)?.screenShareURL, '');
+ act(() => setScreenShareURL('testUrl'));
+ assert.deepEqual((result.current[1] as CurrentCall | null)?.screenShareURL, 'testUrl');
+
+ act(() => {
+ myselfLeftCall();
+ setScreenShareURL('test');
+ });
+ assert.deepEqual(result.current[0], initialCallsState);
+ assert.deepEqual(result.current[1], null);
+ });
+
+ it('setSpeakerPhoneOn', () => {
+ const initialCallsState = {
+ ...DefaultCallsState,
+ myUserId: 'myUserId',
+ calls: {'channel-1': call1, 'channel-2': call2},
+ };
+
+ // setup
+ const {result} = renderHook(() => {
+ return [useCallsState('server1'), useCurrentCall()];
+ });
+ act(() => setCallsState('server1', initialCallsState));
+ assert.deepEqual(result.current[0], initialCallsState);
+ assert.deepEqual(result.current[1], null);
+
+ // test
+ act(() => myselfJoinedCall('server1', 'channel-1'));
+ assert.deepEqual((result.current[1] as CurrentCall | null)?.speakerphoneOn, false);
+ act(() => setSpeakerPhone(true));
+ assert.deepEqual((result.current[1] as CurrentCall | null)?.speakerphoneOn, true);
+ act(() => setSpeakerPhone(false));
+ assert.deepEqual((result.current[1] as CurrentCall | null)?.speakerphoneOn, false);
+ assert.deepEqual(result.current[0], initialCallsState);
+ act(() => {
+ myselfLeftCall();
+ setSpeakerPhone(true);
+ });
+ assert.deepEqual(result.current[0], initialCallsState);
+ assert.deepEqual(result.current[1], null);
+ });
+
+ it('config', () => {
+ const newConfig = {
+ ICEServers: ['google.com'],
+ AllowEnableCalls: true,
+ DefaultEnabled: true,
+ last_retrieved_at: 123,
+ };
+
+ // setup
+ const {result} = renderHook(() => useCallsConfig('server1'));
+ assert.deepEqual(result.current, DefaultCallsConfig);
+
+ // test
+ act(() => setConfig('server1', newConfig));
+ assert.deepEqual(result.current, {...newConfig, pluginEnabled: false});
+ act(() => setPluginEnabled('server1', true));
+ assert.deepEqual(result.current, {...newConfig, pluginEnabled: true});
+ act(() => setPluginEnabled('server1', false));
+ assert.deepEqual(result.current, {...newConfig, pluginEnabled: false});
+ });
+});
diff --git a/app/products/calls/state/actions.ts b/app/products/calls/state/actions.ts
new file mode 100644
index 0000000000..22706a81c7
--- /dev/null
+++ b/app/products/calls/state/actions.ts
@@ -0,0 +1,276 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {getCallsConfig, exportedForInternalUse as callsConfigInternal} from '@calls/state/calls_config';
+import {getCallsState, exportedForInternalUse as callsStateInternal} from '@calls/state/calls_state';
+import {getChannelsWithCalls, exportedForInternalUse as channelsWithCallsInternal} from '@calls/state/channels_with_calls';
+import {getCurrentCall, exportedForInternalUse as currentCallInternal} from '@calls/state/current_call';
+import {Call, ChannelsWithCalls, ServerConfig} from '@calls/types/calls';
+
+const {setCallsConfig} = callsConfigInternal;
+const {setCallsState} = callsStateInternal;
+const {setChannelsWithCalls} = channelsWithCallsInternal;
+const {setCurrentCall} = currentCallInternal;
+
+export const setCalls = (serverUrl: string, myUserId: string, calls: Dictionary, enabled: Dictionary) => {
+ const channelsWithCalls = Object.keys(calls).reduce(
+ (accum, next) => {
+ accum[next] = true;
+ return accum;
+ }, {} as ChannelsWithCalls);
+ setChannelsWithCalls(serverUrl, channelsWithCalls);
+
+ setCallsState(serverUrl, {serverUrl, myUserId, calls, enabled});
+};
+
+export const userJoinedCall = (serverUrl: string, channelId: string, userId: string) => {
+ const callsState = getCallsState(serverUrl);
+ if (!callsState.calls[channelId]) {
+ return;
+ }
+
+ const nextCall = {
+ ...callsState.calls[channelId],
+ participants: {...callsState.calls[channelId].participants},
+ };
+ nextCall.participants[userId] = {
+ id: userId,
+ muted: true,
+ raisedHand: 0,
+ };
+ const nextCalls = {...callsState.calls, [channelId]: nextCall};
+
+ setCallsState(serverUrl, {...callsState, calls: nextCalls});
+
+ // Did the user join the current call? If so, update that too.
+ const currentCall = getCurrentCall();
+ if (!currentCall || currentCall.channelId !== channelId) {
+ return;
+ }
+
+ const nextCall2 = {
+ ...currentCall,
+ participants: {...currentCall.participants, [userId]: nextCall.participants[userId]},
+ };
+ setCurrentCall(nextCall2);
+};
+
+export const userLeftCall = (serverUrl: string, channelId: string, userId: string) => {
+ const callsState = getCallsState(serverUrl);
+ if (!callsState.calls[channelId]?.participants[userId]) {
+ return;
+ }
+
+ const nextCall = {
+ ...callsState.calls[channelId],
+ participants: {...callsState.calls[channelId].participants},
+ };
+ delete nextCall.participants[userId];
+ const nextCalls = {...callsState.calls};
+ if (Object.keys(nextCall.participants).length === 0) {
+ delete nextCalls[channelId];
+
+ const channelsWithCalls = getChannelsWithCalls(serverUrl);
+ const nextChannelsWithCalls = {...channelsWithCalls};
+ delete nextChannelsWithCalls[channelId];
+ setChannelsWithCalls(serverUrl, nextChannelsWithCalls);
+ } else {
+ nextCalls[channelId] = nextCall;
+ }
+
+ setCallsState(serverUrl, {...callsState, calls: nextCalls});
+
+ // Did the user leave the current call? If so, update that too.
+ const currentCall = getCurrentCall();
+ if (!currentCall || currentCall.channelId !== channelId) {
+ return;
+ }
+
+ const nextCall2 = {
+ ...currentCall,
+ participants: {...currentCall.participants},
+ };
+ delete nextCall2.participants[userId];
+ setCurrentCall(nextCall2);
+};
+
+export const myselfJoinedCall = (serverUrl: string, channelId: string) => {
+ const callsState = getCallsState(serverUrl);
+ setCurrentCall({
+ ...callsState.calls[channelId],
+ serverUrl,
+ myUserId: callsState.myUserId,
+ screenShareURL: '',
+ speakerphoneOn: false,
+ });
+};
+
+export const myselfLeftCall = () => {
+ setCurrentCall(null);
+};
+
+export const callStarted = (serverUrl: string, call: Call) => {
+ const callsState = getCallsState(serverUrl);
+ const nextCalls = {...callsState.calls};
+ nextCalls[call.channelId] = call;
+ setCallsState(serverUrl, {...callsState, calls: nextCalls});
+
+ const nextChannelsWithCalls = {...getChannelsWithCalls(serverUrl), [call.channelId]: true};
+ setChannelsWithCalls(serverUrl, nextChannelsWithCalls);
+};
+
+// TODO: should be called callEnded to match the ws event. Will fix when callEnded is implemented.
+export const callFinished = (serverUrl: string, channelId: string) => {
+ const callsState = getCallsState(serverUrl);
+ const nextCalls = {...callsState.calls};
+ delete nextCalls[channelId];
+ setCallsState(serverUrl, {...callsState, calls: nextCalls});
+
+ const channelsWithCalls = getChannelsWithCalls(serverUrl);
+ const nextChannelsWithCalls = {...channelsWithCalls};
+ delete nextChannelsWithCalls[channelId];
+ setChannelsWithCalls(serverUrl, nextChannelsWithCalls);
+};
+
+export const setUserMuted = (serverUrl: string, channelId: string, userId: string, muted: boolean) => {
+ const callsState = getCallsState(serverUrl);
+ if (!callsState.calls[channelId] || !callsState.calls[channelId].participants[userId]) {
+ return;
+ }
+
+ const nextUser = {...callsState.calls[channelId].participants[userId], muted};
+ const nextCall = {
+ ...callsState.calls[channelId],
+ participants: {...callsState.calls[channelId].participants},
+ };
+ nextCall.participants[userId] = nextUser;
+ const nextCalls = {...callsState.calls};
+ nextCalls[channelId] = nextCall;
+ setCallsState(serverUrl, {...callsState, calls: nextCalls});
+
+ // Was it the current call? If so, update that too.
+ const currentCall = getCurrentCall();
+ if (!currentCall || currentCall.channelId !== channelId) {
+ return;
+ }
+
+ const nextCurrentCall = {
+ ...currentCall,
+ participants: {
+ ...currentCall.participants,
+ [userId]: {...currentCall.participants[userId], muted},
+ },
+ };
+ setCurrentCall(nextCurrentCall);
+};
+
+export const setRaisedHand = (serverUrl: string, channelId: string, userId: string, timestamp: number) => {
+ const callsState = getCallsState(serverUrl);
+ if (!callsState.calls[channelId] || !callsState.calls[channelId].participants[userId]) {
+ return;
+ }
+
+ const nextUser = {...callsState.calls[channelId].participants[userId], raisedHand: timestamp};
+ const nextCall = {
+ ...callsState.calls[channelId],
+ participants: {...callsState.calls[channelId].participants},
+ };
+ nextCall.participants[userId] = nextUser;
+ const nextCalls = {...callsState.calls};
+ nextCalls[channelId] = nextCall;
+ setCallsState(serverUrl, {...callsState, calls: nextCalls});
+
+ // Was it the current call? If so, update that too.
+ const currentCall = getCurrentCall();
+ if (!currentCall || currentCall.channelId !== channelId) {
+ return;
+ }
+
+ const nextCurrentCall = {
+ ...currentCall,
+ participants: {
+ ...currentCall.participants,
+ [userId]: {...currentCall.participants[userId], raisedHand: timestamp},
+ },
+ };
+ setCurrentCall(nextCurrentCall);
+};
+
+export const setCallScreenOn = (serverUrl: string, channelId: string, userId: string) => {
+ const callsState = getCallsState(serverUrl);
+ if (!callsState.calls[channelId] || !callsState.calls[channelId].participants[userId]) {
+ return;
+ }
+
+ const nextCall = {...callsState.calls[channelId], screenOn: userId};
+ const nextCalls = {...callsState.calls};
+ nextCalls[channelId] = nextCall;
+ setCallsState(serverUrl, {...callsState, calls: nextCalls});
+
+ // Was it the current call? If so, update that too.
+ const currentCall = getCurrentCall();
+ if (!currentCall || currentCall.channelId !== channelId) {
+ return;
+ }
+
+ const nextCurrentCall = {
+ ...currentCall,
+ screenOn: userId,
+ };
+ setCurrentCall(nextCurrentCall);
+};
+
+export const setCallScreenOff = (serverUrl: string, channelId: string) => {
+ const callsState = getCallsState(serverUrl);
+ if (!callsState.calls[channelId]) {
+ return;
+ }
+
+ const nextCall = {...callsState.calls[channelId], screenOn: ''};
+ const nextCalls = {...callsState.calls};
+ nextCalls[channelId] = nextCall;
+ setCallsState(serverUrl, {...callsState, calls: nextCalls});
+
+ // Was it the current call? If so, update that too.
+ const currentCall = getCurrentCall();
+ if (!currentCall || currentCall.channelId !== channelId) {
+ return;
+ }
+
+ const nextCurrentCall = {
+ ...currentCall,
+ screenOn: '',
+ };
+ setCurrentCall(nextCurrentCall);
+};
+
+export const setChannelEnabled = (serverUrl: string, channelId: string, enabled: boolean) => {
+ const callsState = getCallsState(serverUrl);
+ const nextEnabled = {...callsState.enabled};
+ nextEnabled[channelId] = enabled;
+ setCallsState(serverUrl, {...callsState, enabled: nextEnabled});
+};
+
+export const setScreenShareURL = (url: string) => {
+ const call = getCurrentCall();
+ if (call) {
+ setCurrentCall({...call, screenShareURL: url});
+ }
+};
+
+export const setSpeakerPhone = (speakerphoneOn: boolean) => {
+ const call = getCurrentCall();
+ if (call) {
+ setCurrentCall({...call, speakerphoneOn});
+ }
+};
+
+export const setConfig = (serverUrl: string, config: ServerConfig) => {
+ const callsConfig = getCallsConfig(serverUrl);
+ setCallsConfig(serverUrl, {...callsConfig, ...config});
+};
+
+export const setPluginEnabled = (serverUrl: string, pluginEnabled: boolean) => {
+ const callsConfig = getCallsConfig(serverUrl);
+ setCallsConfig(serverUrl, {...callsConfig, pluginEnabled});
+};
diff --git a/app/products/calls/state/calls_config.ts b/app/products/calls/state/calls_config.ts
new file mode 100644
index 0000000000..baaa0d2aac
--- /dev/null
+++ b/app/products/calls/state/calls_config.ts
@@ -0,0 +1,52 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {useEffect, useState} from 'react';
+import {BehaviorSubject} from 'rxjs';
+
+import {CallsConfig, DefaultCallsConfig} from '@calls/types/calls';
+
+const callsConfigSubjects = {} as Dictionary>;
+
+const getCallsConfigSubject = (serverUrl: string) => {
+ if (!callsConfigSubjects[serverUrl]) {
+ callsConfigSubjects[serverUrl] = new BehaviorSubject(DefaultCallsConfig);
+ }
+
+ return callsConfigSubjects[serverUrl];
+};
+
+export const getCallsConfig = (serverUrl: string) => {
+ return getCallsConfigSubject(serverUrl).value;
+};
+
+const setCallsConfig = (serverUrl: string, callsConfig: CallsConfig) => {
+ getCallsConfigSubject(serverUrl).next(callsConfig);
+};
+
+export const observeCallsConfig = (serverUrl: string) => {
+ return getCallsConfigSubject(serverUrl).asObservable();
+};
+
+const useCallsConfig = (serverUrl: string) => {
+ const [state, setState] = useState(DefaultCallsConfig);
+
+ const callsConfigSubject = getCallsConfigSubject(serverUrl);
+
+ useEffect(() => {
+ const subscription = callsConfigSubject.subscribe((callsConfig) => {
+ setState(callsConfig);
+ });
+
+ return () => {
+ subscription?.unsubscribe();
+ };
+ }, []);
+
+ return state;
+};
+
+export const exportedForInternalUse = {
+ setCallsConfig,
+ useCallsConfig,
+};
diff --git a/app/products/calls/state/calls_state.ts b/app/products/calls/state/calls_state.ts
new file mode 100644
index 0000000000..ce9b832a53
--- /dev/null
+++ b/app/products/calls/state/calls_state.ts
@@ -0,0 +1,52 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {useEffect, useState} from 'react';
+import {BehaviorSubject} from 'rxjs';
+
+import {CallsState, DefaultCallsState} from '@calls/types/calls';
+
+const callsStateSubjects = {} as Dictionary>;
+
+const getCallsStateSubject = (serverUrl: string) => {
+ if (!callsStateSubjects[serverUrl]) {
+ callsStateSubjects[serverUrl] = new BehaviorSubject(DefaultCallsState);
+ }
+
+ return callsStateSubjects[serverUrl];
+};
+
+export const getCallsState = (serverUrl: string) => {
+ return getCallsStateSubject(serverUrl).value;
+};
+
+const setCallsState = (serverUrl: string, state: CallsState) => {
+ getCallsStateSubject(serverUrl).next(state);
+};
+
+export const observeCallsState = (serverUrl: string) => {
+ return getCallsStateSubject(serverUrl).asObservable();
+};
+
+const useCallsState = (serverUrl: string) => {
+ const [state, setState] = useState(DefaultCallsState);
+
+ const callsStateSubject = getCallsStateSubject(serverUrl);
+
+ useEffect(() => {
+ const subscription = callsStateSubject.subscribe((callsState) => {
+ setState(callsState);
+ });
+
+ return () => {
+ subscription?.unsubscribe();
+ };
+ }, []);
+
+ return state;
+};
+
+export const exportedForInternalUse = {
+ setCallsState,
+ useCallsState,
+};
diff --git a/app/products/calls/state/channels_with_calls.ts b/app/products/calls/state/channels_with_calls.ts
new file mode 100644
index 0000000000..3fe8087e3d
--- /dev/null
+++ b/app/products/calls/state/channels_with_calls.ts
@@ -0,0 +1,50 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {useEffect, useState} from 'react';
+import {BehaviorSubject} from 'rxjs';
+
+import {ChannelsWithCalls} from '@calls/types/calls';
+
+const channelsWithCallsSubject = {} as Dictionary>;
+
+const getChannelsWithCallsSubject = (serverUrl: string) => {
+ if (!channelsWithCallsSubject[serverUrl]) {
+ channelsWithCallsSubject[serverUrl] = new BehaviorSubject({});
+ }
+
+ return channelsWithCallsSubject[serverUrl];
+};
+
+export const getChannelsWithCalls = (serverUrl: string) => {
+ return getChannelsWithCallsSubject(serverUrl).value;
+};
+
+const setChannelsWithCalls = (serverUrl: string, channelsWithCalls: ChannelsWithCalls) => {
+ getChannelsWithCallsSubject(serverUrl).next(channelsWithCalls);
+};
+
+export const observeChannelsWithCalls = (serverUrl: string) => {
+ return getChannelsWithCallsSubject(serverUrl).asObservable();
+};
+
+const useChannelsWithCalls = (serverUrl: string) => {
+ const [state, setState] = useState({});
+
+ useEffect(() => {
+ const subscription = getChannelsWithCallsSubject(serverUrl).subscribe((channelsWithCalls) => {
+ setState(channelsWithCalls);
+ });
+
+ return () => {
+ subscription?.unsubscribe();
+ };
+ }, []);
+
+ return state;
+};
+
+export const exportedForInternalUse = {
+ setChannelsWithCalls,
+ useChannelsWithCalls,
+};
diff --git a/app/products/calls/state/current_call.ts b/app/products/calls/state/current_call.ts
new file mode 100644
index 0000000000..d2ffe8db27
--- /dev/null
+++ b/app/products/calls/state/current_call.ts
@@ -0,0 +1,42 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {useEffect, useState} from 'react';
+import {BehaviorSubject} from 'rxjs';
+
+import {CurrentCall} from '@calls/types/calls';
+
+const currentCallSubject = new BehaviorSubject(null);
+
+export const getCurrentCall = () => {
+ return currentCallSubject.value;
+};
+
+const setCurrentCall = (currentCall: CurrentCall | null) => {
+ currentCallSubject.next(currentCall);
+};
+
+export const observeCurrentCall = () => {
+ return currentCallSubject.asObservable();
+};
+
+const useCurrentCall = () => {
+ const [state, setState] = useState(null);
+
+ useEffect(() => {
+ const subscription = currentCallSubject.subscribe((currentCall) => {
+ setState(currentCall);
+ });
+
+ return () => {
+ subscription?.unsubscribe();
+ };
+ }, []);
+
+ return state;
+};
+
+export const exportedForInternalUse = {
+ setCurrentCall,
+ useCurrentCall,
+};
diff --git a/app/products/calls/state/index.ts b/app/products/calls/state/index.ts
new file mode 100644
index 0000000000..ceedb16824
--- /dev/null
+++ b/app/products/calls/state/index.ts
@@ -0,0 +1,8 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+export * from './actions';
+export {getCallsState, observeCallsState} from './calls_state';
+export {getCallsConfig, observeCallsConfig} from './calls_config';
+export {getCurrentCall, observeCurrentCall} from './current_call';
+export {observeChannelsWithCalls} from './channels_with_calls';
diff --git a/app/products/calls/types/calls.ts b/app/products/calls/types/calls.ts
new file mode 100644
index 0000000000..a2bf116b71
--- /dev/null
+++ b/app/products/calls/types/calls.ts
@@ -0,0 +1,112 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import type UserModel from '@typings/database/models/servers/user';
+
+export type CallsState = {
+ serverUrl: string;
+ myUserId: string;
+ calls: Dictionary;
+ enabled: Dictionary;
+}
+
+export const DefaultCallsState = {
+ serverUrl: '',
+ myUserId: '',
+ calls: {} as Dictionary,
+ enabled: {} as Dictionary,
+} as CallsState;
+
+export type Call = {
+ participants: Dictionary;
+ channelId: string;
+ startTime: number;
+ screenOn: string;
+ threadId: string;
+}
+
+export const DefaultCall = {
+ participants: {} as Dictionary,
+ channelId: '',
+ startTime: 0,
+ screenOn: '',
+ threadId: '',
+};
+
+export type CurrentCall = {
+ serverUrl: string;
+ myUserId: string;
+ participants: Dictionary;
+ channelId: string;
+ startTime: number;
+ screenOn: string;
+ threadId: string;
+ screenShareURL: string;
+ speakerphoneOn: boolean;
+}
+
+export type CallParticipant = {
+ id: string;
+ muted: boolean;
+ raisedHand: number;
+ userModel?: UserModel;
+}
+
+export type ChannelsWithCalls = Dictionary;
+
+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;
+}
+
+export type VoiceEventData = {
+ channelId: string;
+ userId: string;
+}
+
+export type CallsConnection = {
+ disconnect: () => void;
+ mute: () => void;
+ unmute: () => void;
+ waitForReady: () => Promise;
+ raiseHand: () => void;
+ unraiseHand: () => void;
+}
+
+export type ServerConfig = {
+ ICEServers: string[];
+ AllowEnableCalls: boolean;
+ DefaultEnabled: boolean;
+ last_retrieved_at: number;
+}
+
+export type CallsConfig = {
+ pluginEnabled: boolean;
+ ICEServers: string[];
+ AllowEnableCalls: boolean;
+ DefaultEnabled: boolean;
+ last_retrieved_at: number;
+}
+
+export const DefaultCallsConfig = {
+ pluginEnabled: false,
+ ICEServers: [],
+ AllowEnableCalls: false,
+ DefaultEnabled: false,
+ last_retrieved_at: 0,
+} as CallsConfig;
diff --git a/app/products/calls/utils.ts b/app/products/calls/utils.ts
new file mode 100644
index 0000000000..159aa0e580
--- /dev/null
+++ b/app/products/calls/utils.ts
@@ -0,0 +1,70 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {CallParticipant} from '@calls/types/calls';
+import {Post} from '@constants';
+import Calls from '@constants/calls';
+import PostModel from '@typings/database/models/servers/post';
+import {isMinimumServerVersion} from '@utils/helpers';
+import {displayUsername} from '@utils/user';
+
+export function sortParticipants(teammateNameDisplay: string, participants?: Dictionary, presenterID?: string): CallParticipant[] {
+ if (!participants) {
+ return [];
+ }
+
+ const users = Object.values(participants);
+
+ return users.sort(sortByName(teammateNameDisplay)).sort(sortByState(presenterID));
+}
+
+const sortByName = (teammateNameDisplay: string) => {
+ return (a: CallParticipant, b: CallParticipant) => {
+ const nameA = displayUsername(a.userModel, teammateNameDisplay);
+ const nameB = displayUsername(b.userModel, teammateNameDisplay);
+ return nameA.localeCompare(nameB);
+ };
+};
+
+const sortByState = (presenterID?: string) => {
+ return (a: CallParticipant, b: CallParticipant) => {
+ if (a.id === presenterID) {
+ return -1;
+ } else if (b.id === presenterID) {
+ return 1;
+ }
+
+ if (!a.muted && b.muted) {
+ return -1;
+ } else if (!b.muted && a.muted) {
+ return 1;
+ }
+
+ if (a.raisedHand && !b.raisedHand) {
+ return -1;
+ } else if (b.raisedHand && !a.raisedHand) {
+ return 1;
+ } else if (a.raisedHand && b.raisedHand) {
+ return a.raisedHand - b.raisedHand;
+ }
+
+ return 0;
+ };
+};
+
+export function isSupportedServerCalls(serverVersion?: string) {
+ if (serverVersion) {
+ return isMinimumServerVersion(
+ serverVersion,
+ Calls.RequiredServer.MAJOR_VERSION,
+ Calls.RequiredServer.MIN_VERSION,
+ Calls.RequiredServer.PATCH_VERSION,
+ );
+ }
+
+ return false;
+}
+
+export function isCallsCustomMessage(post: PostModel | Post): boolean {
+ return Boolean(post.type && post.type?.startsWith(Post.POST_TYPES.CUSTOM_CALLS));
+}
diff --git a/app/queries/servers/user.ts b/app/queries/servers/user.ts
index e1fefa2eac..4ef84e5057 100644
--- a/app/queries/servers/user.ts
+++ b/app/queries/servers/user.ts
@@ -56,6 +56,10 @@ export const queryUsersById = (database: Database, userIds: string[]) => {
return database.get(USER).query(Q.where('id', Q.oneOf(userIds)));
};
+export const observeUsersById = (database: Database, userIds: string[]) => {
+ return queryUsersById(database, userIds).observe();
+};
+
export const queryUsersByUsername = (database: Database, usernames: string[]) => {
return database.get(USER).query(Q.where('username', Q.oneOf(usernames)));
};
diff --git a/app/screens/channel/channel.tsx b/app/screens/channel/channel.tsx
index 6d64666be0..e746375060 100644
--- a/app/screens/channel/channel.tsx
+++ b/app/screens/channel/channel.tsx
@@ -6,6 +6,9 @@ import {BackHandler, DeviceEventEmitter, NativeEventSubscription, StyleSheet, Vi
import {KeyboardTrackingViewRef} from 'react-native-keyboard-tracking-view';
import {Edge, SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context';
+import CurrentCallBar from '@calls/components/current_call_bar';
+import FloatingCallContainer from '@calls/components/floating_call_container';
+import JoinCallBanner from '@calls/components/join_call_banner';
import FreezeScreen from '@components/freeze_screen';
import PostDraft from '@components/post_draft';
import {Events} from '@constants';
@@ -22,8 +25,12 @@ import ChannelPostList from './channel_post_list';
import ChannelHeader from './header';
type ChannelProps = {
+ serverUrl: string;
channelId: string;
componentId?: string;
+ isCallsPluginEnabled: boolean;
+ isCallInCurrentChannel: boolean;
+ isInCall: boolean;
};
const edges: Edge[] = ['left', 'right'];
@@ -34,7 +41,7 @@ const styles = StyleSheet.create({
},
});
-const Channel = ({channelId, componentId}: ChannelProps) => {
+const Channel = ({serverUrl, channelId, componentId, isCallsPluginEnabled, isCallInCurrentChannel, isInCall}: ChannelProps) => {
const appState = useAppState();
const isTablet = useIsTablet();
const insets = useSafeAreaInsets();
@@ -95,6 +102,21 @@ const Channel = ({channelId, componentId}: ChannelProps) => {
};
}, [channelId]);
+ let callsComponents: JSX.Element | null = null;
+ if (isCallsPluginEnabled && (isCallInCurrentChannel || isInCall)) {
+ callsComponents = (
+
+ {isCallInCurrentChannel &&
+
+ }
+ {isInCall && }
+
+ );
+ }
+
return (
{
/>
>
}
+ {callsComponents}
);
diff --git a/app/screens/channel/index.tsx b/app/screens/channel/index.tsx
index 0c797e500e..94b304d8f1 100644
--- a/app/screens/channel/index.tsx
+++ b/app/screens/channel/index.tsx
@@ -3,15 +3,39 @@
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
+import {combineLatest, of as of$} from 'rxjs';
+import {switchMap} from 'rxjs/operators';
+import {observeCallsConfig, observeChannelsWithCalls, observeCurrentCall} from '@calls/state';
+import {withServerUrl} from '@context/server';
import {observeCurrentChannelId} from '@queries/servers/system';
import Channel from './channel';
import type {WithDatabaseArgs} from '@typings/database/database';
-const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({
- channelId: observeCurrentChannelId(database),
-}));
+type EnhanceProps = WithDatabaseArgs & {
+ serverUrl: string;
+}
-export default withDatabase(enhanced(Channel));
+const enhanced = withObservables([], ({database, serverUrl}: EnhanceProps) => {
+ const channelId = observeCurrentChannelId(database);
+ const isCallsPluginEnabled = observeCallsConfig(serverUrl).pipe(
+ switchMap((config) => of$(config.pluginEnabled)),
+ );
+ const isCallInCurrentChannel = combineLatest([channelId, observeChannelsWithCalls(serverUrl)]).pipe(
+ switchMap(([id, calls]) => of$(Boolean(calls[id]))),
+ );
+ const isInCall = observeCurrentCall().pipe(
+ switchMap((call) => of$(Boolean(call))),
+ );
+
+ return {
+ channelId,
+ isCallsPluginEnabled,
+ isCallInCurrentChannel,
+ isInCall,
+ };
+});
+
+export default withDatabase(withServerUrl(enhanced(Channel)));
diff --git a/app/screens/index.tsx b/app/screens/index.tsx
index d3019290d3..09943a68d0 100644
--- a/app/screens/index.tsx
+++ b/app/screens/index.tsx
@@ -214,6 +214,9 @@ Navigation.setLazyComponentRegistrator((screenName) => {
case Screens.USER_PROFILE:
screen = withServerDatabase(require('@screens/user_profile').default);
break;
+ case Screens.CALL:
+ screen = withServerDatabase(require('@calls/screens/call_screen').default);
+ break;
}
if (screen) {
diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json
index ea94cb1814..fc586470f3 100644
--- a/assets/base/i18n/en.json
+++ b/assets/base/i18n/en.json
@@ -415,6 +415,9 @@
"mobile.ios.photos_permission_denied_description": "Upload photos and videos to your server or save them to your device. Open Settings to grant {applicationName} Read and Write access to your photo and video library.",
"mobile.ios.photos_permission_denied_title": "{applicationName} would like to access your photos",
"mobile.join_channel.error": "We couldn't join the channel {displayName}.",
+ "mobile.leave_and_join_confirmation": "Leave & Join",
+ "mobile.leave_and_join_message": "You are already on a channel call in ~{leaveChannelName}. Do you want to leave your current call and join the call in ~{joinChannelName}?",
+ "mobile.leave_and_join_title": "Are you sure you want to switch to a different call?",
"mobile.link.error.text": "Unable to open the link.",
"mobile.link.error.title": "Error",
"mobile.login_options.cant_heading": "Can't Log In",
@@ -445,6 +448,8 @@
"mobile.message_length.message": "Your current message is too long. Current character count: {count}/{max}",
"mobile.message_length.message_split_left": "Message exceeds the character limit",
"mobile.message_length.title": "Message Length",
+ "mobile.microphone_permission_denied_description": "To participate in this call, open Settings to grant Mattermost access to your microphone.",
+ "mobile.microphone_permission_denied_title": "{applicationName} would like to access your microphone",
"mobile.no_results_with_term": "No results for “{term}”",
"mobile.no_results_with_term.files": "No files matching “{term}”",
"mobile.no_results_with_term.messages": "No matches found for “{term}”",
@@ -519,7 +524,6 @@
"mobile.search.modifier.in": "a specific channel",
"mobile.search.modifier.on": "a specific date",
"mobile.search.modifier.phrases": "messages with phrases",
- "mobile.search.recent_title": "Recent searches in {teamName}",
"mobile.search.show_less": "Show less",
"mobile.search.show_more": "Show more",
"mobile.search.team.select": "Select a team to search",
@@ -680,6 +684,9 @@
"screen.search.header.messages": "Messages",
"screen.search.modifier.header": "Search options",
"screen.search.placeholder": "Search messages & files",
+ "screen.search.results.file_options.copy_link": "Copy link",
+ "screen.search.results.file_options.download": "Download",
+ "screen.search.results.file_options.open_in_channel": "Open in channel",
"screen.search.results.filter.all_file_types": "All file types",
"screen.search.results.filter.audio": "Audio",
"screen.search.results.filter.code": "Code",
@@ -696,6 +703,7 @@
"screens.channel_info.dm": "Direct message info",
"screens.channel_info.gm": "Group message info",
"search_bar.search": "Search",
+ "search_bar.search.placeholder": "Search timezone",
"select_team.description": "You are not yet a member of any teams. Select one below to get started.",
"select_team.no_team.description": "To join a team, ask a team admin for an invite, or create your own team. You may also want to check your email inbox for an invitation.",
"select_team.no_team.title": "No teams are available to join",
@@ -726,6 +734,7 @@
"settings.display": "Display",
"settings.notifications": "Notifications",
"settings.save": "Save",
+ "smobile.search.recent_title": "Recent searches in {teamName}",
"snack.bar.favorited.channel": "This channel was favorited",
"snack.bar.link.copied": "Link copied to clipboard",
"snack.bar.message.copied": "Text copied to clipboard",
diff --git a/babel.config.js b/babel.config.js
index 4ab4a703b5..d40bbed902 100644
--- a/babel.config.js
+++ b/babel.config.js
@@ -23,6 +23,7 @@ module.exports = {
'@actions': './app/actions',
'@app': './app/',
'@assets': './dist/assets/',
+ '@calls': './app/products/calls',
'@client': './app/client',
'@components': './app/components',
'@constants': './app/constants',
diff --git a/ios/Mattermost.xcodeproj/project.pbxproj b/ios/Mattermost.xcodeproj/project.pbxproj
index 5471fb0be7..d337764a5c 100644
--- a/ios/Mattermost.xcodeproj/project.pbxproj
+++ b/ios/Mattermost.xcodeproj/project.pbxproj
@@ -565,6 +565,7 @@
);
inputPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Mattermost/Pods-Mattermost-frameworks.sh",
+ "${PODS_ROOT}/../../node_modules/react-native-webrtc/ios/WebRTC.framework",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/Flipper-DoubleConversion/double-conversion.framework/double-conversion",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/Flipper-Glog/glog.framework/glog",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/OpenSSL-Universal/OpenSSL.framework/OpenSSL",
@@ -572,6 +573,7 @@
);
name = "[CP] Embed Pods Frameworks";
outputPaths = (
+ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/WebRTC.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/double-conversion.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/glog.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/OpenSSL.framework",
diff --git a/ios/Mattermost/Info.plist b/ios/Mattermost/Info.plist
index 69531ce7dd..90930571ae 100644
--- a/ios/Mattermost/Info.plist
+++ b/ios/Mattermost/Info.plist
@@ -74,7 +74,7 @@
NSLocationWhenInUseUsageDescription
Send your current location to $(PRODUCT_NAME)
NSMicrophoneUsageDescription
- Capture audio when recording a video to share within $(PRODUCT_NAME)
+ Capture audio when making a call or recording a video to share within $(PRODUCT_NAME)
NSMotionUsageDescription
Share your route in your $(PRODUCT_NAME) workspace
NSPhotoLibraryAddUsageDescription
@@ -102,6 +102,7 @@
UIBackgroundModes
+ audio
fetch
remote-notification
diff --git a/ios/Podfile b/ios/Podfile
index eb82b6e653..5fc1e346c2 100644
--- a/ios/Podfile
+++ b/ios/Podfile
@@ -23,6 +23,7 @@ target 'Mattermost' do
permissions_path = '../node_modules/react-native-permissions/ios'
pod 'Permission-Camera', :path => "#{permissions_path}/Camera"
+ pod 'Permission-Microphone', :path => "#{permissions_path}/Microphone"
pod 'Permission-PhotoLibrary', :path => "#{permissions_path}/PhotoLibrary"
pod 'React-jsi', :path => '../node_modules/react-native/ReactCommon/jsi', :modular_headers => true
pod 'simdjson', path: '../node_modules/@nozbe/simdjson'
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index 8c5fecfc67..4a2df8bfaf 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -94,6 +94,8 @@ PODS:
- OpenSSL-Universal (1.1.1100)
- Permission-Camera (3.3.1):
- RNPermissions
+ - Permission-Microphone (3.3.1):
+ - RNPermissions
- Permission-PhotoLibrary (3.3.1):
- RNPermissions
- RCT-Folly (2021.06.28.00-v2):
@@ -359,6 +361,8 @@ PODS:
- react-native-video/Video (= 5.2.0)
- react-native-video/Video (5.2.0):
- React-Core
+ - react-native-webrtc (1.75.3):
+ - React
- react-native-webview (11.18.2):
- React-Core
- React-perflogger (0.68.2)
@@ -430,6 +434,8 @@ PODS:
- React
- ReactNativeExceptionHandler (2.10.10):
- React-Core
+ - ReactNativeIncallManager (3.3.0):
+ - React-Core
- ReactNativeKeyboardTrackingView (5.7.0):
- React
- ReactNativeNavigation (7.27.1):
@@ -566,6 +572,7 @@ DEPENDENCIES:
- libevent (~> 2.1.12)
- OpenSSL-Universal (= 1.1.1100)
- Permission-Camera (from `../node_modules/react-native-permissions/ios/Camera`)
+ - Permission-Microphone (from `../node_modules/react-native-permissions/ios/Microphone`)
- Permission-PhotoLibrary (from `../node_modules/react-native-permissions/ios/PhotoLibrary`)
- RCT-Folly (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`)
- RCTRequired (from `../node_modules/react-native/Libraries/RCTRequired`)
@@ -597,6 +604,7 @@ DEPENDENCIES:
- "react-native-paste-input (from `../node_modules/@mattermost/react-native-paste-input`)"
- react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`)
- react-native-video (from `../node_modules/react-native-video`)
+ - react-native-webrtc (from `../node_modules/react-native-webrtc`)
- react-native-webview (from `../node_modules/react-native-webview`)
- React-perflogger (from `../node_modules/react-native/ReactCommon/reactperflogger`)
- React-RCTActionSheet (from `../node_modules/react-native/Libraries/ActionSheetIOS`)
@@ -612,6 +620,7 @@ DEPENDENCIES:
- ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`)
- "ReactNativeART (from `../node_modules/@react-native-community/art`)"
- ReactNativeExceptionHandler (from `../node_modules/react-native-exception-handler`)
+ - ReactNativeIncallManager (from `../node_modules/react-native-incall-manager`)
- ReactNativeKeyboardTrackingView (from `../node_modules/react-native-keyboard-tracking-view`)
- ReactNativeNavigation (from `../node_modules/react-native-navigation`)
- "RNCClipboard (from `../node_modules/@react-native-community/clipboard`)"
@@ -685,6 +694,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/jail-monkey"
Permission-Camera:
:path: "../node_modules/react-native-permissions/ios/Camera"
+ Permission-Microphone:
+ :path: "../node_modules/react-native-permissions/ios/Microphone"
Permission-PhotoLibrary:
:path: "../node_modules/react-native-permissions/ios/PhotoLibrary"
RCT-Folly:
@@ -743,6 +754,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-safe-area-context"
react-native-video:
:path: "../node_modules/react-native-video"
+ react-native-webrtc:
+ :path: "../node_modules/react-native-webrtc"
react-native-webview:
:path: "../node_modules/react-native-webview"
React-perflogger:
@@ -773,6 +786,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/@react-native-community/art"
ReactNativeExceptionHandler:
:path: "../node_modules/react-native-exception-handler"
+ ReactNativeIncallManager:
+ :path: "../node_modules/react-native-incall-manager"
ReactNativeKeyboardTrackingView:
:path: "../node_modules/react-native-keyboard-tracking-view"
ReactNativeNavigation:
@@ -857,6 +872,7 @@ SPEC CHECKSUMS:
libwebp: 98a37e597e40bfdb4c911fc98f2c53d0b12d05fc
OpenSSL-Universal: ebc357f1e6bc71fa463ccb2fe676756aff50e88c
Permission-Camera: bae27a8503530770c35aadfecbb97ec71823382a
+ Permission-Microphone: 13922195a81b9d46d2df8050c225006eae126d49
Permission-PhotoLibrary: ddb5a158725b29cb12e9e477e8a5f5151c66cc3c
RCT-Folly: 4d8508a426467c48885f1151029bc15fa5d7b3b8
RCTRequired: 3e917ea5377751094f38145fdece525aa90545a0
@@ -886,6 +902,7 @@ SPEC CHECKSUMS:
react-native-paste-input: efbf0b08fa1673f0e3131da6ea01678c1bb8003e
react-native-safe-area-context: ebf8c413eb8b5f7c392a036a315eb7b46b96845f
react-native-video: a4c2635d0802f983594b7057e1bce8f442f0ad28
+ react-native-webrtc: 86d841823e66d68cc1f86712db1c2956056bf0c2
react-native-webview: 8ec7ddf9eb4ddcd92b32cee7907efec19a9ec7cb
React-perflogger: a18b4f0bd933b8b24ecf9f3c54f9bf65180f3fe6
React-RCTActionSheet: 547fe42fdb4b6089598d79f8e1d855d7c23e2162
@@ -901,6 +918,7 @@ SPEC CHECKSUMS:
ReactCommon: 095366164a276d91ea704ce53cb03825c487a3f2
ReactNativeART: 78edc68dd4a1e675338cd0cd113319cf3a65f2ab
ReactNativeExceptionHandler: b11ff67c78802b2f62eed0e10e75cb1ef7947c60
+ ReactNativeIncallManager: 642c22630caadff0a0619413aff4a9da08d63df9
ReactNativeKeyboardTrackingView: 02137fac3b2ebd330d74fa54ead48b14750a2306
ReactNativeNavigation: 94979dd1572a3f093fc85d4599360530a1bed8c8
RNCClipboard: 41d8d918092ae8e676f18adada19104fa3e68495
@@ -935,6 +953,6 @@ SPEC CHECKSUMS:
Yoga: 99652481fcd320aefa4a7ef90095b95acd181952
YogaKit: f782866e155069a2cca2517aafea43200b01fd5a
-PODFILE CHECKSUM: d2731f6f5ef095481cc3a9332ef85c4369866d2b
+PODFILE CHECKSUM: 872e3e8209322408f2af28f478db4ad3ef313ea4
COCOAPODS: 1.11.3
diff --git a/package-lock.json b/package-lock.json
index d1a8514bd6..d36577015c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -20,6 +20,7 @@
"@mattermost/react-native-emm": "1.2.3",
"@mattermost/react-native-network-client": "github:mattermost/react-native-network-client",
"@mattermost/react-native-paste-input": "0.4.2",
+ "@msgpack/msgpack": "2.7.2",
"@nozbe/watermelondb": "0.24.0",
"@nozbe/with-observables": "1.4.0",
"@react-native-community/art": "1.2.0",
@@ -43,6 +44,7 @@
"jail-monkey": "2.6.0",
"mime-db": "1.52.0",
"moment-timezone": "0.5.34",
+ "pako": "2.0.4",
"react": "17.0.2",
"react-freeze": "1.0.0",
"react-intl": "6.0.2",
@@ -65,6 +67,7 @@
"react-native-haptic-feedback": "1.13.1",
"react-native-hw-keyboard-event": "0.0.4",
"react-native-image-picker": "4.8.3",
+ "react-native-incall-manager": "github:cpoile/react-native-incall-manager",
"react-native-keyboard-aware-scroll-view": "0.9.5",
"react-native-keyboard-tracking-view": "5.7.0",
"react-native-keychain": "8.0.0",
@@ -83,8 +86,10 @@
"react-native-svg": "12.3.0",
"react-native-vector-icons": "9.1.0",
"react-native-video": "5.2.0",
+ "react-native-webrtc": "github:mattermost/react-native-webrtc",
"react-native-webview": "11.18.2",
"react-syntax-highlighter": "15.5.0",
+ "readable-stream": "3.6.0",
"reanimated-bottom-sheet": "1.0.0-alpha.22",
"rn-placeholder": "3.0.3",
"semver": "7.3.7",
@@ -106,6 +111,7 @@
"@babel/register": "7.17.7",
"@babel/runtime": "7.18.0",
"@react-native-community/eslint-config": "3.0.2",
+ "@testing-library/react-hooks": "8.0.0",
"@testing-library/react-native": "9.1.0",
"@types/base-64": "1.0.0",
"@types/commonmark": "0.27.5",
@@ -123,6 +129,7 @@
"@types/react-native-video": "5.0.13",
"@types/react-syntax-highlighter": "15.5.1",
"@types/react-test-renderer": "18.0.0",
+ "@types/readable-stream": "2.3.13",
"@types/semver": "7.3.9",
"@types/shallow-equals": "1.0.0",
"@types/tinycolor2": "1.4.3",
@@ -4142,6 +4149,14 @@
"react-native": "*"
}
},
+ "node_modules/@msgpack/msgpack": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-2.7.2.tgz",
+ "integrity": "sha512-rYEi46+gIzufyYUAoHDnRzkWGxajpD9vVXFQ3g1vbjrBm6P7MBmm+s/fqPa46sxa+8FOUdEuRQKaugo5a4JWpw==",
+ "engines": {
+ "node": ">= 10"
+ }
+ },
"node_modules/@nicolo-ribaudo/chokidar-2": {
"version": "2.1.8-no-fsevents.3",
"resolved": "https://registry.npmjs.org/@nicolo-ribaudo/chokidar-2/-/chokidar-2-2.1.8-no-fsevents.3.tgz",
@@ -6417,6 +6432,36 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/@testing-library/react-hooks": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-8.0.0.tgz",
+ "integrity": "sha512-uZqcgtcUUtw7Z9N32W13qQhVAD+Xki2hxbTR461MKax8T6Jr8nsUvZB+vcBTkzY2nFvsUet434CsgF0ncW2yFw==",
+ "dev": true,
+ "dependencies": {
+ "@babel/runtime": "^7.12.5",
+ "react-error-boundary": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "peerDependencies": {
+ "@types/react": "^16.9.0 || ^17.0.0",
+ "react": "^16.9.0 || ^17.0.0",
+ "react-dom": "^16.9.0 || ^17.0.0",
+ "react-test-renderer": "^16.9.0 || ^17.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ },
+ "react-test-renderer": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@testing-library/react-native": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/@testing-library/react-native/-/react-native-9.1.0.tgz",
@@ -6713,6 +6758,16 @@
"@types/react": "*"
}
},
+ "node_modules/@types/readable-stream": {
+ "version": "2.3.13",
+ "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-2.3.13.tgz",
+ "integrity": "sha512-4JSCx8EUzaW9Idevt+9lsRAt1lcSccoQfE+AouM1gk8sFxnnytKNIO3wTl9Dy+4m6jRJ1yXhboLHHT/LXBQiEw==",
+ "dev": true,
+ "dependencies": {
+ "@types/node": "*",
+ "safe-buffer": "*"
+ }
+ },
"node_modules/@types/scheduler": {
"version": "0.16.2",
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz",
@@ -7691,6 +7746,25 @@
"readable-stream": "^2.0.6"
}
},
+ "node_modules/are-we-there-yet/node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
+ },
+ "node_modules/are-we-there-yet/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/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
@@ -8527,19 +8601,6 @@
"ieee754": "^1.1.13"
}
},
- "node_modules/bl/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==",
- "dependencies": {
- "inherits": "^2.0.3",
- "string_decoder": "^1.1.1",
- "util-deprecate": "^1.0.1"
- },
- "engines": {
- "node": ">= 6"
- }
- },
"node_modules/bluebird": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
@@ -8681,21 +8742,6 @@
"safe-buffer": "^5.2.0"
}
},
- "node_modules/browserify-sign/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,
- "peer": true,
- "dependencies": {
- "inherits": "^2.0.3",
- "string_decoder": "^1.1.1",
- "util-deprecate": "^1.0.1"
- },
- "engines": {
- "node": ">= 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",
@@ -8727,6 +8773,13 @@
"pako": "~1.0.5"
}
},
+ "node_modules/browserify-zlib/node_modules/pako": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+ "dev": true,
+ "peer": true
+ },
"node_modules/browserslist": {
"version": "4.20.3",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.20.3.tgz",
@@ -9584,6 +9637,29 @@
"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": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true,
+ "peer": 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,
+ "peer": 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",
@@ -10379,6 +10455,25 @@
"readable-stream": "^2.0.2"
}
},
+ "node_modules/duplexer2/node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
+ },
+ "node_modules/duplexer2/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/duplexify": {
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz",
@@ -10392,6 +10487,29 @@
"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": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true,
+ "peer": 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,
+ "peer": 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/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -10487,6 +10605,13 @@
"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": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true,
+ "peer": 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",
@@ -10501,6 +10626,22 @@
"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,
+ "peer": 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/entities": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/entities/-/entities-2.0.3.tgz",
@@ -12400,6 +12541,29 @@
"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": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true,
+ "peer": 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,
+ "peer": 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/follow-redirects": {
"version": "1.14.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.9.tgz",
@@ -12565,6 +12729,29 @@
"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": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true,
+ "peer": 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,
+ "peer": 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": {
"version": "0.0.1-security",
"resolved": "https://registry.npmjs.org/fs/-/fs-0.0.1-security.tgz",
@@ -12600,6 +12787,29 @@
"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": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true,
+ "peer": 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,
+ "peer": 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",
@@ -13019,21 +13229,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,
- "peer": 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",
@@ -18170,6 +18365,29 @@
"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": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true,
+ "peer": 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,
+ "peer": 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-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
@@ -19873,6 +20091,13 @@
"dev": true,
"peer": 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": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true,
+ "peer": true
+ },
"node_modules/node-libs-browser/node_modules/path-browserify": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-0.0.1.tgz",
@@ -19887,6 +20112,22 @@
"dev": true,
"peer": 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,
+ "peer": 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",
@@ -20411,11 +20652,9 @@
}
},
"node_modules/pako": {
- "version": "1.0.11",
- "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
- "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
- "dev": true,
- "peer": true
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-2.0.4.tgz",
+ "integrity": "sha512-v8tweI900AUkZN6heMU/4Uy4cXRc2AYNRggVmTR+dEncawDJgCdLMximOVA2p4qO57WMynangsfGRb5WD6L1Bg=="
},
"node_modules/parallel-transform": {
"version": "1.2.0",
@@ -20429,6 +20668,29 @@
"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": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true,
+ "peer": 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,
+ "peer": 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/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -21167,6 +21429,22 @@
"ws": "^7"
}
},
+ "node_modules/react-error-boundary": {
+ "version": "3.1.4",
+ "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz",
+ "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==",
+ "dev": true,
+ "dependencies": {
+ "@babel/runtime": "^7.12.5"
+ },
+ "engines": {
+ "node": ">=10",
+ "npm": ">=6"
+ },
+ "peerDependencies": {
+ "react": ">=16.13.1"
+ }
+ },
"node_modules/react-freeze": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/react-freeze/-/react-freeze-1.0.0.tgz",
@@ -21522,6 +21800,14 @@
"react-native": "*"
}
},
+ "node_modules/react-native-incall-manager": {
+ "version": "3.3.0",
+ "resolved": "git+ssh://git@github.com/cpoile/react-native-incall-manager.git#8c55b9dac0a2ab25d651fb54b504d384f9989b36",
+ "license": "ISC",
+ "peerDependencies": {
+ "react-native": ">=0.40.0"
+ }
+ },
"node_modules/react-native-iphone-x-helper": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/react-native-iphone-x-helper/-/react-native-iphone-x-helper-1.3.1.tgz",
@@ -21788,6 +22074,33 @@
"shaka-player": "^2.5.9"
}
},
+ "node_modules/react-native-webrtc": {
+ "version": "1.75.3",
+ "resolved": "git+ssh://git@github.com/mattermost/react-native-webrtc.git#7f765758f2f67e467ebd224c1c4d00a475e400d3",
+ "dependencies": {
+ "base64-js": "^1.1.2",
+ "event-target-shim": "^1.0.5",
+ "prop-types": "^15.5.10",
+ "uuid": "^3.3.2"
+ },
+ "peerDependencies": {
+ "react-native": ">=0.40.0"
+ }
+ },
+ "node_modules/react-native-webrtc/node_modules/event-target-shim": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-1.1.1.tgz",
+ "integrity": "sha512-9hnrQp9HNLexUaxXvgV83/DNrZET6Yjr5wFZowmv2sfbxYrpGT4YB4pmgvoJ6NmUUr/CDQbC1l99v9EaX3mO5w=="
+ },
+ "node_modules/react-native-webrtc/node_modules/uuid": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",
+ "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==",
+ "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.",
+ "bin": {
+ "uuid": "bin/uuid"
+ }
+ },
"node_modules/react-native-webview": {
"version": "11.18.2",
"resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-11.18.2.tgz",
@@ -21996,24 +22309,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",
@@ -23375,6 +23682,29 @@
"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": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true,
+ "peer": 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,
+ "peer": 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",
@@ -23408,6 +23738,29 @@
"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": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true,
+ "peer": 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,
+ "peer": 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",
@@ -23927,6 +24280,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": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
+ },
+ "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",
@@ -24649,6 +25021,25 @@
"node": ">=0.6"
}
},
+ "node_modules/unzipper/node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
+ },
+ "node_modules/unzipper/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/unzipper/node_modules/rimraf": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
@@ -25069,6 +25460,14 @@
"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": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true,
+ "optional": true,
+ "peer": true
+ },
"node_modules/watchpack-chokidar2/node_modules/micromatch": {
"version": "3.1.10",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz",
@@ -25095,6 +25494,23 @@
"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,
+ "peer": 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",
@@ -28807,6 +29223,11 @@
"deprecated-react-native-prop-types": "^2.3.0"
}
},
+ "@msgpack/msgpack": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-2.7.2.tgz",
+ "integrity": "sha512-rYEi46+gIzufyYUAoHDnRzkWGxajpD9vVXFQ3g1vbjrBm6P7MBmm+s/fqPa46sxa+8FOUdEuRQKaugo5a4JWpw=="
+ },
"@nicolo-ribaudo/chokidar-2": {
"version": "2.1.8-no-fsevents.3",
"resolved": "https://registry.npmjs.org/@nicolo-ribaudo/chokidar-2/-/chokidar-2-2.1.8-no-fsevents.3.tgz",
@@ -30515,6 +30936,16 @@
}
}
},
+ "@testing-library/react-hooks": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-8.0.0.tgz",
+ "integrity": "sha512-uZqcgtcUUtw7Z9N32W13qQhVAD+Xki2hxbTR461MKax8T6Jr8nsUvZB+vcBTkzY2nFvsUet434CsgF0ncW2yFw==",
+ "dev": true,
+ "requires": {
+ "@babel/runtime": "^7.12.5",
+ "react-error-boundary": "^3.1.0"
+ }
+ },
"@testing-library/react-native": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/@testing-library/react-native/-/react-native-9.1.0.tgz",
@@ -30803,6 +31234,16 @@
"@types/react": "*"
}
},
+ "@types/readable-stream": {
+ "version": "2.3.13",
+ "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-2.3.13.tgz",
+ "integrity": "sha512-4JSCx8EUzaW9Idevt+9lsRAt1lcSccoQfE+AouM1gk8sFxnnytKNIO3wTl9Dy+4m6jRJ1yXhboLHHT/LXBQiEw==",
+ "dev": true,
+ "requires": {
+ "@types/node": "*",
+ "safe-buffer": "*"
+ }
+ },
"@types/scheduler": {
"version": "0.16.2",
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz",
@@ -31522,6 +31963,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": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
+ },
+ "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": {
@@ -32160,16 +32622,6 @@
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
- },
- "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==",
- "requires": {
- "inherits": "^2.0.3",
- "string_decoder": "^1.1.1",
- "util-deprecate": "^1.0.1"
- }
}
}
},
@@ -32308,18 +32760,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,
- "peer": 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",
@@ -32337,6 +32777,15 @@
"peer": true,
"requires": {
"pako": "~1.0.5"
+ },
+ "dependencies": {
+ "pako": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+ "dev": true,
+ "peer": true
+ }
}
},
"browserslist": {
@@ -33010,6 +33459,31 @@
"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": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true,
+ "peer": 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,
+ "peer": 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": {
@@ -33642,6 +34116,27 @@
"integrity": "sha1-ixLauHjA1p4+eJEFFmKjL8a93ME=",
"requires": {
"readable-stream": "^2.0.2"
+ },
+ "dependencies": {
+ "isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
+ },
+ "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"
+ }
+ }
}
},
"duplexify": {
@@ -33655,6 +34150,31 @@
"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": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true,
+ "peer": 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,
+ "peer": 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"
+ }
+ }
}
},
"ee-first": {
@@ -33739,6 +34259,13 @@
"tapable": "^1.0.0"
},
"dependencies": {
+ "isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true,
+ "peer": true
+ },
"memory-fs": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.5.0.tgz",
@@ -33749,6 +34276,22 @@
"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,
+ "peer": 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"
+ }
}
}
},
@@ -35228,6 +35771,31 @@
"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": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true,
+ "peer": 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,
+ "peer": 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"
+ }
+ }
}
},
"follow-redirects": {
@@ -35343,6 +35911,31 @@
"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": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true,
+ "peer": 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,
+ "peer": 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": {
@@ -35378,6 +35971,31 @@
"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": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true,
+ "peer": 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,
+ "peer": 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": {
@@ -35683,18 +36301,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,
- "peer": 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",
@@ -39607,6 +40213,31 @@
"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": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true,
+ "peer": 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,
+ "peer": 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-stream": {
@@ -41016,6 +41647,13 @@
"dev": true,
"peer": true
},
+ "isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true,
+ "peer": true
+ },
"path-browserify": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-0.0.1.tgz",
@@ -41030,6 +41668,22 @@
"dev": true,
"peer": 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,
+ "peer": 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",
@@ -41413,11 +42067,9 @@
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="
},
"pako": {
- "version": "1.0.11",
- "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
- "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
- "dev": true,
- "peer": true
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-2.0.4.tgz",
+ "integrity": "sha512-v8tweI900AUkZN6heMU/4Uy4cXRc2AYNRggVmTR+dEncawDJgCdLMximOVA2p4qO57WMynangsfGRb5WD6L1Bg=="
},
"parallel-transform": {
"version": "1.2.0",
@@ -41429,6 +42081,31 @@
"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": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true,
+ "peer": 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,
+ "peer": 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"
+ }
+ }
}
},
"parent-module": {
@@ -42020,6 +42697,15 @@
"ws": "^7"
}
},
+ "react-error-boundary": {
+ "version": "3.1.4",
+ "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz",
+ "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==",
+ "dev": true,
+ "requires": {
+ "@babel/runtime": "^7.12.5"
+ }
+ },
"react-freeze": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/react-freeze/-/react-freeze-1.0.0.tgz",
@@ -42385,6 +43071,11 @@
"integrity": "sha512-mA3xKN8TtXpLl3Aoo65ArZvusv3gCsbe3/hsOLKI1DcC+QcYPcDnsYK/ft05rWBj4BdiJ9E2JoveiHNtU8W9GA==",
"requires": {}
},
+ "react-native-incall-manager": {
+ "version": "git+ssh://git@github.com/cpoile/react-native-incall-manager.git#8c55b9dac0a2ab25d651fb54b504d384f9989b36",
+ "from": "react-native-incall-manager@github:cpoile/react-native-incall-manager",
+ "requires": {}
+ },
"react-native-iphone-x-helper": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/react-native-iphone-x-helper/-/react-native-iphone-x-helper-1.3.1.tgz",
@@ -42571,6 +43262,28 @@
"shaka-player": "^2.5.9"
}
},
+ "react-native-webrtc": {
+ "version": "git+ssh://git@github.com/mattermost/react-native-webrtc.git#7f765758f2f67e467ebd224c1c4d00a475e400d3",
+ "from": "react-native-webrtc@github:mattermost/react-native-webrtc",
+ "requires": {
+ "base64-js": "^1.1.2",
+ "event-target-shim": "^1.0.5",
+ "prop-types": "^15.5.10",
+ "uuid": "^3.3.2"
+ },
+ "dependencies": {
+ "event-target-shim": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-1.1.1.tgz",
+ "integrity": "sha512-9hnrQp9HNLexUaxXvgV83/DNrZET6Yjr5wFZowmv2sfbxYrpGT4YB4pmgvoJ6NmUUr/CDQbC1l99v9EaX3mO5w=="
+ },
+ "uuid": {
+ "version": "3.4.0",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz",
+ "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A=="
+ }
+ }
+ },
"react-native-webview": {
"version": "11.18.2",
"resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-11.18.2.tgz",
@@ -42649,24 +43362,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": {
@@ -43766,6 +44468,31 @@
"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": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true,
+ "peer": 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,
+ "peer": 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": {
@@ -43796,6 +44523,31 @@
"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": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true,
+ "peer": 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,
+ "peer": 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": {
@@ -44204,6 +44956,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": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
+ },
+ "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": {
@@ -44760,6 +45533,25 @@
"rimraf": "2"
}
},
+ "isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
+ },
+ "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"
+ }
+ },
"rimraf": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz",
@@ -45120,6 +45912,14 @@
}
}
},
+ "isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "dev": true,
+ "optional": true,
+ "peer": true
+ },
"micromatch": {
"version": "3.1.10",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz",
@@ -45143,6 +45943,23 @@
"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,
+ "peer": 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 713ecec654..37a262fb94 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,7 @@
"@mattermost/react-native-emm": "1.2.3",
"@mattermost/react-native-network-client": "github:mattermost/react-native-network-client",
"@mattermost/react-native-paste-input": "0.4.2",
+ "@msgpack/msgpack": "2.7.2",
"@nozbe/watermelondb": "0.24.0",
"@nozbe/with-observables": "1.4.0",
"@react-native-community/art": "1.2.0",
@@ -40,6 +41,7 @@
"jail-monkey": "2.6.0",
"mime-db": "1.52.0",
"moment-timezone": "0.5.34",
+ "pako": "2.0.4",
"react": "17.0.2",
"react-freeze": "1.0.0",
"react-intl": "6.0.2",
@@ -62,6 +64,7 @@
"react-native-haptic-feedback": "1.13.1",
"react-native-hw-keyboard-event": "0.0.4",
"react-native-image-picker": "4.8.3",
+ "react-native-incall-manager": "github:cpoile/react-native-incall-manager",
"react-native-keyboard-aware-scroll-view": "0.9.5",
"react-native-keyboard-tracking-view": "5.7.0",
"react-native-keychain": "8.0.0",
@@ -80,8 +83,10 @@
"react-native-svg": "12.3.0",
"react-native-vector-icons": "9.1.0",
"react-native-video": "5.2.0",
+ "react-native-webrtc": "github:mattermost/react-native-webrtc",
"react-native-webview": "11.18.2",
"react-syntax-highlighter": "15.5.0",
+ "readable-stream": "3.6.0",
"reanimated-bottom-sheet": "1.0.0-alpha.22",
"rn-placeholder": "3.0.3",
"semver": "7.3.7",
@@ -103,6 +108,7 @@
"@babel/register": "7.17.7",
"@babel/runtime": "7.18.0",
"@react-native-community/eslint-config": "3.0.2",
+ "@testing-library/react-hooks": "8.0.0",
"@testing-library/react-native": "9.1.0",
"@types/base-64": "1.0.0",
"@types/commonmark": "0.27.5",
@@ -120,6 +126,7 @@
"@types/react-native-video": "5.0.13",
"@types/react-syntax-highlighter": "15.5.1",
"@types/react-test-renderer": "18.0.0",
+ "@types/readable-stream": "2.3.13",
"@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 e3f4288a72..6b405d1cfa 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -36,6 +36,7 @@
"@actions/*": ["app/actions/*"],
"@app/*": ["app/*"],
"@assets/*": ["dist/assets/*"],
+ "@calls/*": ["app/products/calls/*"],
"@client/*": ["app/client/*"],
"@components/*": ["app/components/*"],
"@constants": ["app/constants/index"],
diff --git a/types/api/plugins.d.ts b/types/api/plugins.d.ts
new file mode 100644
index 0000000000..d6e8669316
--- /dev/null
+++ b/types/api/plugins.d.ts
@@ -0,0 +1,24 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+type ClientPluginManifest = {
+ id: string;
+ min_server_version?: string;
+ version: string;
+ webapp: {
+ bundle_path: string;
+ };
+}
+
+type MarketplacePlugin = {
+ homepage_url: string;
+ download_url: string;
+ manifest: {
+ id: string;
+ name: string;
+ description: string;
+ version: string;
+ minServerVersion: string;
+ };
+ installed_version: string;
+}
diff --git a/types/modules/react-native-webrtc.d.ts b/types/modules/react-native-webrtc.d.ts
new file mode 100644
index 0000000000..1e0f688276
--- /dev/null
+++ b/types/modules/react-native-webrtc.d.ts
@@ -0,0 +1,278 @@
+// 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-webrtc' {
+ 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 interface EventOnConnectionStateChange {
+ target: {
+ iceConnectionState: RTCIceConnectionState;
+ };
+ }
+
+ export interface ConfigurationParam {
+ username?: string | undefined;
+ credential?: string | undefined;
+ }
+
+ export interface ConfigurationParamWithUrls extends ConfigurationParam {
+ urls: string[];
+ }
+
+ export interface ConfigurationParamWithUrl extends ConfigurationParam {
+ url: string;
+ }
+
+ export interface RTCPeerConnectionConfiguration {
+ iceServers: ConfigurationParamWithUrls[] | ConfigurationParamWithUrl[];
+ iceTransportPolicy?: 'all' | 'relay' | 'nohost' | 'none' | undefined;
+ bundlePolicy?: 'balanced' | 'max-compat' | 'max-bundle' | undefined;
+ rtcpMuxPolicy?: 'negotiate' | 'require' | undefined;
+ iceCandidatePoolSize?: number | undefined;
+ }
+
+ 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;
+
+ addTrack(track: MediaStreamTrack): void;
+
+ addTransceiver(kind: 'audio' | 'video' | MediaStreamTrack, init: any): void;
+
+ removeStream(stream: MediaStream): void;
+
+ removeTrack(sender: RTCRtpSender): 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(cb?: () => void): 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;
+ }
+}