forked from Ivasoft/mattermost-mobile
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:
committed by
GitHub
parent
9bbd59e4dd
commit
5bb240dec8
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
17
app/client/rest/plugins.ts
Normal file
17
app/client/rest/plugins.ts
Normal 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;
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>,
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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))));
|
||||
|
||||
44
app/components/formatted_relative_time/index.tsx
Normal file
44
app/components/formatted_relative_time/index.tsx
Normal 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;
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
15
app/constants/calls.ts
Normal 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};
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>([
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
287
app/products/calls/actions/calls.test.ts
Normal file
287
app/products/calls/actions/calls.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
265
app/products/calls/actions/calls.ts
Normal file
265
app/products/calls/actions/calls.ts
Normal 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);
|
||||
};
|
||||
18
app/products/calls/actions/index.ts
Normal file
18
app/products/calls/actions/index.ts
Normal 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';
|
||||
66
app/products/calls/actions/permissions.ts
Normal file
66
app/products/calls/actions/permissions.ts
Normal 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;
|
||||
};
|
||||
|
||||
56
app/products/calls/client/rest.ts
Normal file
56
app/products/calls/client/rest.ts
Normal 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;
|
||||
188
app/products/calls/components/call_avatar.tsx
Normal file
188
app/products/calls/components/call_avatar.tsx
Normal 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;
|
||||
54
app/products/calls/components/call_duration.tsx
Normal file
54
app/products/calls/components/call_duration.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
63
app/products/calls/components/calls_custom_message/index.ts
Normal file
63
app/products/calls/components/calls_custom_message/index.ts
Normal 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));
|
||||
@@ -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;
|
||||
48
app/products/calls/components/current_call_bar/index.ts
Normal file
48
app/products/calls/components/current_call_bar/index.ts
Normal 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);
|
||||
47
app/products/calls/components/floating_call_container.tsx
Normal file
47
app/products/calls/components/floating_call_container.tsx
Normal 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;
|
||||
49
app/products/calls/components/join_call_banner/index.ts
Normal file
49
app/products/calls/components/join_call_banner/index.ts
Normal 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));
|
||||
@@ -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;
|
||||
47
app/products/calls/components/leave_and_join_alert.tsx
Normal file
47
app/products/calls/components/leave_and_join_alert.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
219
app/products/calls/connection/connection.ts
Normal file
219
app/products/calls/connection/connection.ts
Normal 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;
|
||||
}
|
||||
1043
app/products/calls/connection/simple-peer.ts
Normal file
1043
app/products/calls/connection/simple-peer.ts
Normal file
File diff suppressed because it is too large
Load Diff
120
app/products/calls/connection/websocket_client.ts
Normal file
120
app/products/calls/connection/websocket_client.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
84
app/products/calls/connection/websocket_event_handlers.ts
Normal file
84
app/products/calls/connection/websocket_event_handlers.ts
Normal 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);
|
||||
};
|
||||
35
app/products/calls/icons/raised_hand_icon.tsx
Normal file
35
app/products/calls/icons/raised_hand_icon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
35
app/products/calls/icons/unraised_hand_icon.tsx
Normal file
35
app/products/calls/icons/unraised_hand_icon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
545
app/products/calls/screens/call_screen/call_screen.tsx
Normal file
545
app/products/calls/screens/call_screen/call_screen.tsx
Normal 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;
|
||||
43
app/products/calls/screens/call_screen/index.ts
Normal file
43
app/products/calls/screens/call_screen/index.ts
Normal 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);
|
||||
603
app/products/calls/state/actions.test.ts
Normal file
603
app/products/calls/state/actions.test.ts
Normal 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});
|
||||
});
|
||||
});
|
||||
276
app/products/calls/state/actions.ts
Normal file
276
app/products/calls/state/actions.ts
Normal 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});
|
||||
};
|
||||
52
app/products/calls/state/calls_config.ts
Normal file
52
app/products/calls/state/calls_config.ts
Normal 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,
|
||||
};
|
||||
52
app/products/calls/state/calls_state.ts
Normal file
52
app/products/calls/state/calls_state.ts
Normal 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,
|
||||
};
|
||||
50
app/products/calls/state/channels_with_calls.ts
Normal file
50
app/products/calls/state/channels_with_calls.ts
Normal 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,
|
||||
};
|
||||
42
app/products/calls/state/current_call.ts
Normal file
42
app/products/calls/state/current_call.ts
Normal 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,
|
||||
};
|
||||
8
app/products/calls/state/index.ts
Normal file
8
app/products/calls/state/index.ts
Normal 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';
|
||||
112
app/products/calls/types/calls.ts
Normal file
112
app/products/calls/types/calls.ts
Normal 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;
|
||||
70
app/products/calls/utils.ts
Normal file
70
app/products/calls/utils.ts
Normal 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));
|
||||
}
|
||||
@@ -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)));
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
1055
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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
24
types/api/plugins.d.ts
vendored
Normal 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
278
types/modules/react-native-webrtc.d.ts
vendored
Normal 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>;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user