mattermost-mobile/app/screens/channel_add_members/channel_add_members.tsx
Elias Nahum a5a1e53827
Biometric prompt, Jailbreak / Root detection and screenshot prevention (#8645)
* Handle biometric authentication

* jailbreak/root detection and biometric small fixes

* remove server from initializeSecurityManager and fix loginEntry

* Add screen capture prevention and other small fixes

* added unit tests to SecurityManager

* added shielded nativeID to protect views

* use MobilePreventScreenCapture instead of MobileAllowScreenshots in config type definition

* Apply Swizzle for screen capture on iOS

* Apply patch to bottom sheet to prevent screen captures

* fix ios sendReply

* Fix SDWebImage swizzle to use the correct session

* Fix potential crash on Android when using hardware keyboard

* rename patch for network library to remove warning

* add temp emm reference

* fix initializeSecurityManager tests

* fix translations

* use siteName for jailbreak detection when connecting to a new server

* fix i18n typo

* do not query the entire config from the db only the required fields

* migrate manage_apps to use defineMessages

* use TestHelper.wait in tests

* use defineMessages for security manager

* fix missing else statement for gm_to_channel

* created a TestHelper function to mockQuery and replace as jest.Mock with jest.mocked

* fix unit tests

* fix unit tests (again) and include setting the test environment to UTC

* Fix keyboard disappearing on iOS

* update react-native-emm
2025-03-13 14:07:41 -04:00

298 lines
10 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {Keyboard, type LayoutChangeEvent, Platform, View} from 'react-native';
import {SafeAreaView} from 'react-native-safe-area-context';
import {addMembersToChannel} from '@actions/remote/channel';
import {fetchProfilesNotInChannel, searchProfiles} from '@actions/remote/user';
import CompassIcon from '@components/compass_icon';
import Loading from '@components/loading';
import Search from '@components/search';
import SelectedUsers from '@components/selected_users';
import ServerUserList from '@components/server_user_list';
import {General} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {useKeyboardOverlap} from '@hooks/device';
import useNavButtonPressed from '@hooks/navigation_button_pressed';
import {t} from '@i18n';
import SecurityManager from '@managers/security_manager';
import {dismissModal} from '@screens/navigation';
import {alertErrorWithFallback} from '@utils/draft';
import {mergeNavigationOptions} from '@utils/navigation';
import {showAddChannelMembersSnackbar} from '@utils/snack_bar';
import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import type ChannelModel from '@typings/database/models/servers/channel';
import type {AvailableScreens} from '@typings/screens/navigation';
const CLOSE_BUTTON_ID = 'close-add-member';
const TEST_ID = 'add_members';
const CLOSE_BUTTON_TEST_ID = 'close.button';
export const getHeaderOptions = async (theme: Theme, displayName: string, inModal = false) => {
let leftButtons;
if (!inModal) {
const closeButton = await CompassIcon.getImageSourceSync('close', 24, theme.sidebarHeaderTextColor);
leftButtons = [{
id: CLOSE_BUTTON_ID,
icon: closeButton,
testID: `${TEST_ID}.${CLOSE_BUTTON_TEST_ID}`,
}];
}
return {
topBar: {
subtitle: {
color: changeOpacity(theme.sidebarHeaderTextColor, 0.72),
text: displayName,
},
leftButtons,
backButton: inModal ? {
color: theme.sidebarHeaderTextColor,
} : undefined,
},
};
};
type Props = {
componentId: AvailableScreens;
channel?: ChannelModel;
currentUserId: string;
teammateNameDisplay: string;
tutorialWatched: boolean;
inModal?: boolean;
}
const close = () => {
Keyboard.dismiss();
dismissModal();
};
const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
flex: 1,
},
searchBar: {
marginLeft: 12,
marginRight: Platform.select({ios: 4, default: 12}),
marginVertical: 12,
},
loadingContainer: {
alignItems: 'center',
backgroundColor: theme.centerChannelBg,
height: 70,
justifyContent: 'center',
},
loadingText: {
color: changeOpacity(theme.centerChannelColor, 0.6),
},
noResultContainer: {
flexGrow: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
noResultText: {
color: changeOpacity(theme.centerChannelColor, 0.5),
...typography('Body', 600, 'Regular'),
},
};
});
function removeProfileFromList(list: {[id: string]: UserProfile}, id: string) {
const newSelectedIds = Object.assign({}, list);
Reflect.deleteProperty(newSelectedIds, id);
return newSelectedIds;
}
export default function ChannelAddMembers({
componentId,
channel,
currentUserId,
teammateNameDisplay,
tutorialWatched,
inModal,
}: Props) {
const serverUrl = useServerUrl();
const theme = useTheme();
const style = getStyleFromTheme(theme);
const intl = useIntl();
const {formatMessage} = intl;
const mainView = useRef<View>(null);
const [containerHeight, setContainerHeight] = useState(0);
const keyboardOverlap = useKeyboardOverlap(mainView, containerHeight);
const [term, setTerm] = useState('');
const [addingMembers, setAddingMembers] = useState(false);
const [selectedIds, setSelectedIds] = useState<{[id: string]: UserProfile}>({});
const clearSearch = useCallback(() => {
setTerm('');
}, []);
const handleRemoveProfile = useCallback((id: string) => {
setSelectedIds((current) => removeProfileFromList(current, id));
}, []);
const addMembers = useCallback(async () => {
if (!channel) {
return;
}
if (addingMembers) {
return;
}
const idsToUse = Object.keys(selectedIds);
if (!idsToUse.length) {
return;
}
setAddingMembers(true);
const result = await addMembersToChannel(serverUrl, channel.id, idsToUse);
if (result.error) {
alertErrorWithFallback(intl, result.error, {id: t('mobile.channel_add_members.error'), defaultMessage: 'There has been an error and we could not add those users to the channel.'});
setAddingMembers(false);
} else {
close();
showAddChannelMembersSnackbar(idsToUse.length);
}
}, [channel, addingMembers, selectedIds, serverUrl, intl]);
const handleSelectProfile = useCallback((user: UserProfile) => {
clearSearch();
setSelectedIds((current) => {
if (current[user.id]) {
return removeProfileFromList(current, user.id);
}
const newSelectedIds = Object.assign({}, current);
newSelectedIds[user.id] = user;
return newSelectedIds;
});
}, [currentUserId, clearSearch]);
const onTextChange = useCallback((searchTerm: string) => {
setTerm(searchTerm);
}, []);
const onLayout = useCallback((e: LayoutChangeEvent) => {
setContainerHeight(e.nativeEvent.layout.height);
}, []);
const updateNavigationButtons = useCallback(async () => {
const options = await getHeaderOptions(theme, channel?.displayName || '', inModal);
mergeNavigationOptions(componentId, options);
}, [theme, channel?.displayName, inModal, componentId]);
const userFetchFunction = useCallback(async (page: number) => {
if (!channel) {
return [];
}
const result = await fetchProfilesNotInChannel(serverUrl, channel.teamId, channel.id, channel.isGroupConstrained, page, General.PROFILE_CHUNK_SIZE);
if (result.users?.length) {
return result.users.filter((u) => !u.delete_at);
}
return [];
}, [serverUrl, channel]);
const userSearchFunction = useCallback(async (searchTerm: string) => {
if (!channel) {
return [];
}
const lowerCasedTerm = searchTerm.toLowerCase();
const results = await searchProfiles(serverUrl, lowerCasedTerm, {team_id: channel.teamId, not_in_channel_id: channel.id, allow_inactive: false});
if (results.data) {
return results.data;
}
return [];
}, [serverUrl, channel]);
const createUserFilter = useCallback((exactMatches: UserProfile[], searchTerm: string) => {
return (p: UserProfile) => {
if (p.username === searchTerm || p.username.startsWith(searchTerm)) {
exactMatches.push(p);
return false;
}
return true;
};
}, []);
useNavButtonPressed(CLOSE_BUTTON_ID, componentId, close, [close]);
useAndroidHardwareBackHandler(componentId, close);
useEffect(() => {
updateNavigationButtons();
}, [updateNavigationButtons]);
if (addingMembers) {
return (
<View style={style.container}>
<Loading color={theme.centerChannelColor}/>
</View>
);
}
return (
<SafeAreaView
style={style.container}
testID={`${TEST_ID}.screen`}
onLayout={onLayout}
ref={mainView}
edges={['top', 'left', 'right']}
nativeID={SecurityManager.getShieldScreenId(componentId)}
>
<View style={style.searchBar}>
<Search
testID={`${TEST_ID}.search_bar`}
placeholder={formatMessage({id: 'search_bar.search', defaultMessage: 'Search'})}
cancelButtonTitle={formatMessage({id: 'mobile.post.cancel', defaultMessage: 'Cancel'})}
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.5)}
onChangeText={onTextChange}
onCancel={clearSearch}
autoCapitalize='none'
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
value={term}
/>
</View>
<ServerUserList
currentUserId={currentUserId}
handleSelectProfile={handleSelectProfile}
selectedIds={selectedIds}
term={term}
testID={`${TEST_ID}.user_list`}
tutorialWatched={tutorialWatched}
fetchFunction={userFetchFunction}
searchFunction={userSearchFunction}
createFilter={createUserFilter}
/>
<SelectedUsers
keyboardOverlap={keyboardOverlap}
selectedIds={selectedIds}
onRemove={handleRemoveProfile}
teammateNameDisplay={teammateNameDisplay}
onPress={addMembers}
buttonIcon={'account-plus-outline'}
buttonText={formatMessage({id: 'channel_add_members.add_members.button', defaultMessage: 'Add Members'})}
testID={`${TEST_ID}.selected`}
/>
</SafeAreaView>
);
}