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 f9bd171a54.

* working on typing withServerUrl

* added exportedForInternalUse instead of commenting "internal export"

* better switchToThread in call_screen

* i18n

* typed withServerUrl
This commit is contained in:
Christopher Poile
2022-07-22 15:57:12 -04:00
committed by GitHub
parent 9bbd59e4dd
commit 5bb240dec8
68 changed files with 6815 additions and 143 deletions

View File

@@ -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"
},

View File

@@ -8,6 +8,7 @@
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<queries>
<intent>

View File

@@ -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 {};
}

View File

@@ -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();

View File

@@ -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) {

View File

@@ -0,0 +1,17 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export interface ClientPluginsMix {
getPluginsManifests: () => Promise<ClientPluginManifest[]>;
}
const ClientPlugins = (superclass: any) => class extends superclass {
getPluginsManifests = async () => {
return this.doFetch(
`${this.getPluginsRoute()}/webapp`,
{method: 'get'},
);
};
};
export default ClientPlugins;

View File

@@ -113,6 +113,131 @@ exports[`components/channel_list/categories/body/channel_item should match snaps
</View>
`;
exports[`components/channel_list/categories/body/channel_item should match snapshot when it has a call 1`] = `
<View
accessible={true}
collapsable={false}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
Object {
"opacity": 1,
}
}
>
<View
style={
Array [
Object {
"alignItems": "center",
"flexDirection": "row",
"minHeight": 40,
"paddingHorizontal": 20,
},
false,
undefined,
Object {
"minHeight": 40,
},
]
}
testID="channel_item.hello"
>
<View
style={
Object {
"flex": 1,
"flexDirection": "row",
}
}
>
<View
style={
Array [
Object {
"alignItems": "center",
"justifyContent": "center",
},
Object {
"height": 24,
"width": 24,
},
undefined,
undefined,
]
}
>
<Icon
name="pencil-outline"
style={
Array [
Object {
"color": "rgba(255,255,255,0.4)",
},
undefined,
undefined,
Object {
"fontSize": 24,
"left": 2,
},
]
}
testID="undefined.draft"
/>
</View>
<View>
<Text
ellipsizeMode="tail"
numberOfLines={1}
style={
Array [
Object {
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
},
Object {
"color": "rgba(255,255,255,0.72)",
"marginTop": -1,
"paddingLeft": 12,
"paddingRight": 20,
},
false,
null,
null,
false,
false,
]
}
testID="channel_item.hello.display_name"
>
Hello!
</Text>
</View>
</View>
<Icon
name="phone-in-talk"
size={16}
style={
Object {
"color": "#ffffff",
"flex": 1,
"marginRight": 20,
"textAlign": "right",
}
}
/>
</View>
</View>
`;
exports[`components/channel_list/categories/body/channel_item should match snapshot when it has a draft 1`] = `
<View
accessible={true}

View File

@@ -42,6 +42,7 @@ describe('components/channel_list/categories/body/channel_item', () => {
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(
<ChannelItem
channel={{displayName: 'Hello!', type: 'G', shared: false, name: 'hello', deleteAt: 0} as ChannelModel}
hasDraft={true}
isActive={false}
membersCount={3}
isMuted={false}
currentUserId={'id'}
testID='channel_item'
onPress={() => undefined}
isUnread={myChannel.isUnread}
mentionsCount={myChannel.mentionsCount}
hasMember={Boolean(myChannel)}
hasCall={true}
/>,
);

View File

@@ -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 &&
<CompassIcon
name='phone-in-talk'
size={16}
style={styles.hasCall}
/>
}
</View>
</>
</TouchableOpacity>

View File

@@ -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))));

View File

@@ -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 (
<Text {...props}>
{formattedTime}
</Text>
);
};
export default FormattedRelativeTime;

View File

@@ -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 = (
<CallsCustomMessage
post={post}
/>
);
} else {
body = (
<Body

View File

@@ -21,6 +21,7 @@ type Props = {
iconSize?: number;
size: number;
source?: Source | string;
url?: string;
};
// @ts-expect-error FastImage does work with Animated.createAnimatedComponent
@@ -34,9 +35,11 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
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,

View File

@@ -30,6 +30,7 @@ type ProfilePictureProps = {
statusStyle?: StyleProp<ViewProps>;
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 &&
<Status

15
app/constants/calls.ts Normal file
View File

@@ -0,0 +1,15 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
const RefreshConfigMillis = 20 * 60 * 1000; // Refresh config after 20 minutes
const RequiredServer = {
FULL_VERSION: '6.3.0',
MAJOR_VERSION: 6,
MIN_VERSION: 3,
PATCH_VERSION: 0,
};
const PluginId = 'com.mattermost.calls';
export default {RequiredServer, RefreshConfigMillis, PluginId};

View File

@@ -29,4 +29,6 @@ export default keyMirror({
ITEM_IN_VIEWPORT: null,
SEND_TO_POST_DRAFT: null,
CRT_TOGGLED: null,
JOIN_CALL_BAR_VISIBLE: null,
CURRENT_CALL_BAR_VISIBLE: null,
});

View File

@@ -31,6 +31,7 @@ export const PostTypes: Record<string, string> = {
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;

View File

@@ -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<string>([

View File

@@ -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;

View File

@@ -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;

View File

@@ -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> = C extends React.ComponentType<infer P & WithServerUrlProps> ? P : never
type ServerContext = {
displayName: string;
url: string;
@@ -26,8 +28,8 @@ function ServerUrlProvider({server, children}: Props) {
);
}
export function withServerUrl<T extends WithServerUrlProps>(Component: ComponentType<T>): ComponentType<T> {
return function ServerUrlComponent(props) {
export function withServerUrl<C extends React.ComponentType<P>, P = GetProps<C>>(Component: C) {
return function ServerUrlComponent(props: JSX.LibraryManagedAttributes<C, P>) {
return (
<Consumer>
{(server: ServerContext) => (

View File

@@ -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);
});
});

View File

@@ -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<Call> = {};
const enabledChannels: Dictionary<boolean> = {};
// Batch load userModels async because we'll need them later
const ids = new Set<string>();
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<CallParticipant>),
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);
};

View File

@@ -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';

View File

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

View File

@@ -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<Boolean>;
getCalls: () => Promise<ServerChannelState[]>;
getCallsConfig: () => Promise<ServerConfig>;
enableChannelCalls: (channelId: string) => Promise<ServerChannelState>;
disableChannelCalls: (channelId: string) => Promise<ServerChannelState>;
}
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;

View File

@@ -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 = (
<CompassIcon
name={'monitor'}
size={iconSize}
style={style.screenSharing}
/>
);
} else if (raisedHand) {
topRightIcon = (
<Text style={style.raisedHand}>
{'✋'}
</Text>
);
}
const profile = userModel ? (
<ProfilePicture
author={userModel}
size={profileSize}
showStatus={false}
url={serverUrl}
/>
) : (
<CompassIcon
name='account-outline'
size={profileSize}
/>
);
const view = (
<View style={[style.picture, styleShadow]}>
{profile}
{
muted !== undefined &&
<CompassIcon
name={muted ? 'microphone-off' : 'microphone'}
size={iconSize}
style={style.mute}
/>
}
{topRightIcon}
</View>
);
if (Platform.OS === 'android') {
return (
<View style={style.pictureHalo}>
<View style={style.pictureHalo2}>
{view}
</View>
</View>
);
}
return view;
};
export default CallAvatar;

View File

@@ -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<TextStyle>;
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 (
<Text style={style}>
{formattedTime}
</Text>
);
};
export default CallDuration;

View File

@@ -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 (
<View style={style.messageStyle}>
<CompassIcon
name='phone-hangup'
size={16}
style={style.phoneHangupIcon}
/>
<View style={style.messageText}>
<Text style={style.startedText}>{'Call ended'}</Text>
<View style={style.endCallInfo}>
<Text style={style.timeText}>{'Ended at '}</Text>
{
<FormattedTime
value={post.props.end_at}
isMilitaryTime={isMilitaryTime}
timezone={timezone}
/>
}
<Text style={style.separator}>{'•'}</Text>
<Text style={style.timeText}>
{`Lasted ${moment.duration(post.props.end_at - post.props.start_at).humanize(false)}`}
</Text>
</View>
</View>
</View>
);
}
return (
<View style={style.messageStyle}>
<CompassIcon
name='phone-in-talk'
size={16}
style={style.joinCallIcon}
/>
<View style={style.messageText}>
<Text style={style.startedText}>
{`${displayUsername(author, intl.locale, teammateNameDisplay)} started a call`}
</Text>
<FormattedRelativeTime
value={post.props.start_at}
updateIntervalInSeconds={1}
style={style.timeText}
/>
</View>
<TouchableOpacity
style={style.joinCallButton}
onPress={joinHandler}
>
<CompassIcon
name='phone-outline'
size={16}
style={style.joinCallButtonIcon}
/>
{
alreadyInTheCall &&
<Text style={style.joinCallButtonText}>{'Current call'}</Text>
}
{
!alreadyInTheCall &&
<Text style={style.joinCallButtonText}>{'Join call'}</Text>
}
</TouchableOpacity>
</View>
);
};

View File

@@ -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));

View File

@@ -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<UserModel>;
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<string | null>(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 (
<View style={style.wrapper}>
<View style={style.container}>
<CallAvatar
userModel={userModelsDict[speaker || '']}
volume={speaker ? 0.5 : 0}
serverUrl={currentCall.serverUrl}
/>
<View style={style.userInfo}>
<Text style={style.speakingUser}>
{speaker && `${displayUsername(userModelsDict[speaker], teammateNameDisplay)} is talking`}
{!speaker && 'No one is talking'}
</Text>
<Text style={style.currentChannel}>
{`~${displayName}`}
</Text>
</View>
<Pressable
onPressIn={goToCallScreen}
style={style.pressable}
>
<CompassIcon
name='arrow-expand'
size={24}
style={style.expandIcon}
/>
</Pressable>
<TouchableOpacity
onPress={muteUnmute}
style={style.pressable}
>
<CompassIcon
name={myParticipant?.muted ? 'microphone-off' : 'microphone'}
size={24}
style={[style.micIcon, myParticipant?.muted ? style.muted : undefined]}
/>
</TouchableOpacity>
</View>
</View>
);
};
export default CurrentCallBar;

View File

@@ -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<UserModel>)),
),
);
const teammateNameDisplay = database.pipe(
switchMap((db) => (db ? observeTeammateNameDisplay(db) : of$(''))),
);
return {
displayName,
currentCall,
userModelsDict,
teammateNameDisplay,
};
});
export default enhanced(CurrentCallBar);

View File

@@ -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 (
<View style={[style.wrapper, wrapperTop]}>
{props.children}
</View>
);
};
export default FloatingCallContainer;

View File

@@ -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));

View File

@@ -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 (
<Pressable
style={style.container}
onPress={joinHandler}
>
<CompassIcon
name='phone-in-talk'
size={16}
style={style.joinCallIcon}
/>
<Text style={style.joinCall}>{'Join Call'}</Text>
<Text style={style.started}>
<FormattedRelativeTime
value={channelCallStartTime}
updateIntervalInSeconds={1}
/>
</Text>
<View style={style.avatars}>
<UserAvatarsStack
channelId={channelId}
location={Screens.CHANNEL}
users={participants}
breakAt={1}
/>
</View>
</Pressable>
);
};
export default JoinCallBanner;

View File

@@ -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);
}
}

View File

@@ -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<void>((resolve, reject) => {
waitForReadyImpl(resolve, reject, websocketConnectTimeout);
});
return promise;
};
const connection = {
disconnect,
mute,
unmute,
waitForReady,
raiseHand,
unraiseHand,
} as CallsConnection;
return connection;
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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);
};

View File

@@ -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<ViewStyle>;
svgStyle?: StyleProp<ViewStyle>;
}
export default function RaisedHandIcon({width = 25, height = 27, style, svgStyle, ...props}: Props) {
return (
<View style={style}>
<Svg
{...props}
width={width}
height={height}
viewBox='0 0 25 27'
style={svgStyle}
>
<Path
d='M24.6999 6.15117V21.873C24.6999 23.0918 24.2733 24.1074 23.4202 24.9199C22.5671 25.773 21.5515 26.1996 20.3733 26.1996H12.4515C11.2327 26.1996 10.1968 25.773 9.34365 24.9199L0.87334 16.2668L2.2749 14.9262C2.51865 14.723 2.80303 14.6215 3.12803 14.6215C3.37178 14.6215 3.59521 14.6824 3.79834 14.8043L8.42959 17.4855V4.5668C8.42959 4.11992 8.59209 3.73398 8.91709 3.40898C9.24209 3.08398 9.62803 2.92148 10.0749 2.92148C10.5218 2.92148 10.9077 3.08398 11.2327 3.40898C11.5577 3.73398 11.7202 4.11992 11.7202 4.5668V12.123H12.7562V1.82461C12.7562 1.37773 12.9187 1.01211 13.2437 0.727734C13.5687 0.402734 13.9546 0.240234 14.4015 0.240234C14.8483 0.240234 15.2343 0.402734 15.5593 0.727734C15.8843 1.01211 16.0468 1.37773 16.0468 1.82461V12.123H17.1437V2.92148C17.1437 2.47461 17.2858 2.08867 17.5702 1.76367C17.8952 1.43867 18.2812 1.27617 18.728 1.27617C19.1749 1.27617 19.5608 1.43867 19.8858 1.76367C20.2108 2.08867 20.3733 2.47461 20.3733 2.92148V12.123H21.4702V6.15117C21.4702 5.7043 21.6124 5.33867 21.8968 5.0543C22.2218 4.7293 22.6077 4.5668 23.0546 4.5668C23.5015 4.5668 23.8874 4.7293 24.2124 5.0543C24.5374 5.33867 24.6999 5.7043 24.6999 6.15117Z'
/>
</Svg>
</View>
);
}

View File

@@ -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<ViewStyle>;
svgStyle?: StyleProp<ViewStyle>;
}
export default function UnraisedHandIcon({width = 24, height = 24, style, svgStyle, ...props}: Props) {
return (
<View style={style}>
<Svg
{...props}
width={width}
height={height}
viewBox='0 0 24 24'
style={svgStyle}
>
<Path
d='M20.84 22.73L19.17 21.06C17.7 22.85 15.5 24 13 24C9.74 24 6.81 22 5.6 19L2.57 11.37C2.26 10.58 3 9.79 3.81 10.05L4.6 10.31C5.16 10.5 5.62 10.92 5.84 11.47L7.25 15H8V9.89L1.11 3L2.39 1.73L22.11 21.46L20.84 22.73M14 1.25C14 .56 13.44 0 12.75 0S11.5 .56 11.5 1.25V8.3L14 10.8V1.25M21 16V5.75C21 5.06 20.44 4.5 19.75 4.5S18.5 5.06 18.5 5.75V12H17.5V2.75C17.5 2.06 16.94 1.5 16.25 1.5S15 2.06 15 2.75V11.8L20.83 17.63C20.94 17.11 21 16.56 21 16M10.5 3.25C10.5 2.56 9.94 2 9.25 2S8 2.56 8 3.25V4.8L10.5 7.3V3.25Z'
/>
</Svg>
</View>
);
}

View File

@@ -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<CallParticipant>;
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 (
<View style={style.bottomSheet}>
<SlideUpPanelItem
icon='message-text-outline'
onPress={switchToThread}
text='Chat thread'
/>
</View>
);
};
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 = (
<Pressable
testID='screen-share-container'
style={style.screenShareImage}
onPress={toggleControlsInLandscape}
>
<RTCView
streamURL={currentCall.screenShareURL}
style={style.screenShareImage}
/>
<Text style={style.screenShareText}>
{`You are viewing ${displayUsername(participantsDict[currentCall.screenOn].userModel, teammateNameDisplay)}'s screen`}
</Text>
</Pressable>
);
}
const participants = sortParticipants(teammateNameDisplay, participantsDict, currentCall.screenOn);
let usersList = null;
if (!currentCall.screenOn || !isLandscape) {
usersList = (
<ScrollView
alwaysBounceVertical={false}
horizontal={currentCall?.screenOn !== ''}
contentContainerStyle={[isLandscape && currentCall?.screenOn && style.usersScrollLandscapeScreenOn]}
>
<Pressable
testID='users-list'
onPress={toggleControlsInLandscape}
style={style.users}
>
{participants.map((user) => {
return (
<View
style={[style.user, currentCall?.screenOn && style.userScreenOn]}
key={user.id}
>
<CallAvatar
userModel={user.userModel}
volume={speaker === user.id ? 1 : 0}
muted={user.muted}
sharingScreen={user.id === currentCall.screenOn}
raisedHand={Boolean(user.raisedHand)}
size={currentCall.screenOn ? 'm' : 'l'}
serverUrl={currentCall.serverUrl}
/>
<Text style={style.username}>
{displayUsername(user.userModel, teammateNameDisplay)}
{user.id === myParticipant.id && ' (you)'}
</Text>
</View>
);
})}
</Pressable>
</ScrollView>
);
}
const HandIcon = myParticipant.raisedHand ? UnraisedHandIcon : RaisedHandIcon;
return (
<SafeAreaView style={style.wrapper}>
<View style={style.container}>
<View
style={[style.header, isLandscape && style.headerLandscape, !showControls && style.headerLandscapeNoControls]}
>
<CallDuration
style={style.time}
value={currentCall.startTime}
updateIntervalInSeconds={1}
/>
<Pressable onPress={() => popTopScreen()}>
<CompassIcon
name='arrow-collapse'
size={24}
style={style.collapseIcon}
/>
</Pressable>
</View>
{usersList}
{screenShareView}
<View
style={[style.buttons, isLandscape && style.buttonsLandscape, !showControls && style.buttonsLandscapeNoControls]}
>
{!isLandscape &&
<Pressable
testID='mute-unmute'
style={[style.mute, myParticipant.muted && style.muteMuted]}
onPress={muteUnmuteHandler}
>
<CompassIcon
name={myParticipant.muted ? 'microphone-off' : 'microphone'}
size={24}
style={style.muteIcon}
/>
{myParticipant.muted &&
<Text style={style.buttonText}>{'Unmute'}</Text>}
{!myParticipant.muted &&
<Text style={style.buttonText}>{'Mute'}</Text>}
</Pressable>}
<View style={style.otherButtons}>
<Pressable
testID='leave'
style={style.button}
onPress={leaveCallHandler}
>
<CompassIcon
name='phone-hangup'
size={24}
style={{...style.buttonIcon, ...style.hangUpIcon}}
/>
<Text style={style.buttonText}>{'Leave'}</Text>
</Pressable>
<Pressable
testID={'toggle-speakerphone'}
style={style.button}
onPress={() => setSpeakerphoneOn(!currentCall?.speakerphoneOn)}
>
<CompassIcon
name={'volume-high'}
size={24}
style={[style.buttonIcon, style.speakerphoneIcon, currentCall?.speakerphoneOn && style.speakerphoneIconOn]}
/>
<Text style={style.buttonText}>{'Speaker'}</Text>
</Pressable>
<Pressable
style={style.button}
onPress={toggleRaiseHand}
>
<HandIcon
fill={myParticipant.raisedHand ? 'rgb(255, 188, 66)' : theme.sidebarText}
height={24}
width={24}
style={[style.buttonIcon, style.handIcon, myParticipant.raisedHand && style.handIconRaisedHand]}
svgStyle={style.handIconSvgStyle}
/>
<Text style={style.buttonText}>
{myParticipant.raisedHand ? 'Lower hand' : 'Raise hand'}
</Text>
</Pressable>
<Pressable
style={style.button}
onPress={showOtherActions}
>
<CompassIcon
name='dots-horizontal'
size={24}
style={style.buttonIcon}
/>
<Text
style={style.buttonText}
>{'More'}</Text>
</Pressable>
{isLandscape &&
<Pressable
testID='mute-unmute'
style={style.button}
onPress={muteUnmuteHandler}
>
<CompassIcon
name={myParticipant.muted ? 'microphone-off' : 'microphone'}
size={24}
style={[style.buttonIcon, style.muteIconLandscape, myParticipant?.muted && style.muteIconLandscapeMuted]}
/>
{myParticipant.muted &&
<Text
style={style.buttonText}
>{'Unmute'}</Text>}
{!myParticipant.muted &&
<Text
style={style.buttonText}
>{'Mute'}</Text>}
</Pressable>}
</View>
</View>
</View>
</SafeAreaView>
);
};
export default CallScreen;

