forked from Ivasoft/mattermost-mobile
Merge branch 'gekidou' of https://github.com/mattermost/mattermost-mobile into MM-39720
This commit is contained in:
@@ -145,7 +145,7 @@ android {
|
||||
applicationId "com.mattermost.rnbeta"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 430
|
||||
versionCode 432
|
||||
versionName "2.0.0"
|
||||
testBuildType System.getProperty('testBuildType', 'debug')
|
||||
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
|
||||
|
||||
@@ -38,6 +38,20 @@ buildscript {
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
exclusiveContent {
|
||||
// We get React Native's Android binaries exclusively through npm,
|
||||
// from a local Maven repo inside node_modules/react-native/.
|
||||
// (The use of exclusiveContent prevents looking elsewhere like Maven Central
|
||||
// and potentially getting a wrong version.)
|
||||
filter {
|
||||
includeGroup "com.facebook.react"
|
||||
}
|
||||
forRepository {
|
||||
maven {
|
||||
url "$rootDir/../node_modules/react-native/android"
|
||||
}
|
||||
}
|
||||
}
|
||||
google()
|
||||
mavenCentral()
|
||||
mavenLocal()
|
||||
|
||||
@@ -179,12 +179,13 @@ async function doReconnect(serverUrl: string) {
|
||||
|
||||
const {id: currentUserId, locale: currentUserLocale} = (await getCurrentUser(database))!;
|
||||
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);
|
||||
}
|
||||
|
||||
await deferredAppEntryActions(serverUrl, lastDisconnectedAt, currentUserId, currentUserLocale, prefData.preferences, config, license, teamData, chData, initialTeamId, switchedToChannel ? initialChannelId : undefined);
|
||||
|
||||
AppsManager.refreshAppBindings(serverUrl);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import withObservables from '@nozbe/with-observables';
|
||||
import {of as of$} from 'rxjs';
|
||||
|
||||
import AppsManager from '@managers/apps_manager';
|
||||
|
||||
@@ -12,7 +13,7 @@ type OwnProps = {
|
||||
}
|
||||
|
||||
const enhanced = withObservables(['serverUrl'], ({serverUrl}: OwnProps) => ({
|
||||
isAppsEnabled: serverUrl ? AppsManager.observeIsAppsEnabled(serverUrl) : false,
|
||||
isAppsEnabled: serverUrl ? AppsManager.observeIsAppsEnabled(serverUrl) : of$(false),
|
||||
}));
|
||||
|
||||
export default enhanced(Autocomplete);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import React, {forwardRef} from 'react';
|
||||
import Animated, {useAnimatedStyle, useDerivedValue} from 'react-native-reanimated';
|
||||
|
||||
import {SEARCH_INPUT_HEIGHT, SEARCH_INPUT_MARGIN} from '@constants/view';
|
||||
@@ -14,7 +14,7 @@ import Header, {HeaderRightButton} from './header';
|
||||
import NavigationHeaderLargeTitle from './large';
|
||||
import NavigationSearch from './search';
|
||||
|
||||
import type {SearchProps} from '@components/search';
|
||||
import type {SearchProps, SearchRef} from '@components/search';
|
||||
|
||||
type Props = SearchProps & {
|
||||
hasSearch?: boolean;
|
||||
@@ -41,7 +41,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const NavigationHeader = ({
|
||||
const NavigationHeader = forwardRef<SearchRef, Props>(({
|
||||
hasSearch = false,
|
||||
isLargeTitle = false,
|
||||
leftComponent,
|
||||
@@ -56,7 +56,7 @@ const NavigationHeader = ({
|
||||
title = '',
|
||||
hideHeader,
|
||||
...searchProps
|
||||
}: Props) => {
|
||||
}: Props, ref) => {
|
||||
const theme = useTheme();
|
||||
const styles = getStyleSheet(theme);
|
||||
|
||||
@@ -125,12 +125,14 @@ const NavigationHeader = ({
|
||||
hideHeader={hideHeader}
|
||||
theme={theme}
|
||||
topStyle={searchTopStyle}
|
||||
ref={ref}
|
||||
/>
|
||||
}
|
||||
</Animated.View>
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
NavigationHeader.displayName = 'NavHeader';
|
||||
export default NavigationHeader;
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useEffect, useMemo} from 'react';
|
||||
import React, {forwardRef, useCallback, useEffect, useMemo} from 'react';
|
||||
import {DeviceEventEmitter, Keyboard, NativeSyntheticEvent, Platform, TextInputFocusEventData, ViewStyle} from 'react-native';
|
||||
import Animated, {AnimatedStyleProp} from 'react-native-reanimated';
|
||||
|
||||
import Search, {SearchProps} from '@components/search';
|
||||
import Search, {SearchProps, SearchRef} from '@components/search';
|
||||
import {Events} from '@constants';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
@@ -31,12 +31,12 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const NavigationSearch = ({
|
||||
const NavigationSearch = forwardRef<SearchRef, Props>(({
|
||||
hideHeader,
|
||||
theme,
|
||||
topStyle,
|
||||
...searchProps
|
||||
}: Props) => {
|
||||
}: Props, ref) => {
|
||||
const styles = getStyleSheet(theme);
|
||||
|
||||
const cancelButtonProps: SearchProps['cancelButtonProps'] = useMemo(() => ({
|
||||
@@ -52,24 +52,27 @@ const NavigationSearch = ({
|
||||
searchProps.onFocus?.(e);
|
||||
}, [hideHeader, searchProps.onFocus]);
|
||||
|
||||
useEffect(() => {
|
||||
const show = Keyboard.addListener('keyboardDidShow', () => {
|
||||
if (Platform.OS === 'android') {
|
||||
DeviceEventEmitter.emit(Events.TAB_BAR_VISIBLE, false);
|
||||
}
|
||||
});
|
||||
const showEmitter = useCallback(() => {
|
||||
if (Platform.OS === 'android') {
|
||||
DeviceEventEmitter.emit(Events.TAB_BAR_VISIBLE, false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const hide = Keyboard.addListener('keyboardDidHide', () => {
|
||||
if (Platform.OS === 'android') {
|
||||
DeviceEventEmitter.emit(Events.TAB_BAR_VISIBLE, true);
|
||||
}
|
||||
});
|
||||
const hideEmitter = useCallback(() => {
|
||||
if (Platform.OS === 'android') {
|
||||
DeviceEventEmitter.emit(Events.TAB_BAR_VISIBLE, true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const show = Keyboard.addListener('keyboardDidShow', showEmitter);
|
||||
const hide = Keyboard.addListener('keyboardDidHide', hideEmitter);
|
||||
|
||||
return () => {
|
||||
hide.remove();
|
||||
show.remove();
|
||||
};
|
||||
}, []);
|
||||
}, [hideEmitter, showEmitter]);
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.container, topStyle]}>
|
||||
@@ -83,10 +86,12 @@ const NavigationSearch = ({
|
||||
placeholderTextColor={changeOpacity(theme.sidebarText, Platform.select({android: 0.56, default: 0.72}))}
|
||||
searchIconColor={theme.sidebarText}
|
||||
selectionColor={theme.sidebarText}
|
||||
ref={ref}
|
||||
/>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
NavigationSearch.displayName = 'NavSearch';
|
||||
export default NavigationSearch;
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ export default function FileQuickAction({
|
||||
>
|
||||
<CompassIcon
|
||||
color={color}
|
||||
name='file-generic-outline'
|
||||
name='paperclip'
|
||||
size={ICON_SIZE}
|
||||
/>
|
||||
</TouchableWithFeedback>
|
||||
|
||||
@@ -77,7 +77,7 @@ exports[`ThreadOverview should match snapshot when post is not saved and 0 repli
|
||||
}
|
||||
>
|
||||
<Icon
|
||||
color="#386fe5"
|
||||
color="#1C58D9"
|
||||
name="bookmark"
|
||||
size={24}
|
||||
/>
|
||||
|
||||
@@ -42,7 +42,7 @@ export type SearchProps = TextInputProps & {
|
||||
showLoading?: boolean;
|
||||
};
|
||||
|
||||
type SearchRef = {
|
||||
export type SearchRef = {
|
||||
blur: () => void;
|
||||
cancel: () => void;
|
||||
clear: () => void;
|
||||
@@ -151,7 +151,6 @@ const Search = forwardRef<SearchRef, SearchProps>((props: SearchProps, ref) => {
|
||||
focus: () => {
|
||||
searchRef.current?.focus();
|
||||
},
|
||||
|
||||
}), [searchRef]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -58,7 +58,7 @@ function RadioSetting({
|
||||
isSelected={value === entryValue}
|
||||
text={text}
|
||||
value={entryValue}
|
||||
key={value}
|
||||
key={entryValue}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,11 +35,11 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
},
|
||||
containerSelected: {
|
||||
borderWidth: 3,
|
||||
borderRadius: 12,
|
||||
borderRadius: 14,
|
||||
borderColor: theme.sidebarTextActiveBorder,
|
||||
},
|
||||
unread: {
|
||||
left: 40,
|
||||
left: 43,
|
||||
top: 3,
|
||||
},
|
||||
mentionsOneDigit: {
|
||||
@@ -102,7 +102,7 @@ export default function TeamItem({team, hasUnreads, mentionCount, selected}: Pro
|
||||
</TouchableWithFeedback>
|
||||
</View>
|
||||
<Badge
|
||||
borderColor={theme.sidebarTeamBarBg}
|
||||
borderColor={theme.sidebarHeaderBg}
|
||||
visible={hasBadge && !selected}
|
||||
style={badgeStyle}
|
||||
value={value}
|
||||
|
||||
@@ -31,5 +31,4 @@ export default keyMirror({
|
||||
SEND_TO_POST_DRAFT: null,
|
||||
CRT_TOGGLED: null,
|
||||
JOIN_CALL_BAR_VISIBLE: null,
|
||||
CURRENT_CALL_BAR_VISIBLE: null,
|
||||
});
|
||||
|
||||
@@ -68,7 +68,7 @@ const Preferences: Record<string, any> = {
|
||||
centerChannelBg: '#ffffff',
|
||||
centerChannelColor: '#3f4350',
|
||||
newMessageSeparator: '#cc8f00',
|
||||
linkColor: '#386fe5',
|
||||
linkColor: '#1C58D9',
|
||||
buttonBg: '#1c58d9',
|
||||
buttonColor: '#ffffff',
|
||||
errorTextColor: '#d24b4e',
|
||||
|
||||
@@ -23,6 +23,7 @@ export const SEARCH_INPUT_MARGIN = 5;
|
||||
|
||||
export const JOIN_CALL_BAR_HEIGHT = 38;
|
||||
export const CURRENT_CALL_BAR_HEIGHT = 74;
|
||||
export const CALL_ERROR_BAR_HEIGHT = 62;
|
||||
|
||||
export const QUICK_OPTIONS_HEIGHT = 270;
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ describe('Actions.Calls', () => {
|
||||
|
||||
let response: { data?: string };
|
||||
await act(async () => {
|
||||
response = await CallsActions.joinCall('server1', 'channel-id');
|
||||
response = await CallsActions.joinCall('server1', 'channel-id', true);
|
||||
userJoinedCall('server1', 'channel-id', 'myUserId');
|
||||
});
|
||||
|
||||
@@ -163,7 +163,7 @@ describe('Actions.Calls', () => {
|
||||
|
||||
let response: { data?: string };
|
||||
await act(async () => {
|
||||
response = await CallsActions.joinCall('server1', 'channel-id');
|
||||
response = await CallsActions.joinCall('server1', 'channel-id', true);
|
||||
userJoinedCall('server1', 'channel-id', 'myUserId');
|
||||
});
|
||||
assert.equal(response!.data, 'channel-id');
|
||||
@@ -191,7 +191,7 @@ describe('Actions.Calls', () => {
|
||||
|
||||
let response: { data?: string };
|
||||
await act(async () => {
|
||||
response = await CallsActions.joinCall('server1', 'channel-id');
|
||||
response = await CallsActions.joinCall('server1', 'channel-id', true);
|
||||
userJoinedCall('server1', 'channel-id', 'myUserId');
|
||||
});
|
||||
assert.equal(response!.data, 'channel-id');
|
||||
@@ -218,7 +218,7 @@ describe('Actions.Calls', () => {
|
||||
|
||||
let response: { data?: string };
|
||||
await act(async () => {
|
||||
response = await CallsActions.joinCall('server1', 'channel-id');
|
||||
response = await CallsActions.joinCall('server1', 'channel-id', true);
|
||||
userJoinedCall('server1', 'channel-id', 'myUserId');
|
||||
});
|
||||
assert.equal(response!.data, 'channel-id');
|
||||
|
||||
@@ -218,7 +218,7 @@ export const enableChannelCalls = async (serverUrl: string, channelId: string, e
|
||||
return {};
|
||||
};
|
||||
|
||||
export const joinCall = async (serverUrl: string, channelId: string): Promise<{ error?: string | Error; data?: string }> => {
|
||||
export const joinCall = async (serverUrl: string, channelId: string, hasMicPermission: boolean): Promise<{ error?: string | Error; data?: string }> => {
|
||||
// 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);
|
||||
@@ -233,7 +233,7 @@ export const joinCall = async (serverUrl: string, channelId: string): Promise<{
|
||||
setSpeakerphoneOn(false);
|
||||
|
||||
try {
|
||||
connection = await newConnection(serverUrl, channelId, () => null, setScreenShareURL);
|
||||
connection = await newConnection(serverUrl, channelId, () => null, setScreenShareURL, hasMicPermission);
|
||||
} catch (error: unknown) {
|
||||
await forceLogoutIfNecessary(serverUrl, error as ClientError);
|
||||
return {error: error as Error};
|
||||
@@ -270,6 +270,12 @@ export const unmuteMyself = () => {
|
||||
}
|
||||
};
|
||||
|
||||
export const initializeVoiceTrack = () => {
|
||||
if (connection) {
|
||||
connection.initializeVoiceTrack();
|
||||
}
|
||||
};
|
||||
|
||||
export const raiseHand = () => {
|
||||
if (connection) {
|
||||
connection.raiseHand();
|
||||
|
||||
@@ -1,28 +1,10 @@
|
||||
// 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 {Platform} from 'react-native';
|
||||
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) => {
|
||||
export const hasMicrophonePermission = async () => {
|
||||
const targetSource = Platform.select({
|
||||
ios: Permissions.PERMISSIONS.IOS.MICROPHONE,
|
||||
default: Permissions.PERMISSIONS.ANDROID.RECORD_AUDIO,
|
||||
@@ -36,32 +18,8 @@ export const hasMicrophonePermission = async (intl: IntlShape) => {
|
||||
|
||||
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',
|
||||
}),
|
||||
},
|
||||
],
|
||||
);
|
||||
case Permissions.RESULTS.BLOCKED:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import {Alert} from 'react-native';
|
||||
|
||||
import {hasMicrophonePermission, joinCall, unmuteMyself} from '@calls/actions';
|
||||
import {setMicPermissionsGranted} from '@calls/state';
|
||||
import {errorAlert} from '@calls/utils';
|
||||
|
||||
import type {IntlShape} from 'react-intl';
|
||||
@@ -89,16 +90,10 @@ export const leaveAndJoinWithAlert = (
|
||||
const doJoinCall = async (serverUrl: string, channelId: string, isDMorGM: boolean, intl: IntlShape) => {
|
||||
const {formatMessage} = intl;
|
||||
|
||||
const hasPermission = await hasMicrophonePermission(intl);
|
||||
if (!hasPermission) {
|
||||
errorAlert(formatMessage({
|
||||
id: 'mobile.calls_error_permissions',
|
||||
defaultMessage: 'No permissions to microphone, unable to start call',
|
||||
}), intl);
|
||||
return;
|
||||
}
|
||||
const hasPermission = await hasMicrophonePermission();
|
||||
setMicPermissionsGranted(hasPermission);
|
||||
|
||||
const res = await joinCall(serverUrl, channelId);
|
||||
const res = await joinCall(serverUrl, channelId, hasPermission);
|
||||
if (res.error) {
|
||||
const seeLogs = formatMessage({id: 'mobile.calls_see_logs', defaultMessage: 'See server logs'});
|
||||
errorAlert(res.error?.toString() || seeLogs, intl);
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useEffect, useState} from 'react';
|
||||
import React, {useCallback} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {View, Text, TouchableOpacity, Pressable, Platform, DeviceEventEmitter} from 'react-native';
|
||||
import {View, Text, TouchableOpacity, Pressable, Platform} 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 PermissionErrorBar from '@calls/components/permission_error_bar';
|
||||
import UnavailableIconWrapper from '@calls/components/unavailable_icon_wrapper';
|
||||
import {usePermissionsChecker} from '@calls/hooks';
|
||||
import {CurrentCall} from '@calls/types/calls';
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import {Events, Screens, WebsocketEvents} from '@constants';
|
||||
import {Screens} from '@constants';
|
||||
import {CURRENT_CALL_BAR_HEIGHT} from '@constants/view';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {dismissAllModalsAndPopToScreen} from '@screens/navigation';
|
||||
@@ -24,6 +27,7 @@ type Props = {
|
||||
currentCall: CurrentCall | null;
|
||||
userModelsDict: Dictionary<UserModel>;
|
||||
teammateNameDisplay: string;
|
||||
micPermissionsGranted: boolean;
|
||||
threadScreen?: boolean;
|
||||
}
|
||||
|
||||
@@ -57,18 +61,18 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
color: theme.sidebarText,
|
||||
opacity: 0.64,
|
||||
},
|
||||
micIcon: {
|
||||
color: theme.sidebarText,
|
||||
micIconContainer: {
|
||||
width: 42,
|
||||
height: 42,
|
||||
textAlign: 'center',
|
||||
textAlignVertical: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#3DB887',
|
||||
alignItems: 'center',
|
||||
backgroundColor: theme.onlineIndicator,
|
||||
borderRadius: 4,
|
||||
margin: 4,
|
||||
padding: 9,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
micIcon: {
|
||||
color: theme.sidebarText,
|
||||
},
|
||||
muted: {
|
||||
backgroundColor: 'transparent',
|
||||
@@ -86,49 +90,13 @@ const CurrentCallBar = ({
|
||||
currentCall,
|
||||
userModelsDict,
|
||||
teammateNameDisplay,
|
||||
micPermissionsGranted,
|
||||
threadScreen,
|
||||
}: Props) => {
|
||||
const theme = useTheme();
|
||||
const style = getStyleSheet(theme);
|
||||
const {formatMessage} = useIntl();
|
||||
const [speaker, setSpeaker] = useState<string | null>(null);
|
||||
const [talkingMessage, setTalkingMessage] = useState('');
|
||||
|
||||
const isCurrentCall = Boolean(currentCall);
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (speaker) {
|
||||
setTalkingMessage(formatMessage({
|
||||
id: 'mobile.calls_name_is_talking',
|
||||
defaultMessage: '{name} is talking',
|
||||
}, {name: displayUsername(userModelsDict[speaker], teammateNameDisplay)}));
|
||||
} else {
|
||||
setTalkingMessage(formatMessage({
|
||||
id: 'mobile.calls_noone_talking',
|
||||
defaultMessage: 'No one is talking',
|
||||
}));
|
||||
}
|
||||
}, [speaker, setTalkingMessage]);
|
||||
usePermissionsChecker(micPermissionsGranted);
|
||||
|
||||
const goToCallScreen = useCallback(async () => {
|
||||
const options: Options = {
|
||||
@@ -150,6 +118,21 @@ const CurrentCallBar = ({
|
||||
|
||||
const myParticipant = currentCall?.participants[currentCall.myUserId];
|
||||
|
||||
// Since we can only see one user talking, it doesn't really matter who we show here (e.g., we can't
|
||||
// tell who is speaking louder).
|
||||
const talkingUsers = Object.keys(currentCall?.voiceOn || {});
|
||||
const speaker = talkingUsers.length > 0 ? talkingUsers[0] : '';
|
||||
let talkingMessage = formatMessage({
|
||||
id: 'mobile.calls_noone_talking',
|
||||
defaultMessage: 'No one is talking',
|
||||
});
|
||||
if (speaker) {
|
||||
talkingMessage = formatMessage({
|
||||
id: 'mobile.calls_name_is_talking',
|
||||
defaultMessage: '{name} is talking',
|
||||
}, {name: displayUsername(userModelsDict[speaker], teammateNameDisplay)});
|
||||
}
|
||||
|
||||
const muteUnmute = () => {
|
||||
if (myParticipant?.muted) {
|
||||
unmuteMyself();
|
||||
@@ -158,42 +141,48 @@ const CurrentCallBar = ({
|
||||
}
|
||||
};
|
||||
|
||||
const style = getStyleSheet(theme);
|
||||
const micPermissionsError = !micPermissionsGranted && !currentCall?.micPermissionsErrorDismissed;
|
||||
|
||||
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}>{talkingMessage}</Text>
|
||||
<Text style={style.currentChannel}>{`~${displayName}`}</Text>
|
||||
<>
|
||||
<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}>{talkingMessage}</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, style.micIconContainer, myParticipant?.muted && style.muted]}
|
||||
disabled={!micPermissionsGranted}
|
||||
>
|
||||
<UnavailableIconWrapper
|
||||
name={myParticipant?.muted ? 'microphone-off' : 'microphone'}
|
||||
size={24}
|
||||
unavailable={!micPermissionsGranted}
|
||||
style={[style.micIcon]}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</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>
|
||||
{micPermissionsError && <PermissionErrorBar/>}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CurrentCallBar;
|
||||
|
||||
@@ -5,7 +5,7 @@ import withObservables from '@nozbe/with-observables';
|
||||
import {combineLatest, of as of$} from 'rxjs';
|
||||
import {distinctUntilChanged, switchMap} from 'rxjs/operators';
|
||||
|
||||
import {observeCurrentCall} from '@calls/state';
|
||||
import {observeCurrentCall, observeGlobalCallsState} from '@calls/state';
|
||||
import {idsAreEqual} from '@calls/utils';
|
||||
import DatabaseManager from '@database/manager';
|
||||
import {observeChannel} from '@queries/servers/channel';
|
||||
@@ -45,12 +45,17 @@ const enhanced = withObservables([], () => {
|
||||
const teammateNameDisplay = database.pipe(
|
||||
switchMap((db) => (db ? observeTeammateNameDisplay(db) : of$(''))),
|
||||
);
|
||||
const micPermissionsGranted = observeGlobalCallsState().pipe(
|
||||
switchMap((gs) => of$(gs.micPermissionsGranted)),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
|
||||
return {
|
||||
displayName,
|
||||
currentCall,
|
||||
userModelsDict,
|
||||
teammateNameDisplay,
|
||||
micPermissionsGranted,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
104
app/products/calls/components/permission_error_bar.tsx
Normal file
104
app/products/calls/components/permission_error_bar.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {Pressable, View} from 'react-native';
|
||||
import Permissions from 'react-native-permissions';
|
||||
|
||||
import {setMicPermissionsErrorDismissed} from '@calls/state';
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import {CALL_ERROR_BAR_HEIGHT} from '@constants/view';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {typography} from '@utils/typography';
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => (
|
||||
{
|
||||
pressable: {
|
||||
zIndex: 10,
|
||||
},
|
||||
errorWrapper: {
|
||||
padding: 10,
|
||||
paddingTop: 0,
|
||||
},
|
||||
errorBar: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: theme.dndIndicator,
|
||||
minHeight: CALL_ERROR_BAR_HEIGHT,
|
||||
width: '100%',
|
||||
borderRadius: 5,
|
||||
padding: 10,
|
||||
alignItems: 'center',
|
||||
},
|
||||
errorText: {
|
||||
flex: 1,
|
||||
...typography('Body', 100, 'SemiBold'),
|
||||
color: theme.buttonColor,
|
||||
},
|
||||
errorIconContainer: {
|
||||
width: 42,
|
||||
height: 42,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: 4,
|
||||
margin: 0,
|
||||
padding: 9,
|
||||
},
|
||||
pressedErrorIconContainer: {
|
||||
backgroundColor: theme.buttonColor,
|
||||
},
|
||||
errorIcon: {
|
||||
color: theme.buttonColor,
|
||||
fontSize: 18,
|
||||
},
|
||||
pressedErrorIcon: {
|
||||
color: theme.dndIndicator,
|
||||
},
|
||||
paddingRight: {
|
||||
paddingRight: 9,
|
||||
},
|
||||
}
|
||||
));
|
||||
|
||||
const PermissionErrorBar = () => {
|
||||
const theme = useTheme();
|
||||
const style = getStyleSheet(theme);
|
||||
|
||||
return (
|
||||
<View style={style.errorWrapper}>
|
||||
<Pressable
|
||||
onPress={Permissions.openSettings}
|
||||
style={style.errorBar}
|
||||
>
|
||||
<CompassIcon
|
||||
name='microphone-off'
|
||||
style={[style.errorIcon, style.paddingRight]}
|
||||
/>
|
||||
<FormattedText
|
||||
id={'mobile.calls_mic_error'}
|
||||
defaultMessage={'To participate, open Settings to grant Mattermost access to your microphone.'}
|
||||
style={style.errorText}
|
||||
/>
|
||||
<Pressable
|
||||
onPress={setMicPermissionsErrorDismissed}
|
||||
hitSlop={5}
|
||||
style={({pressed}) => [
|
||||
style.pressable,
|
||||
style.errorIconContainer,
|
||||
pressed && style.pressedErrorIconContainer,
|
||||
]}
|
||||
>
|
||||
{({pressed}) => (
|
||||
<CompassIcon
|
||||
name='close'
|
||||
style={[style.errorIcon, pressed && style.pressedErrorIcon]}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default PermissionErrorBar;
|
||||
68
app/products/calls/components/unavailable_icon_wrapper.tsx
Normal file
68
app/products/calls/components/unavailable_icon_wrapper.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {StyleProp, TextStyle, View} from 'react-native';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
type Props = {
|
||||
name: string;
|
||||
size: number;
|
||||
style: StyleProp<TextStyle>;
|
||||
unavailable: boolean;
|
||||
}
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
return {
|
||||
container: {
|
||||
position: 'relative',
|
||||
},
|
||||
unavailable: {
|
||||
color: changeOpacity(theme.sidebarText, 0.32),
|
||||
},
|
||||
errorContainer: {
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
backgroundColor: '#3F4350',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 0.5,
|
||||
borderColor: '#3F4350',
|
||||
},
|
||||
errorIcon: {
|
||||
color: theme.dndIndicator,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const UnavailableIconWrapper = ({name, size, style: providedStyle, unavailable}: Props) => {
|
||||
const theme = useTheme();
|
||||
const style = getStyleSheet(theme);
|
||||
const errorIconSize = size / 2;
|
||||
|
||||
return (
|
||||
<View style={style.container}>
|
||||
<CompassIcon
|
||||
name={name}
|
||||
size={size}
|
||||
style={[providedStyle, unavailable && style.unavailable]}
|
||||
/>
|
||||
{unavailable &&
|
||||
<View
|
||||
style={[style.errorContainer, {borderRadius: errorIconSize / 2}]}
|
||||
>
|
||||
<CompassIcon
|
||||
name={'close-circle'}
|
||||
size={errorIconSize}
|
||||
style={style.errorIcon}
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnavailableIconWrapper;
|
||||
@@ -25,7 +25,13 @@ import type {CallsConnection} from '@calls/types/calls';
|
||||
|
||||
const peerConnectTimeout = 5000;
|
||||
|
||||
export async function newConnection(serverUrl: string, channelID: string, closeCb: () => void, setScreenShareURL: (url: string) => void) {
|
||||
export async function newConnection(
|
||||
serverUrl: string,
|
||||
channelID: string,
|
||||
closeCb: () => void,
|
||||
setScreenShareURL: (url: string) => void,
|
||||
hasMicPermission: boolean,
|
||||
) {
|
||||
let peer: Peer | null = null;
|
||||
let stream: MediaStream;
|
||||
let voiceTrackAdded = false;
|
||||
@@ -34,17 +40,23 @@ export async function newConnection(serverUrl: string, channelID: string, closeC
|
||||
let onCallEnd: EmitterSubscription | null = null;
|
||||
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) {
|
||||
logError('Unable to get media device:', err);
|
||||
}
|
||||
const initializeVoiceTrack = async () => {
|
||||
if (voiceTrack) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
stream = await mediaDevices.getUserMedia({
|
||||
video: false,
|
||||
audio: true,
|
||||
}) as MediaStream;
|
||||
voiceTrack = stream.getAudioTracks()[0];
|
||||
voiceTrack.enabled = false;
|
||||
streams.push(stream);
|
||||
} catch (err) {
|
||||
logError('Unable to get media device:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// getClient can throw an error, which will be handled by the caller.
|
||||
const client = NetworkManager.getClient(serverUrl);
|
||||
@@ -56,6 +68,10 @@ export async function newConnection(serverUrl: string, channelID: string, closeC
|
||||
// Throws an error, to be caught by caller.
|
||||
await ws.initialize();
|
||||
|
||||
if (hasMicPermission) {
|
||||
initializeVoiceTrack();
|
||||
}
|
||||
|
||||
const disconnect = () => {
|
||||
if (isClosed) {
|
||||
return;
|
||||
@@ -265,6 +281,7 @@ export async function newConnection(serverUrl: string, channelID: string, closeC
|
||||
waitForPeerConnection,
|
||||
raiseHand,
|
||||
unraiseHand,
|
||||
initializeVoiceTrack,
|
||||
};
|
||||
|
||||
return connection;
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
setChannelEnabled,
|
||||
setRaisedHand,
|
||||
setUserMuted,
|
||||
setUserVoiceOn,
|
||||
userJoinedCall,
|
||||
userLeftCall,
|
||||
} from '@calls/state';
|
||||
@@ -38,17 +39,11 @@ export const handleCallUserUnmuted = (serverUrl: string, msg: WebSocketMessage)
|
||||
};
|
||||
|
||||
export const handleCallUserVoiceOn = (msg: WebSocketMessage) => {
|
||||
DeviceEventEmitter.emit(WebsocketEvents.CALLS_USER_VOICE_ON, {
|
||||
channelId: msg.broadcast.channel_id,
|
||||
userId: msg.data.userID,
|
||||
});
|
||||
setUserVoiceOn(msg.broadcast.channel_id, msg.data.userID, true);
|
||||
};
|
||||
|
||||
export const handleCallUserVoiceOff = (msg: WebSocketMessage) => {
|
||||
DeviceEventEmitter.emit(WebsocketEvents.CALLS_USER_VOICE_OFF, {
|
||||
channelId: msg.broadcast.channel_id,
|
||||
userId: msg.data.userID,
|
||||
});
|
||||
setUserVoiceOn(msg.broadcast.channel_id, msg.data.userID, false);
|
||||
};
|
||||
|
||||
export const handleCallStarted = (serverUrl: string, msg: WebSocketMessage) => {
|
||||
|
||||
@@ -3,14 +3,18 @@
|
||||
|
||||
// Check if calls is enabled. If it is, then run fn; if it isn't, show an alert and set
|
||||
// msgPostfix to ' (Not Available)'.
|
||||
import {useCallback, useState} from 'react';
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {Alert} from 'react-native';
|
||||
import {Alert, Platform} from 'react-native';
|
||||
import Permissions from 'react-native-permissions';
|
||||
|
||||
import {initializeVoiceTrack} from '@calls/actions/calls';
|
||||
import {setMicPermissionsGranted} from '@calls/state';
|
||||
import {errorAlert} from '@calls/utils';
|
||||
import {Client} from '@client/rest';
|
||||
import ClientError from '@client/rest/error';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {useAppState} from '@hooks/device';
|
||||
import NetworkManager from '@managers/network_manager';
|
||||
|
||||
export const useTryCallsFunction = (fn: () => void) => {
|
||||
@@ -71,3 +75,27 @@ export const useTryCallsFunction = (fn: () => void) => {
|
||||
|
||||
return [tryFn, msgPostfix] as [() => Promise<void>, string];
|
||||
};
|
||||
|
||||
const micPermission = Platform.select({
|
||||
ios: Permissions.PERMISSIONS.IOS.MICROPHONE,
|
||||
default: Permissions.PERMISSIONS.ANDROID.RECORD_AUDIO,
|
||||
});
|
||||
|
||||
export const usePermissionsChecker = (micPermissionsGranted: boolean) => {
|
||||
const appState = useAppState();
|
||||
|
||||
useEffect(() => {
|
||||
const asyncFn = async () => {
|
||||
if (appState === 'active') {
|
||||
const hasPermission = (await Permissions.check(micPermission)) === Permissions.RESULTS.GRANTED;
|
||||
if (hasPermission) {
|
||||
initializeVoiceTrack();
|
||||
setMicPermissionsGranted(hasPermission);
|
||||
}
|
||||
}
|
||||
};
|
||||
if (!micPermissionsGranted) {
|
||||
asyncFn();
|
||||
}
|
||||
}, [appState]);
|
||||
};
|
||||
|
||||
@@ -28,9 +28,12 @@ import {
|
||||
} from '@calls/actions';
|
||||
import CallAvatar from '@calls/components/call_avatar';
|
||||
import CallDuration from '@calls/components/call_duration';
|
||||
import PermissionErrorBar from '@calls/components/permission_error_bar';
|
||||
import UnavailableIconWrapper from '@calls/components/unavailable_icon_wrapper';
|
||||
import {usePermissionsChecker} from '@calls/hooks';
|
||||
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 {CallParticipant, CurrentCall} from '@calls/types/calls';
|
||||
import {sortParticipants} from '@calls/utils';
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
@@ -48,13 +51,14 @@ import {
|
||||
import NavigationStore from '@store/navigation_store';
|
||||
import {bottomSheetSnapPoint} from '@utils/helpers';
|
||||
import {mergeNavigationOptions} from '@utils/navigation';
|
||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
import {displayUsername} from '@utils/user';
|
||||
|
||||
export type Props = {
|
||||
componentId: string;
|
||||
currentCall: CurrentCall | null;
|
||||
participantsDict: Dictionary<CallParticipant>;
|
||||
micPermissionsGranted: boolean;
|
||||
teammateNameDisplay: string;
|
||||
fromThreadScreen?: boolean;
|
||||
}
|
||||
@@ -252,20 +256,31 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||
color: 'white',
|
||||
margin: 3,
|
||||
},
|
||||
unavailableText: {
|
||||
color: changeOpacity(theme.sidebarText, 0.32),
|
||||
},
|
||||
}));
|
||||
|
||||
const CallScreen = ({componentId, currentCall, participantsDict, teammateNameDisplay, fromThreadScreen}: Props) => {
|
||||
const CallScreen = ({
|
||||
componentId,
|
||||
currentCall,
|
||||
participantsDict,
|
||||
micPermissionsGranted,
|
||||
teammateNameDisplay,
|
||||
fromThreadScreen,
|
||||
}: Props) => {
|
||||
const intl = useIntl();
|
||||
const theme = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
const {width, height} = useWindowDimensions();
|
||||
usePermissionsChecker(micPermissionsGranted);
|
||||
const [showControlsInLandscape, setShowControlsInLandscape] = useState(false);
|
||||
const [speakers, setSpeakers] = useState<Dictionary<boolean>>({});
|
||||
|
||||
const style = getStyleSheet(theme);
|
||||
const isLandscape = width > height;
|
||||
const showControls = !isLandscape || showControlsInLandscape;
|
||||
const myParticipant = currentCall?.participants[currentCall.myUserId];
|
||||
const micPermissionsError = !micPermissionsGranted && !currentCall?.micPermissionsErrorDismissed;
|
||||
const chatThreadTitle = intl.formatMessage({id: 'mobile.calls_chat_thread', defaultMessage: 'Chat thread'});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -279,30 +294,6 @@ const CallScreen = ({componentId, currentCall, participantsDict, teammateNameDis
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleVoiceOn = (data: VoiceEventData) => {
|
||||
if (data.channelId === currentCall?.channelId) {
|
||||
setSpeakers((prev) => ({...prev, [data.userId]: true}));
|
||||
}
|
||||
};
|
||||
const handleVoiceOff = (data: VoiceEventData) => {
|
||||
if (data.channelId === currentCall?.channelId && speakers.hasOwnProperty(data.userId)) {
|
||||
setSpeakers((prev) => {
|
||||
const next = {...prev};
|
||||
delete next[data.userId];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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();
|
||||
};
|
||||
}, [speakers, currentCall?.channelId]);
|
||||
|
||||
const leaveCallHandler = useCallback(() => {
|
||||
popTopScreen();
|
||||
leaveCall();
|
||||
@@ -325,6 +316,10 @@ const CallScreen = ({componentId, currentCall, participantsDict, teammateNameDis
|
||||
}
|
||||
}, [myParticipant?.raisedHand]);
|
||||
|
||||
const toggleSpeakerPhone = useCallback(() => {
|
||||
setSpeakerphoneOn(!currentCall?.speakerphoneOn);
|
||||
}, [currentCall?.speakerphoneOn]);
|
||||
|
||||
const toggleControlsInLandscape = useCallback(() => {
|
||||
setShowControlsInLandscape(!showControlsInLandscape);
|
||||
}, [showControlsInLandscape]);
|
||||
@@ -424,8 +419,8 @@ const CallScreen = ({componentId, currentCall, participantsDict, teammateNameDis
|
||||
usersList = (
|
||||
<ScrollView
|
||||
alwaysBounceVertical={false}
|
||||
horizontal={currentCall?.screenOn !== ''}
|
||||
contentContainerStyle={[isLandscape && currentCall?.screenOn && style.usersScrollLandscapeScreenOn]}
|
||||
horizontal={currentCall.screenOn !== ''}
|
||||
contentContainerStyle={[isLandscape && currentCall.screenOn && style.usersScrollLandscapeScreenOn]}
|
||||
>
|
||||
<Pressable
|
||||
testID='users-list'
|
||||
@@ -435,12 +430,12 @@ const CallScreen = ({componentId, currentCall, participantsDict, teammateNameDis
|
||||
{participants.map((user) => {
|
||||
return (
|
||||
<View
|
||||
style={[style.user, currentCall?.screenOn && style.userScreenOn]}
|
||||
style={[style.user, currentCall.screenOn && style.userScreenOn]}
|
||||
key={user.id}
|
||||
>
|
||||
<CallAvatar
|
||||
userModel={user.userModel}
|
||||
volume={speakers[user.id] ? 1 : 0}
|
||||
volume={currentCall.voiceOn[user.id] ? 1 : 0}
|
||||
muted={user.muted}
|
||||
sharingScreen={user.id === currentCall.screenOn}
|
||||
raisedHand={Boolean(user.raisedHand)}
|
||||
@@ -484,7 +479,7 @@ const CallScreen = ({componentId, currentCall, participantsDict, teammateNameDis
|
||||
<FormattedText
|
||||
id={'mobile.calls_unmute'}
|
||||
defaultMessage={'Unmute'}
|
||||
style={style.buttonText}
|
||||
style={[style.buttonText, !micPermissionsGranted && style.unavailableText]}
|
||||
/>);
|
||||
|
||||
return (
|
||||
@@ -508,6 +503,7 @@ const CallScreen = ({componentId, currentCall, participantsDict, teammateNameDis
|
||||
</View>
|
||||
{usersList}
|
||||
{screenShareView}
|
||||
{micPermissionsError && <PermissionErrorBar/>}
|
||||
<View
|
||||
style={[style.buttons, isLandscape && style.buttonsLandscape, !showControls && style.buttonsLandscapeNoControls]}
|
||||
>
|
||||
@@ -516,10 +512,12 @@ const CallScreen = ({componentId, currentCall, participantsDict, teammateNameDis
|
||||
testID='mute-unmute'
|
||||
style={[style.mute, myParticipant.muted && style.muteMuted]}
|
||||
onPress={muteUnmuteHandler}
|
||||
disabled={!micPermissionsGranted}
|
||||
>
|
||||
<CompassIcon
|
||||
<UnavailableIconWrapper
|
||||
name={myParticipant.muted ? 'microphone-off' : 'microphone'}
|
||||
size={24}
|
||||
unavailable={!micPermissionsGranted}
|
||||
style={style.muteIcon}
|
||||
/>
|
||||
{myParticipant.muted ? UnmuteText : MuteText}
|
||||
@@ -544,12 +542,12 @@ const CallScreen = ({componentId, currentCall, participantsDict, teammateNameDis
|
||||
<Pressable
|
||||
testID={'toggle-speakerphone'}
|
||||
style={style.button}
|
||||
onPress={() => setSpeakerphoneOn(!currentCall?.speakerphoneOn)}
|
||||
onPress={toggleSpeakerPhone}
|
||||
>
|
||||
<CompassIcon
|
||||
name={'volume-high'}
|
||||
size={24}
|
||||
style={[style.buttonIcon, style.speakerphoneIcon, currentCall?.speakerphoneOn && style.speakerphoneIconOn]}
|
||||
style={[style.buttonIcon, style.speakerphoneIcon, currentCall.speakerphoneOn && style.speakerphoneIconOn]}
|
||||
/>
|
||||
<FormattedText
|
||||
id={'mobile.calls_speaker'}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {combineLatest, of as of$} from 'rxjs';
|
||||
import {distinctUntilChanged, switchMap} from 'rxjs/operators';
|
||||
|
||||
import CallScreen from '@calls/screens/call_screen/call_screen';
|
||||
import {observeCurrentCall} from '@calls/state';
|
||||
import {observeCurrentCall, observeGlobalCallsState} from '@calls/state';
|
||||
import {CallParticipant} from '@calls/types/calls';
|
||||
import DatabaseManager from '@database/manager';
|
||||
import {observeTeammateNameDisplay, queryUsersById} from '@queries/servers/user';
|
||||
@@ -34,6 +34,10 @@ const enhanced = withObservables([], () => {
|
||||
}, {} as Dictionary<CallParticipant>))),
|
||||
)),
|
||||
);
|
||||
const micPermissionsGranted = observeGlobalCallsState().pipe(
|
||||
switchMap((gs) => of$(gs.micPermissionsGranted)),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
const teammateNameDisplay = database.pipe(
|
||||
switchMap((db) => (db ? observeTeammateNameDisplay(db) : of$(''))),
|
||||
distinctUntilChanged(),
|
||||
@@ -42,6 +46,7 @@ const enhanced = withObservables([], () => {
|
||||
return {
|
||||
currentCall,
|
||||
participantsDict,
|
||||
micPermissionsGranted,
|
||||
teammateNameDisplay,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -9,10 +9,12 @@ import {
|
||||
setCallsState,
|
||||
setChannelsWithCalls,
|
||||
setCurrentCall,
|
||||
setMicPermissionsErrorDismissed,
|
||||
setMicPermissionsGranted,
|
||||
useCallsConfig,
|
||||
useCallsState,
|
||||
useChannelsWithCalls,
|
||||
useCurrentCall,
|
||||
useCurrentCall, useGlobalCallsState,
|
||||
} from '@calls/state';
|
||||
import {
|
||||
setCalls,
|
||||
@@ -30,12 +32,22 @@ import {
|
||||
setSpeakerPhone,
|
||||
setConfig,
|
||||
setPluginEnabled,
|
||||
setUserVoiceOn,
|
||||
} from '@calls/state/actions';
|
||||
import {License} from '@constants';
|
||||
|
||||
import {CallsState, CurrentCall, DefaultCallsConfig, DefaultCallsState} from '../types/calls';
|
||||
import {
|
||||
Call,
|
||||
CallsState,
|
||||
CurrentCall,
|
||||
DefaultCallsConfig,
|
||||
DefaultCallsState,
|
||||
DefaultCurrentCall,
|
||||
DefaultGlobalCallsState,
|
||||
GlobalCallsState,
|
||||
} from '../types/calls';
|
||||
|
||||
const call1 = {
|
||||
const call1: Call = {
|
||||
participants: {
|
||||
'user-1': {id: 'user-1', muted: false, raisedHand: 0},
|
||||
'user-2': {id: 'user-2', muted: true, raisedHand: 0},
|
||||
@@ -46,7 +58,7 @@ const call1 = {
|
||||
threadId: 'thread-1',
|
||||
ownerId: 'user-1',
|
||||
};
|
||||
const call2 = {
|
||||
const call2: Call = {
|
||||
participants: {
|
||||
'user-3': {id: 'user-3', muted: false, raisedHand: 0},
|
||||
'user-4': {id: 'user-4', muted: true, raisedHand: 0},
|
||||
@@ -57,7 +69,7 @@ const call2 = {
|
||||
threadId: 'thread-2',
|
||||
ownerId: 'user-3',
|
||||
};
|
||||
const call3 = {
|
||||
const call3: Call = {
|
||||
participants: {
|
||||
'user-5': {id: 'user-5', muted: false, raisedHand: 0},
|
||||
'user-6': {id: 'user-6', muted: true, raisedHand: 0},
|
||||
@@ -107,39 +119,67 @@ describe('useCallsState', () => {
|
||||
const initialChannelsWithCallsState = {
|
||||
'channel-1': true,
|
||||
};
|
||||
const initialCurrentCallState: CurrentCall = {
|
||||
...DefaultCurrentCall,
|
||||
serverUrl: 'server1',
|
||||
myUserId: 'myUserId',
|
||||
...call1,
|
||||
};
|
||||
const testNewCall1 = {
|
||||
...call1,
|
||||
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: false, raisedHand: 123},
|
||||
},
|
||||
};
|
||||
const test = {
|
||||
calls: {'channel-1': call2, 'channel-2': call3},
|
||||
calls: {'channel-1': testNewCall1, 'channel-2': call2, 'channel-3': call3},
|
||||
enabled: {'channel-2': true},
|
||||
};
|
||||
|
||||
const expectedCallsState = {
|
||||
...initialCallsState,
|
||||
serverUrl: 'server1',
|
||||
myUserId: 'myId',
|
||||
calls: {'channel-1': call2, 'channel-2': call3},
|
||||
calls: {'channel-1': testNewCall1, 'channel-2': call2, 'channel-3': call3},
|
||||
enabled: {'channel-2': true},
|
||||
};
|
||||
const expectedChannelsWithCallsState = {
|
||||
...initialChannelsWithCallsState,
|
||||
'channel-2': true,
|
||||
'channel-3': true,
|
||||
};
|
||||
const expectedCurrentCallState = {
|
||||
...initialCurrentCallState,
|
||||
...testNewCall1,
|
||||
};
|
||||
|
||||
// setup
|
||||
const {result} = renderHook(() => {
|
||||
return [useCallsState('server1'), useCallsState('server1'), useChannelsWithCalls('server1')];
|
||||
return [
|
||||
useCallsState('server1'),
|
||||
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], initialCallsState);
|
||||
assert.deepEqual(result.current[2], initialChannelsWithCallsState);
|
||||
assert.deepEqual(result.current[3], initialCurrentCallState);
|
||||
|
||||
// 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);
|
||||
assert.deepEqual(result.current[3], expectedCurrentCallState);
|
||||
});
|
||||
|
||||
it('joinedCall', () => {
|
||||
@@ -150,13 +190,13 @@ describe('useCallsState', () => {
|
||||
const initialChannelsWithCallsState = {
|
||||
'channel-1': true,
|
||||
};
|
||||
const initialCurrentCallState = {
|
||||
|
||||
const initialCurrentCallState: CurrentCall = {
|
||||
...DefaultCurrentCall,
|
||||
serverUrl: 'server1',
|
||||
myUserId: 'myUserId',
|
||||
...call1,
|
||||
screenShareURL: '',
|
||||
speakerphoneOn: false,
|
||||
} as CurrentCall;
|
||||
};
|
||||
const expectedCallsState = {
|
||||
'channel-1': {
|
||||
participants: {
|
||||
@@ -209,13 +249,12 @@ describe('useCallsState', () => {
|
||||
const initialChannelsWithCallsState = {
|
||||
'channel-1': true,
|
||||
};
|
||||
const initialCurrentCallState = {
|
||||
const initialCurrentCallState: CurrentCall = {
|
||||
...DefaultCurrentCall,
|
||||
serverUrl: 'server1',
|
||||
myUserId: 'myUserId',
|
||||
...call1,
|
||||
screenShareURL: '',
|
||||
speakerphoneOn: false,
|
||||
} as CurrentCall;
|
||||
};
|
||||
const expectedCallsState = {
|
||||
'channel-1': {
|
||||
participants: {
|
||||
@@ -311,13 +350,12 @@ describe('useCallsState', () => {
|
||||
calls: {'channel-1': call1, 'channel-2': call2},
|
||||
};
|
||||
const initialChannelsWithCallsState = {'channel-1': true, 'channel-2': true};
|
||||
const initialCurrentCallState = {
|
||||
const initialCurrentCallState: CurrentCall = {
|
||||
...DefaultCurrentCall,
|
||||
serverUrl: 'server1',
|
||||
myUserId: 'myUserId',
|
||||
...call1,
|
||||
screenShareURL: '',
|
||||
speakerphoneOn: false,
|
||||
} as CurrentCall;
|
||||
};
|
||||
|
||||
// setup
|
||||
const {result} = renderHook(() => {
|
||||
@@ -358,13 +396,12 @@ describe('useCallsState', () => {
|
||||
calls: {'channel-1': call1, 'channel-2': call2},
|
||||
};
|
||||
const initialChannelsWithCallsState = {'channel-1': true, 'channel-2': true};
|
||||
const initialCurrentCallState = {
|
||||
const initialCurrentCallState: CurrentCall = {
|
||||
...DefaultCurrentCall,
|
||||
serverUrl: 'server1',
|
||||
myUserId: 'myUserId',
|
||||
...call1,
|
||||
screenShareURL: '',
|
||||
speakerphoneOn: false,
|
||||
} as CurrentCall;
|
||||
};
|
||||
|
||||
// setup
|
||||
const {result} = renderHook(() => {
|
||||
@@ -416,13 +453,12 @@ describe('useCallsState', () => {
|
||||
ownerId: 'user-1',
|
||||
},
|
||||
};
|
||||
const initialCurrentCallState = {
|
||||
const initialCurrentCallState: CurrentCall = {
|
||||
...DefaultCurrentCall,
|
||||
serverUrl: 'server1',
|
||||
myUserId: 'myUserId',
|
||||
...call1,
|
||||
screenShareURL: '',
|
||||
speakerphoneOn: false,
|
||||
} as CurrentCall;
|
||||
};
|
||||
const expectedCurrentCallState = {
|
||||
...initialCurrentCallState,
|
||||
...expectedCalls['channel-1'],
|
||||
@@ -469,17 +505,17 @@ describe('useCallsState', () => {
|
||||
};
|
||||
const expectedCallsState = {
|
||||
...initialCallsState,
|
||||
calls: {...initialCallsState.calls,
|
||||
calls: {
|
||||
...initialCallsState.calls,
|
||||
'channel-1': newCall1,
|
||||
},
|
||||
};
|
||||
const expectedCurrentCallState = {
|
||||
const expectedCurrentCallState: CurrentCall = {
|
||||
...DefaultCurrentCall,
|
||||
serverUrl: 'server1',
|
||||
myUserId: 'myUserId',
|
||||
screenShareURL: '',
|
||||
speakerphoneOn: false,
|
||||
...newCall1,
|
||||
} as CurrentCall;
|
||||
};
|
||||
|
||||
// setup
|
||||
const {result} = renderHook(() => {
|
||||
@@ -589,7 +625,8 @@ describe('useCallsState', () => {
|
||||
};
|
||||
const expectedCallsState = {
|
||||
...initialCallsState,
|
||||
calls: {...initialCallsState.calls,
|
||||
calls: {
|
||||
...initialCallsState.calls,
|
||||
'channel-1': newCall1,
|
||||
},
|
||||
};
|
||||
@@ -618,6 +655,121 @@ describe('useCallsState', () => {
|
||||
assert.deepEqual(result.current[1], null);
|
||||
});
|
||||
|
||||
it('MicPermissions', () => {
|
||||
const initialGlobalState = DefaultGlobalCallsState;
|
||||
const initialCallsState: CallsState = {
|
||||
...DefaultCallsState,
|
||||
myUserId: 'myUserId',
|
||||
calls: {'channel-1': call1, 'channel-2': call2},
|
||||
};
|
||||
const newCall1: Call = {
|
||||
...call1,
|
||||
participants: {
|
||||
...call1.participants,
|
||||
myUserId: {id: 'myUserId', muted: true, raisedHand: 0},
|
||||
},
|
||||
};
|
||||
const expectedCallsState: CallsState = {
|
||||
...initialCallsState,
|
||||
calls: {
|
||||
...initialCallsState.calls,
|
||||
'channel-1': newCall1,
|
||||
},
|
||||
};
|
||||
const expectedCurrentCallState: CurrentCall = {
|
||||
...DefaultCurrentCall,
|
||||
serverUrl: 'server1',
|
||||
myUserId: 'myUserId',
|
||||
...newCall1,
|
||||
};
|
||||
const secondExpectedCurrentCallState: CurrentCall = {
|
||||
...expectedCurrentCallState,
|
||||
micPermissionsErrorDismissed: true,
|
||||
};
|
||||
const expectedGlobalState: GlobalCallsState = {
|
||||
micPermissionsGranted: true,
|
||||
};
|
||||
|
||||
// setup
|
||||
const {result} = renderHook(() => {
|
||||
return [useCallsState('server1'), useCurrentCall(), useGlobalCallsState()];
|
||||
});
|
||||
act(() => setCallsState('server1', initialCallsState));
|
||||
assert.deepEqual(result.current[0], initialCallsState);
|
||||
assert.deepEqual(result.current[1], null);
|
||||
assert.deepEqual(result.current[2], initialGlobalState);
|
||||
|
||||
// join call
|
||||
act(() => {
|
||||
setMicPermissionsGranted(false);
|
||||
userJoinedCall('server1', 'channel-1', 'myUserId');
|
||||
});
|
||||
assert.deepEqual(result.current[0], expectedCallsState);
|
||||
assert.deepEqual(result.current[1], expectedCurrentCallState);
|
||||
assert.deepEqual(result.current[2], initialGlobalState);
|
||||
|
||||
// dismiss mic error
|
||||
act(() => setMicPermissionsErrorDismissed());
|
||||
assert.deepEqual(result.current[0], expectedCallsState);
|
||||
assert.deepEqual(result.current[1], secondExpectedCurrentCallState);
|
||||
assert.deepEqual(result.current[2], initialGlobalState);
|
||||
|
||||
// grant permissions
|
||||
act(() => setMicPermissionsGranted(true));
|
||||
assert.deepEqual(result.current[0], expectedCallsState);
|
||||
assert.deepEqual(result.current[1], secondExpectedCurrentCallState);
|
||||
assert.deepEqual(result.current[2], expectedGlobalState);
|
||||
|
||||
act(() => {
|
||||
myselfLeftCall();
|
||||
userLeftCall('server1', 'channel-1', 'myUserId');
|
||||
});
|
||||
assert.deepEqual(result.current[0], initialCallsState);
|
||||
assert.deepEqual(result.current[1], null);
|
||||
});
|
||||
|
||||
it('voiceOn and Off', () => {
|
||||
const initialCallsState = {
|
||||
...DefaultCallsState,
|
||||
serverUrl: 'server1',
|
||||
myUserId: 'myUserId',
|
||||
calls: {'channel-1': call1, 'channel-2': call2},
|
||||
};
|
||||
const initialCurrentCallState: CurrentCall = {
|
||||
...DefaultCurrentCall,
|
||||
serverUrl: 'server1',
|
||||
myUserId: 'myUserId',
|
||||
...call1,
|
||||
};
|
||||
|
||||
// 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(() => setUserVoiceOn('channel-1', 'user-1', true));
|
||||
assert.deepEqual(result.current[1], {...initialCurrentCallState, voiceOn: {'user-1': true}});
|
||||
assert.deepEqual(result.current[0], initialCallsState);
|
||||
act(() => setUserVoiceOn('channel-1', 'user-2', true));
|
||||
assert.deepEqual(result.current[1], {...initialCurrentCallState, voiceOn: {'user-1': true, 'user-2': true}});
|
||||
assert.deepEqual(result.current[0], initialCallsState);
|
||||
act(() => setUserVoiceOn('channel-1', 'user-1', false));
|
||||
assert.deepEqual(result.current[1], {...initialCurrentCallState, voiceOn: {'user-2': true}});
|
||||
assert.deepEqual(result.current[0], initialCallsState);
|
||||
|
||||
// test that voice state is cleared on reconnect
|
||||
act(() => setCalls('server1', 'myUserId', initialCallsState.calls, {}));
|
||||
assert.deepEqual(result.current[1], initialCurrentCallState);
|
||||
assert.deepEqual(result.current[0], initialCallsState);
|
||||
});
|
||||
|
||||
it('config', () => {
|
||||
const newConfig = {
|
||||
ICEServers: [],
|
||||
|
||||
@@ -6,10 +6,12 @@ import {
|
||||
getCallsState,
|
||||
getChannelsWithCalls,
|
||||
getCurrentCall,
|
||||
getGlobalCallsState,
|
||||
setCallsConfig,
|
||||
setCallsState,
|
||||
setChannelsWithCalls,
|
||||
setCurrentCall,
|
||||
setGlobalCallsState,
|
||||
} from '@calls/state';
|
||||
import {Call, CallsConfig, ChannelsWithCalls} from '@calls/types/calls';
|
||||
|
||||
@@ -22,6 +24,21 @@ export const setCalls = (serverUrl: string, myUserId: string, calls: Dictionary<
|
||||
setChannelsWithCalls(serverUrl, channelsWithCalls);
|
||||
|
||||
setCallsState(serverUrl, {serverUrl, myUserId, calls, enabled});
|
||||
|
||||
// Does the current call need to be updated?
|
||||
const currentCall = getCurrentCall();
|
||||
if (!currentCall || !calls[currentCall.channelId]) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Edge case: if the app went into the background and lost the main ws connection, we don't know who is currently
|
||||
// talking. Instead of guessing, erase voiceOn state (same state as when joining an ongoing call).
|
||||
const nextCall = {
|
||||
...currentCall,
|
||||
...calls[currentCall.channelId],
|
||||
voiceOn: {},
|
||||
};
|
||||
setCurrentCall(nextCall);
|
||||
};
|
||||
|
||||
export const setCallForChannel = (serverUrl: string, channelId: string, enabled: boolean, call?: Call) => {
|
||||
@@ -80,9 +97,13 @@ export const userJoinedCall = (serverUrl: string, channelId: string, userId: str
|
||||
// Did the user join the current call? If so, update that too.
|
||||
const currentCall = getCurrentCall();
|
||||
if (currentCall && currentCall.channelId === channelId) {
|
||||
const voiceOn = {...currentCall.voiceOn};
|
||||
delete voiceOn[userId];
|
||||
|
||||
const nextCurrentCall = {
|
||||
...currentCall,
|
||||
participants: {...currentCall.participants, [userId]: nextCall.participants[userId]},
|
||||
voiceOn,
|
||||
};
|
||||
setCurrentCall(nextCurrentCall);
|
||||
}
|
||||
@@ -96,6 +117,8 @@ export const userJoinedCall = (serverUrl: string, channelId: string, userId: str
|
||||
myUserId: userId,
|
||||
screenShareURL: '',
|
||||
speakerphoneOn: false,
|
||||
voiceOn: {},
|
||||
micPermissionsErrorDismissed: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -137,9 +160,14 @@ export const userLeftCall = (serverUrl: string, channelId: string, userId: strin
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear them from the voice list
|
||||
const voiceOn = {...currentCall.voiceOn};
|
||||
delete voiceOn[userId];
|
||||
|
||||
const nextCurrentCall = {
|
||||
...currentCall,
|
||||
participants: {...currentCall.participants},
|
||||
voiceOn,
|
||||
};
|
||||
delete nextCurrentCall.participants[userId];
|
||||
setCurrentCall(nextCurrentCall);
|
||||
@@ -208,6 +236,26 @@ export const setUserMuted = (serverUrl: string, channelId: string, userId: strin
|
||||
setCurrentCall(nextCurrentCall);
|
||||
};
|
||||
|
||||
export const setUserVoiceOn = (channelId: string, userId: string, voiceOn: boolean) => {
|
||||
const currentCall = getCurrentCall();
|
||||
if (!currentCall || currentCall.channelId !== channelId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextVoiceOn = {...currentCall.voiceOn};
|
||||
if (voiceOn) {
|
||||
nextVoiceOn[userId] = true;
|
||||
} else {
|
||||
delete nextVoiceOn[userId];
|
||||
}
|
||||
|
||||
const nextCurrentCall = {
|
||||
...currentCall,
|
||||
voiceOn: nextVoiceOn,
|
||||
};
|
||||
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]) {
|
||||
@@ -318,3 +366,26 @@ export const setPluginEnabled = (serverUrl: string, pluginEnabled: boolean) => {
|
||||
const callsConfig = getCallsConfig(serverUrl);
|
||||
setCallsConfig(serverUrl, {...callsConfig, pluginEnabled});
|
||||
};
|
||||
|
||||
export const setMicPermissionsGranted = (granted: boolean) => {
|
||||
const globalState = getGlobalCallsState();
|
||||
|
||||
const nextGlobalState = {
|
||||
...globalState,
|
||||
micPermissionsGranted: granted,
|
||||
};
|
||||
setGlobalCallsState(nextGlobalState);
|
||||
};
|
||||
|
||||
export const setMicPermissionsErrorDismissed = () => {
|
||||
const currentCall = getCurrentCall();
|
||||
if (!currentCall) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextCurrentCall = {
|
||||
...currentCall,
|
||||
micPermissionsErrorDismissed: true,
|
||||
};
|
||||
setCurrentCall(nextCurrentCall);
|
||||
};
|
||||
|
||||
38
app/products/calls/state/global_calls_state.ts
Normal file
38
app/products/calls/state/global_calls_state.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// 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 {DefaultGlobalCallsState, GlobalCallsState} from '@calls/types/calls';
|
||||
|
||||
const globalStateSubject = new BehaviorSubject(DefaultGlobalCallsState);
|
||||
|
||||
export const getGlobalCallsState = () => {
|
||||
return globalStateSubject.value;
|
||||
};
|
||||
|
||||
export const setGlobalCallsState = (globalState: GlobalCallsState) => {
|
||||
globalStateSubject.next(globalState);
|
||||
};
|
||||
|
||||
export const observeGlobalCallsState = () => {
|
||||
return globalStateSubject.asObservable();
|
||||
};
|
||||
|
||||
export const useGlobalCallsState = () => {
|
||||
const [state, setState] = useState<GlobalCallsState>(DefaultGlobalCallsState);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = globalStateSubject.subscribe((globalState) => {
|
||||
setState(globalState);
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription?.unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
@@ -6,3 +6,4 @@ export * from './calls_state';
|
||||
export * from './calls_config';
|
||||
export * from './current_call';
|
||||
export * from './channels_with_calls';
|
||||
export * from './global_calls_state';
|
||||
|
||||
@@ -4,6 +4,14 @@
|
||||
import type UserModel from '@typings/database/models/servers/user';
|
||||
import type {ConfigurationParamWithUrls, ConfigurationParamWithUrl} from 'react-native-webrtc';
|
||||
|
||||
export type GlobalCallsState = {
|
||||
micPermissionsGranted: boolean;
|
||||
}
|
||||
|
||||
export const DefaultGlobalCallsState: GlobalCallsState = {
|
||||
micPermissionsGranted: false,
|
||||
};
|
||||
|
||||
export type CallsState = {
|
||||
serverUrl: string;
|
||||
myUserId: string;
|
||||
@@ -11,12 +19,12 @@ export type CallsState = {
|
||||
enabled: Dictionary<boolean>;
|
||||
}
|
||||
|
||||
export const DefaultCallsState = {
|
||||
export const DefaultCallsState: CallsState = {
|
||||
serverUrl: '',
|
||||
myUserId: '',
|
||||
calls: {} as Dictionary<Call>,
|
||||
enabled: {} as Dictionary<boolean>,
|
||||
} as CallsState;
|
||||
};
|
||||
|
||||
export type Call = {
|
||||
participants: Dictionary<CallParticipant>;
|
||||
@@ -45,8 +53,24 @@ export type CurrentCall = {
|
||||
threadId: string;
|
||||
screenShareURL: string;
|
||||
speakerphoneOn: boolean;
|
||||
voiceOn: Dictionary<boolean>;
|
||||
micPermissionsErrorDismissed: boolean;
|
||||
}
|
||||
|
||||
export const DefaultCurrentCall: CurrentCall = {
|
||||
serverUrl: '',
|
||||
myUserId: '',
|
||||
participants: {},
|
||||
channelId: '',
|
||||
startTime: 0,
|
||||
screenOn: '',
|
||||
threadId: '',
|
||||
screenShareURL: '',
|
||||
speakerphoneOn: false,
|
||||
voiceOn: {},
|
||||
micPermissionsErrorDismissed: false,
|
||||
};
|
||||
|
||||
export type CallParticipant = {
|
||||
id: string;
|
||||
muted: boolean;
|
||||
@@ -89,6 +113,7 @@ export type CallsConnection = {
|
||||
waitForPeerConnection: () => Promise<void>;
|
||||
raiseHand: () => void;
|
||||
unraiseHand: () => void;
|
||||
initializeVoiceTrack: () => void;
|
||||
}
|
||||
|
||||
export type ServerCallsConfig = {
|
||||
@@ -106,7 +131,7 @@ export type CallsConfig = ServerCallsConfig & {
|
||||
last_retrieved_at: number;
|
||||
}
|
||||
|
||||
export const DefaultCallsConfig = {
|
||||
export const DefaultCallsConfig: CallsConfig = {
|
||||
pluginEnabled: false,
|
||||
ICEServers: [], // deprecated
|
||||
ICEServersConfigs: [],
|
||||
@@ -116,7 +141,7 @@ export const DefaultCallsConfig = {
|
||||
last_retrieved_at: 0,
|
||||
sku_short_name: '',
|
||||
MaxCallParticipants: 0,
|
||||
} as CallsConfig;
|
||||
};
|
||||
|
||||
export type ICEServersConfigs = Array<ConfigurationParamWithUrls | ConfigurationParamWithUrl>;
|
||||
|
||||
|
||||
@@ -47,7 +47,6 @@ exports[`components/categories_list should render channels error 1`] = `
|
||||
>
|
||||
<View
|
||||
accessible={true}
|
||||
collapsable={false}
|
||||
focusable={false}
|
||||
onClick={[Function]}
|
||||
onResponderGrant={[Function]}
|
||||
@@ -58,52 +57,26 @@ exports[`components/categories_list should render channels error 1`] = `
|
||||
onStartShouldSetResponder={[Function]}
|
||||
style={
|
||||
{
|
||||
"opacity": 1,
|
||||
"alignItems": "center",
|
||||
"flexDirection": "row",
|
||||
"justifyContent": "space-between",
|
||||
}
|
||||
}
|
||||
>
|
||||
<View
|
||||
<Text
|
||||
style={
|
||||
{
|
||||
"alignItems": "center",
|
||||
"flexDirection": "row",
|
||||
"justifyContent": "space-between",
|
||||
"color": "#ffffff",
|
||||
"fontFamily": "Metropolis-SemiBold",
|
||||
"fontSize": 28,
|
||||
"fontWeight": "600",
|
||||
"lineHeight": 36,
|
||||
}
|
||||
}
|
||||
testID="channel_list_header.team_display_name"
|
||||
>
|
||||
<Text
|
||||
style={
|
||||
{
|
||||
"color": "#ffffff",
|
||||
"fontFamily": "Metropolis-SemiBold",
|
||||
"fontSize": 28,
|
||||
"fontWeight": "600",
|
||||
"lineHeight": 36,
|
||||
}
|
||||
}
|
||||
testID="channel_list_header.team_display_name"
|
||||
>
|
||||
Test Team!
|
||||
</Text>
|
||||
<View
|
||||
style={
|
||||
{
|
||||
"marginLeft": 4,
|
||||
}
|
||||
}
|
||||
testID="channel_list_header.chevron.button"
|
||||
>
|
||||
<Icon
|
||||
name="chevron-down"
|
||||
style={
|
||||
{
|
||||
"color": "rgba(255,255,255,0.8)",
|
||||
"fontSize": 24,
|
||||
}
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
Test Team!
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
accessible={true}
|
||||
|
||||
@@ -27,7 +27,6 @@ exports[`components/channel_list/header Channel List Header Component should mat
|
||||
>
|
||||
<View
|
||||
accessible={true}
|
||||
collapsable={false}
|
||||
focusable={false}
|
||||
onClick={[Function]}
|
||||
onResponderGrant={[Function]}
|
||||
@@ -38,52 +37,26 @@ exports[`components/channel_list/header Channel List Header Component should mat
|
||||
onStartShouldSetResponder={[Function]}
|
||||
style={
|
||||
{
|
||||
"opacity": 1,
|
||||
"alignItems": "center",
|
||||
"flexDirection": "row",
|
||||
"justifyContent": "space-between",
|
||||
}
|
||||
}
|
||||
>
|
||||
<View
|
||||
<Text
|
||||
style={
|
||||
{
|
||||
"alignItems": "center",
|
||||
"flexDirection": "row",
|
||||
"justifyContent": "space-between",
|
||||
"color": "#ffffff",
|
||||
"fontFamily": "Metropolis-SemiBold",
|
||||
"fontSize": 28,
|
||||
"fontWeight": "600",
|
||||
"lineHeight": 36,
|
||||
}
|
||||
}
|
||||
testID="channel_list_header.team_display_name"
|
||||
>
|
||||
<Text
|
||||
style={
|
||||
{
|
||||
"color": "#ffffff",
|
||||
"fontFamily": "Metropolis-SemiBold",
|
||||
"fontSize": 28,
|
||||
"fontWeight": "600",
|
||||
"lineHeight": 36,
|
||||
}
|
||||
}
|
||||
testID="channel_list_header.team_display_name"
|
||||
>
|
||||
Test!
|
||||
</Text>
|
||||
<View
|
||||
style={
|
||||
{
|
||||
"marginLeft": 4,
|
||||
}
|
||||
}
|
||||
testID="channel_list_header.chevron.button"
|
||||
>
|
||||
<Icon
|
||||
name="chevron-down"
|
||||
style={
|
||||
{
|
||||
"color": "rgba(255,255,255,0.8)",
|
||||
"fontSize": 24,
|
||||
}
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
Test!
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
accessible={true}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import React, {useCallback, useEffect} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
import {Insets, Text, View} from 'react-native';
|
||||
import {Insets, Text, TouchableWithoutFeedback, View} from 'react-native';
|
||||
import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||
|
||||
@@ -173,9 +173,8 @@ const ChannelListHeader = ({
|
||||
header = (
|
||||
<>
|
||||
<View style={styles.headerRow}>
|
||||
<TouchableWithFeedback
|
||||
<TouchableWithoutFeedback
|
||||
onPress={onHeaderPress}
|
||||
type='opacity'
|
||||
>
|
||||
<View style={styles.headerRow}>
|
||||
<Text
|
||||
@@ -184,17 +183,8 @@ const ChannelListHeader = ({
|
||||
>
|
||||
{displayName}
|
||||
</Text>
|
||||
<View
|
||||
style={styles.chevronButton}
|
||||
testID='channel_list_header.chevron.button'
|
||||
>
|
||||
<CompassIcon
|
||||
style={styles.chevronIcon}
|
||||
name={'chevron-down'}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableWithFeedback>
|
||||
</TouchableWithoutFeedback>
|
||||
<TouchableWithFeedback
|
||||
hitSlop={hitSlop}
|
||||
onPress={onPress}
|
||||
|
||||
@@ -20,10 +20,9 @@ import Account from './account';
|
||||
import ChannelList from './channel_list';
|
||||
import RecentMentions from './recent_mentions';
|
||||
import SavedMessages from './saved_messages';
|
||||
import Search from './search';
|
||||
import TabBar from './tab_bar';
|
||||
|
||||
// import Search from './search';
|
||||
|
||||
import type {LaunchProps} from '@typings/launch';
|
||||
|
||||
if (Platform.OS === 'ios') {
|
||||
@@ -125,11 +124,11 @@ export default function HomeScreen(props: HomeProps) {
|
||||
>
|
||||
{() => <ChannelList {...props}/>}
|
||||
</Tab.Screen>
|
||||
{/* <Tab.Screen
|
||||
<Tab.Screen
|
||||
name={Screens.SEARCH}
|
||||
component={Search}
|
||||
options={{unmountOnBlur: false, lazy: true, tabBarTestID: 'tab_bar.search.tab', freezeOnBlur: true}}
|
||||
/> */}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name={Screens.MENTIONS}
|
||||
component={RecentMentions}
|
||||
|
||||
@@ -17,6 +17,7 @@ import FreezeScreen from '@components/freeze_screen';
|
||||
import Loading from '@components/loading';
|
||||
import NavigationHeader from '@components/navigation_header';
|
||||
import RoundedHeaderContext from '@components/rounded_header_context';
|
||||
import {SearchRef} from '@components/search';
|
||||
import {BOTTOM_TAB_HEIGHT} from '@constants/view';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {useTheme} from '@context/theme';
|
||||
@@ -84,6 +85,8 @@ const SearchScreen = ({teamId}: Props) => {
|
||||
|
||||
const clearRef = useRef<boolean>(false);
|
||||
const cancelRef = useRef<boolean>(false);
|
||||
const searchRef = useRef<SearchRef>(null);
|
||||
|
||||
const [cursorPosition, setCursorPosition] = useState(searchTerm?.length || 0);
|
||||
const [searchValue, setSearchValue] = useState<string>(searchTerm || '');
|
||||
const [searchTeamId, setSearchTeamId] = useState<string>(teamId);
|
||||
@@ -144,6 +147,11 @@ const SearchScreen = ({teamId}: Props) => {
|
||||
setCursorPosition(newValue.length);
|
||||
}, []);
|
||||
|
||||
const handleModifierTextChange = useCallback((newValue: string) => {
|
||||
searchRef.current?.focus?.();
|
||||
handleTextChange(newValue);
|
||||
}, [handleTextChange]);
|
||||
|
||||
const handleLoading = useCallback((show: boolean) => {
|
||||
(showResults ? setResultsLoading : setLoading)(show);
|
||||
}, [showResults]);
|
||||
@@ -218,7 +226,7 @@ const SearchScreen = ({teamId}: Props) => {
|
||||
scrollEnabled={scrollEnabled}
|
||||
searchValue={searchValue}
|
||||
setRecentValue={handleRecentSearch}
|
||||
setSearchValue={handleTextChange}
|
||||
setSearchValue={handleModifierTextChange}
|
||||
setTeamId={setSearchTeamId}
|
||||
teamId={searchTeamId}
|
||||
/>
|
||||
@@ -318,6 +326,7 @@ const SearchScreen = ({teamId}: Props) => {
|
||||
onClear={handleClearSearch}
|
||||
onCancel={handleCancelSearch}
|
||||
defaultValue={searchValue}
|
||||
ref={searchRef}
|
||||
/>
|
||||
<SafeAreaView
|
||||
style={styles.flex}
|
||||
|
||||
@@ -370,7 +370,6 @@
|
||||
"mobile.calls_end_permission_title": "Error",
|
||||
"mobile.calls_ended_at": "Ended at",
|
||||
"mobile.calls_error_message": "Error: {error}",
|
||||
"mobile.calls_error_permissions": "No permissions to microphone, unable to start call",
|
||||
"mobile.calls_error_title": "Error",
|
||||
"mobile.calls_join_call": "Join call",
|
||||
"mobile.calls_lasted": "Lasted {duration}",
|
||||
@@ -379,6 +378,7 @@
|
||||
"mobile.calls_limit_msg": "The maximum number of participants per call is {maxParticipants}. Contact your System Admin to increase the limit.",
|
||||
"mobile.calls_limit_reached": "Participant limit reached",
|
||||
"mobile.calls_lower_hand": "Lower hand",
|
||||
"mobile.calls_mic_error": "To participate, open Settings to grant Mattermost access to your microphone.",
|
||||
"mobile.calls_more": "More",
|
||||
"mobile.calls_mute": "Mute",
|
||||
"mobile.calls_name_is_talking": "{name} is talking",
|
||||
@@ -484,8 +484,6 @@
|
||||
"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}”",
|
||||
@@ -550,12 +548,9 @@
|
||||
"mobile.screen.settings": "Settings",
|
||||
"mobile.screen.your_profile": "Your Profile",
|
||||
"mobile.search.jump": "Jump to recent messages",
|
||||
"mobile.search.modifier.after": "after a date",
|
||||
"mobile.search.modifier.before": "before a date",
|
||||
"mobile.search.modifier.exclude": "exclude search terms",
|
||||
"mobile.search.modifier.from": "a specific user",
|
||||
"mobile.search.modifier.in": "a specific channel",
|
||||
"mobile.search.modifier.on": "a specific date",
|
||||
"mobile.search.modifier.phrases": "messages with phrases",
|
||||
"mobile.search.show_less": "Show less",
|
||||
"mobile.search.show_more": "Show more",
|
||||
|
||||
@@ -1095,7 +1095,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Mattermost/Mattermost.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CURRENT_PROJECT_VERSION = 430;
|
||||
CURRENT_PROJECT_VERSION = 432;
|
||||
DEVELOPMENT_TEAM = UQ8HT4Q2XM;
|
||||
ENABLE_BITCODE = NO;
|
||||
HEADER_SEARCH_PATHS = (
|
||||
@@ -1139,7 +1139,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Mattermost/Mattermost.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CURRENT_PROJECT_VERSION = 430;
|
||||
CURRENT_PROJECT_VERSION = 432;
|
||||
DEVELOPMENT_TEAM = UQ8HT4Q2XM;
|
||||
ENABLE_BITCODE = NO;
|
||||
HEADER_SEARCH_PATHS = (
|
||||
@@ -1282,7 +1282,7 @@
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 430;
|
||||
CURRENT_PROJECT_VERSION = 432;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = UQ8HT4Q2XM;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
@@ -1333,7 +1333,7 @@
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 430;
|
||||
CURRENT_PROJECT_VERSION = 432;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = UQ8HT4Q2XM;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>430</string>
|
||||
<string>432</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2.0.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>430</string>
|
||||
<string>432</string>
|
||||
<key>UIAppFonts</key>
|
||||
<array>
|
||||
<string>OpenSans-Bold.ttf</string>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2.0.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>430</string>
|
||||
<string>432</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
|
||||
@@ -128,6 +128,10 @@ jest.doMock('react-native', () => {
|
||||
},
|
||||
}),
|
||||
},
|
||||
WebRTCModule: {
|
||||
senderGetCapabilities: jest.fn().mockReturnValue(null),
|
||||
receiverGetCapabilities: jest.fn().mockReturnValue(null),
|
||||
},
|
||||
};
|
||||
|
||||
const Linking = {
|
||||
|
||||
Reference in New Issue
Block a user