This commit is contained in:
Julian Mondragon
2022-11-08 11:09:15 -05:00
43 changed files with 803 additions and 383 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -63,7 +63,7 @@ export default function FileQuickAction({
>
<CompassIcon
color={color}
name='file-generic-outline'
name='paperclip'
size={ICON_SIZE}
/>
</TouchableWithFeedback>

View File

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

View File

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

View File

@@ -58,7 +58,7 @@ function RadioSetting({
isSelected={value === entryValue}
text={text}
value={entryValue}
key={value}
key={entryValue}
/>,
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: [],

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -37,7 +37,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>430</string>
<string>432</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key>

View File

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

View File

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

View File

@@ -128,6 +128,10 @@ jest.doMock('react-native', () => {
},
}),
},
WebRTCModule: {
senderGetCapabilities: jest.fn().mockReturnValue(null),
receiverGetCapabilities: jest.fn().mockReturnValue(null),
},
};
const Linking = {