View File

@@ -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<CallParticipant>))),
)),
);
const teammateNameDisplay = database.pipe(
switchMap((db) => (db ? observeTeammateNameDisplay(db) : of$(''))),
);
return {
currentCall,
participantsDict,
teammateNameDisplay,
};
});
export default enhanced(CallScreen);

View File

@@ -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});
});
});

View File

@@ -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<Call>, enabled: Dictionary<boolean>) => {
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});
};

View File

@@ -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<BehaviorSubject<CallsConfig>>;
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,
};

View File

@@ -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<BehaviorSubject<CallsState>>;
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,
};

View File

@@ -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<BehaviorSubject<ChannelsWithCalls>>;
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<ChannelsWithCalls>({});
useEffect(() => {
const subscription = getChannelsWithCallsSubject(serverUrl).subscribe((channelsWithCalls) => {
setState(channelsWithCalls);
});
return () => {
subscription?.unsubscribe();
};
}, []);
return state;
};
export const exportedForInternalUse = {
setChannelsWithCalls,
useChannelsWithCalls,
};

View File

@@ -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<CurrentCall | null>(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<CurrentCall | null>(null);
useEffect(() => {
const subscription = currentCallSubject.subscribe((currentCall) => {
setState(currentCall);
});
return () => {
subscription?.unsubscribe();
};
}, []);
return state;
};
export const exportedForInternalUse = {
setCurrentCall,
useCurrentCall,
};

