From 5bb240dec880ee0b1165e4c10584df12d94bdaff Mon Sep 17 00:00:00 2001 From: Christopher Poile Date: Fri, 22 Jul 2022 15:57:12 -0400 Subject: [PATCH] MM-43300: Calls v2 first cut (#6475) * Android and iOS requirements * external types * babel config for calls, package.json for calls dependencies * state in rxJS; tests * actions, client/rest, websocket events, constants * webrtc connection logic * calls components / screens * handle peer destroyed gracefully * PR comments * remove ViewPropTypes from mocks; no need to ignore error in LogBox * calls.d.ts -> calls.ts; i18-extract * @app/products/calls -> @calls * PR comments; test cleanup * Revert "remove ViewPropTypes from mocks; no need to ignore error in LogBox" This reverts commit f9bd171a544de9c02da8387455f904b3840fc5dc. * working on typing withServerUrl * added exportedForInternalUse instead of commenting "internal export" * better switchToThread in call_screen * i18n * typed withServerUrl --- .eslintrc.json | 2 +- android/app/src/main/AndroidManifest.xml | 1 + app/actions/websocket/index.ts | 73 +- app/client/rest/base.ts | 9 + app/client/rest/index.ts | 8 +- app/client/rest/plugins.ts | 17 + .../__snapshots__/channel_item.test.tsx.snap | 125 ++ .../channel_item/channel_item.test.tsx | 23 + app/components/channel_item/channel_item.tsx | 17 +- app/components/channel_item/index.ts | 11 +- .../formatted_relative_time/index.tsx | 44 + app/components/post_list/post/post.tsx | 15 +- app/components/profile_picture/image.tsx | 7 +- app/components/profile_picture/index.tsx | 6 +- app/constants/calls.ts | 15 + app/constants/events.ts | 2 + app/constants/post.ts | 1 + app/constants/screens.ts | 2 + app/constants/view.ts | 2 + app/constants/websocket.ts | 17 + app/context/server/index.tsx | 8 +- app/products/calls/actions/calls.test.ts | 287 +++++ app/products/calls/actions/calls.ts | 265 +++++ app/products/calls/actions/index.ts | 18 + app/products/calls/actions/permissions.ts | 66 ++ app/products/calls/client/rest.ts | 56 + app/products/calls/components/call_avatar.tsx | 188 +++ .../calls/components/call_duration.tsx | 54 + .../calls_custom_message.tsx | 186 +++ .../components/calls_custom_message/index.ts | 63 + .../current_call_bar/current_call_bar.tsx | 184 +++ .../components/current_call_bar/index.ts | 48 + .../components/floating_call_container.tsx | 47 + .../components/join_call_banner/index.ts | 49 + .../join_call_banner/join_call_banner.tsx | 139 +++ .../calls/components/leave_and_join_alert.tsx | 47 + app/products/calls/connection/connection.ts | 219 ++++ app/products/calls/connection/simple-peer.ts | 1043 ++++++++++++++++ .../calls/connection/websocket_client.ts | 120 ++ .../connection/websocket_event_handlers.ts | 84 ++ app/products/calls/icons/raised_hand_icon.tsx | 35 + .../calls/icons/unraised_hand_icon.tsx | 35 + .../calls/screens/call_screen/call_screen.tsx | 545 +++++++++ .../calls/screens/call_screen/index.ts | 43 + app/products/calls/state/actions.test.ts | 603 ++++++++++ app/products/calls/state/actions.ts | 276 +++++ app/products/calls/state/calls_config.ts | 52 + app/products/calls/state/calls_state.ts | 52 + .../calls/state/channels_with_calls.ts | 50 + app/products/calls/state/current_call.ts | 42 + app/products/calls/state/index.ts | 8 + app/products/calls/types/calls.ts | 112 ++ app/products/calls/utils.ts | 70 ++ app/queries/servers/user.ts | 4 + app/screens/channel/channel.tsx | 25 +- app/screens/channel/index.tsx | 32 +- app/screens/index.tsx | 3 + assets/base/i18n/en.json | 11 +- babel.config.js | 1 + ios/Mattermost.xcodeproj/project.pbxproj | 2 + ios/Mattermost/Info.plist | 3 +- ios/Podfile | 1 + ios/Podfile.lock | 20 +- package-lock.json | 1055 +++++++++++++++-- package.json | 7 + tsconfig.json | 1 + types/api/plugins.d.ts | 24 + types/modules/react-native-webrtc.d.ts | 278 +++++ 68 files changed, 6815 insertions(+), 143 deletions(-) create mode 100644 app/client/rest/plugins.ts create mode 100644 app/components/formatted_relative_time/index.tsx create mode 100644 app/constants/calls.ts create mode 100644 app/products/calls/actions/calls.test.ts create mode 100644 app/products/calls/actions/calls.ts create mode 100644 app/products/calls/actions/index.ts create mode 100644 app/products/calls/actions/permissions.ts create mode 100644 app/products/calls/client/rest.ts create mode 100644 app/products/calls/components/call_avatar.tsx create mode 100644 app/products/calls/components/call_duration.tsx create mode 100644 app/products/calls/components/calls_custom_message/calls_custom_message.tsx create mode 100644 app/products/calls/components/calls_custom_message/index.ts create mode 100644 app/products/calls/components/current_call_bar/current_call_bar.tsx create mode 100644 app/products/calls/components/current_call_bar/index.ts create mode 100644 app/products/calls/components/floating_call_container.tsx create mode 100644 app/products/calls/components/join_call_banner/index.ts create mode 100644 app/products/calls/components/join_call_banner/join_call_banner.tsx create mode 100644 app/products/calls/components/leave_and_join_alert.tsx create mode 100644 app/products/calls/connection/connection.ts create mode 100644 app/products/calls/connection/simple-peer.ts create mode 100644 app/products/calls/connection/websocket_client.ts create mode 100644 app/products/calls/connection/websocket_event_handlers.ts create mode 100644 app/products/calls/icons/raised_hand_icon.tsx create mode 100644 app/products/calls/icons/unraised_hand_icon.tsx create mode 100644 app/products/calls/screens/call_screen/call_screen.tsx create mode 100644 app/products/calls/screens/call_screen/index.ts create mode 100644 app/products/calls/state/actions.test.ts create mode 100644 app/products/calls/state/actions.ts create mode 100644 app/products/calls/state/calls_config.ts create mode 100644 app/products/calls/state/calls_state.ts create mode 100644 app/products/calls/state/channels_with_calls.ts create mode 100644 app/products/calls/state/current_call.ts create mode 100644 app/products/calls/state/index.ts create mode 100644 app/products/calls/types/calls.ts create mode 100644 app/products/calls/utils.ts create mode 100644 types/api/plugins.d.ts create mode 100644 types/modules/react-native-webrtc.d.ts 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; + } +}