View File

@@ -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';

View File

@@ -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<Call>;
enabled: Dictionary<boolean>;
}
export const DefaultCallsState = {
serverUrl: '',
myUserId: '',
calls: {} as Dictionary<Call>,
enabled: {} as Dictionary<boolean>,
} as CallsState;
export type Call = {
participants: Dictionary<CallParticipant>;
channelId: string;
startTime: number;
screenOn: string;
threadId: string;
}
export const DefaultCall = {
participants: {} as Dictionary<CallParticipant>,
channelId: '',
startTime: 0,
screenOn: '',
threadId: '',
};
export type CurrentCall = {
serverUrl: string;
myUserId: string;
participants: Dictionary<CallParticipant>;
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<boolean>;
export type ServerChannelState = {
channel_id: string;
enabled: boolean;
call?: ServerCallState;
}
export type ServerUserState = {
unmuted: boolean;
raised_hand: number;
}
export type ServerCallState = {
id: string;
start_at: number;
users: string[];
states: ServerUserState[];
thread_id: string;
screen_sharing_id: string;
}
export type VoiceEventData = {
channelId: string;
userId: string;
}
export type CallsConnection = {
disconnect: () => void;
mute: () => void;
unmute: () => void;
waitForReady: () => Promise<void>;
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;

View File

@@ -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<CallParticipant>, 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));
}

View File

@@ -56,6 +56,10 @@ export const queryUsersById = (database: Database, userIds: string[]) => {
return database.get<UserModel>(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<UserModel>(USER).query(Q.where('username', Q.oneOf(usernames)));
};

View File

@@ -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 = (
<FloatingCallContainer>
{isCallInCurrentChannel &&
<JoinCallBanner
serverUrl={serverUrl}
channelId={channelId}
/>
}
{isInCall && <CurrentCallBar/>}
</FloatingCallContainer>
);
}
return (
<FreezeScreen>
<SafeAreaView
@@ -125,6 +147,7 @@ const Channel = ({channelId, componentId}: ChannelProps) => {
/>
</>
}
{callsComponents}
</SafeAreaView>
</FreezeScreen>
);

View File

@@ -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)));

View File

@@ -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) {

View File

@@ -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",

View File

@@ -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',

View File

@@ -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",

View File

@@ -74,7 +74,7 @@
<key>NSLocationWhenInUseUsageDescription</key>
<string>Send your current location to $(PRODUCT_NAME)</string>
<key>NSMicrophoneUsageDescription</key>
<string>Capture audio when recording a video to share within $(PRODUCT_NAME)</string>
<string>Capture audio when making a call or recording a video to share within $(PRODUCT_NAME)</string>
<key>NSMotionUsageDescription</key>
<string>Share your route in your $(PRODUCT_NAME) workspace</string>
<key>NSPhotoLibraryAddUsageDescription</key>
@@ -102,6 +102,7 @@
</array>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
<string>fetch</string>
<string>remote-notification</string>
</array>

View File

@@ -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'

View File

@@ -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

1055
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -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"],

24
types/api/plugins.d.ts vendored Normal file
View File

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

278
types/modules/react-native-webrtc.d.ts vendored Normal file
View File

@@ -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 <https://github.com/KarlosQ>
// 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<boolean>
createOffer(options?: RTCOfferOptions): Promise<RTCSessionDescriptionType>;
createAnswer(options?: RTCAnswerOptions): Promise<RTCSessionDescriptionType>;
setConfiguration(configuration: RTCPeerConnectionConfiguration): void;
setLocalDescription(sessionDescription: RTCSessionDescriptionType): Promise<void>;
setRemoteDescription(sessionDescription: RTCSessionDescriptionType): Promise<void>;
addIceCandidate(candidate: RTCIceCandidateType): Promise<void>;
getStats(selector?: MediaStreamTrack | null): Promise<any>;
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<any>;
static getUserMedia(constraints: MediaStreamConstraints): Promise<MediaStream | boolean>;
}
}