mattermost-mobile/app/screens/invite/selection.tsx
Daniel Espino García 2111c8fb2c
[MM-66923] Fix scroll and private channel icon on invite user (#9363)
* [MM-66923] Fix scroll and private channel icon on invite user

* Address feedback

* Fix test
2026-01-07 08:13:59 +02:00

467 lines
17 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useState, useMemo, useRef} from 'react';
import {useIntl} from 'react-intl';
import {
Keyboard,
Platform,
View,
type LayoutChangeEvent,
useWindowDimensions,
FlatList,
type ListRenderItemInfo,
ScrollView,
} from 'react-native';
import {KeyboardAwareScrollView} from 'react-native-keyboard-aware-scroll-view';
import Animated, {useAnimatedStyle, useDerivedValue} from 'react-native-reanimated';
import SelectedChip from '@components/chips/selected_chip';
import SelectedUserChip from '@components/chips/selected_user_chip';
import FloatingTextInput from '@components/floating_input/floating_text_input_label';
import OptionItem from '@components/option_item';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import UserItem from '@components/user_item';
import {Screens} from '@constants';
import {MAX_LIST_HEIGHT, MAX_LIST_TABLET_DIFF} from '@constants/autocomplete';
import {useTheme} from '@context/theme';
import {useAutocompleteDefaultAnimatedValues} from '@hooks/autocomplete';
import {useAvoidKeyboard, useIsTablet} from '@hooks/device';
import {goToScreen} from '@screens/navigation';
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
import SelectionSearchBar from './selection_search_bar';
import SelectionTeamBar from './selection_team_bar';
import TextItem from './text_item';
import {TextItemType, type SearchResult, type SendOptions} from './types';
const AUTOCOMPLETE_ADJUST = 5;
const INITIAL_BATCH_TO_RENDER = 15;
const SCROLL_EVENT_THROTTLE = 60;
const keyboardDismissProp = Platform.select({
android: {
onScrollBeginDrag: Keyboard.dismiss,
},
ios: {
keyboardDismissMode: 'on-drag' as const,
},
});
const keyExtractor = (item: SearchResult) => (
typeof item === 'string' ? item : (item as UserProfile).id
);
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
display: 'flex',
flex: 1,
},
searchList: {
left: 20,
right: 20,
position: 'absolute',
bottom: Platform.select({ios: 'auto', default: undefined}),
},
searchListBorder: {
borderWidth: 1,
borderColor: changeOpacity(theme.centerChannelColor, 0.2),
borderRadius: 4,
elevation: 3,
},
searchListPadding: {
paddingVertical: 8,
flex: 1,
},
searchListShadow: {
shadowColor: '#000',
shadowOpacity: 0.12,
shadowRadius: 6,
shadowOffset: {
width: 0,
height: 6,
},
borderRadius: 4,
backgroundColor: theme.centerChannelBg,
},
searchListFlatList: {
backgroundColor: theme.centerChannelBg,
borderRadius: 4,
paddingHorizontal: 16,
},
selectedItems: {
display: 'flex',
},
selectedItemsContainer: {
alignItems: 'flex-start',
flexDirection: 'row',
flexWrap: 'wrap',
marginVertical: 16,
gap: 8,
},
contentContainer: {
paddingHorizontal: 20,
},
optionsContainer: {
marginTop: 16,
gap: 8,
},
};
});
function extractChannelId(channelId: Channel) {
return channelId.id;
}
type SelectionProps = {
teamId: string;
teamDisplayName: string;
teamLastIconUpdate: number;
teamInviteId: string;
teammateNameDisplay: string;
serverUrl: string;
term: string;
searchResults: SearchResult[];
selectedIds: {[id: string]: SearchResult};
keyboardOverlap: number;
wrapperHeight: number;
loading: boolean;
testID: string;
sendOptions: SendOptions;
onSendOptionsChange: React.Dispatch<React.SetStateAction<SendOptions>>;
onSearchChange: (text: string) => void;
onSelectItem: (item: SearchResult) => void;
onRemoveItem: (id: string) => void;
onClose: () => Promise<void>;
canInviteGuests: boolean;
allowGuestMagicLink: boolean;
}
export default function Selection({
teamId,
teamDisplayName,
teamLastIconUpdate,
teamInviteId,
teammateNameDisplay,
serverUrl,
term,
searchResults,
selectedIds,
keyboardOverlap,
wrapperHeight,
loading,
testID,
onSearchChange,
onSelectItem,
onRemoveItem,
onClose,
sendOptions: {
inviteAsGuest,
includeCustomMessage,
customMessage,
selectedChannels,
guestMagicLink,
},
onSendOptionsChange,
canInviteGuests,
allowGuestMagicLink,
}: SelectionProps) {
const theme = useTheme();
const styles = getStyleSheet(theme);
const dimensions = useWindowDimensions();
const isTablet = useIsTablet();
const intl = useIntl();
const [teamBarHeight, setTeamBarHeight] = useState(0);
const [searchBarHeight, setSearchBarHeight] = useState(0);
const hasChannelsSelected = selectedChannels.length > 0;
const keyboardAwareRef = useRef<KeyboardAwareScrollView>(null);
useAvoidKeyboard(keyboardAwareRef);
const onLayoutSelectionTeamBar = useCallback((e: LayoutChangeEvent) => {
setTeamBarHeight(e.nativeEvent.layout.height);
}, []);
const onLayoutSearchBar = useCallback((e: LayoutChangeEvent) => {
setSearchBarHeight(e.nativeEvent.layout.height);
}, []);
const handleOnRemoveItem = useCallback((id: string) => {
onRemoveItem(id);
}, [onRemoveItem]);
const otherElementsSize = teamBarHeight + searchBarHeight;
const workingSpace = wrapperHeight - keyboardOverlap;
const spaceOnTop = otherElementsSize - AUTOCOMPLETE_ADJUST;
const spaceOnBottom = workingSpace - otherElementsSize;
const autocompletePosition = spaceOnBottom > spaceOnTop ? (
otherElementsSize
) : (
workingSpace - otherElementsSize
);
const autocompleteAvailableSpace = spaceOnBottom > spaceOnTop ? spaceOnBottom : spaceOnTop;
const isLandscape = dimensions.width > dimensions.height;
const maxHeightAdjust = (isTablet && isLandscape) ? MAX_LIST_TABLET_DIFF : 0;
const defaultMaxHeight = MAX_LIST_HEIGHT - maxHeightAdjust;
const [animatedAutocompletePosition, animatedAutocompleteAvailableSpace] = useAutocompleteDefaultAnimatedValues(autocompletePosition, autocompleteAvailableSpace);
const maxHeight = useDerivedValue(() => {
return Math.min(animatedAutocompleteAvailableSpace.value, defaultMaxHeight);
}, [animatedAutocompleteAvailableSpace, defaultMaxHeight]);
const searchListContainerAnimatedStyle = useAnimatedStyle(() => ({
top: animatedAutocompletePosition.value,
maxHeight: maxHeight.value,
}), [animatedAutocompletePosition, maxHeight]);
const searchListContainerStyle = useMemo(() => {
const style = [];
style.push(
styles.searchList,
searchListContainerAnimatedStyle,
);
if (Platform.OS === 'ios') {
style.push(styles.searchListShadow);
}
return style;
}, [styles, searchListContainerAnimatedStyle]);
const searchListFlatListStyle = useMemo(() => {
const style = [];
style.push(styles.searchListFlatList);
if (searchResults.length || (term && !loading)) {
style.push(styles.searchListBorder, styles.searchListPadding);
}
return style;
}, [loading, searchResults.length, styles, term]);
const renderNoResults = useCallback(() => {
if (!term || loading) {
return null;
}
return (
<TextItem
text={term}
type={TextItemType.SEARCH_NO_RESULTS}
testID='invite.search_list_no_results'
/>
);
}, [term, loading]);
const renderItem = useCallback(({item}: ListRenderItemInfo<SearchResult>) => {
const key = keyExtractor(item);
return typeof item === 'string' ? (
<TouchableWithFeedback
key={key}
index={key}
onPress={() => onSelectItem(item)}
underlayColor={changeOpacity(theme.buttonBg, 0.08)}
type='native'
testID={`invite.search_list_item.${key}`}
>
<TextItem
text={item}
type={TextItemType.SEARCH_INVITE}
testID='invite.search_list_text_item'
/>
</TouchableWithFeedback>
) : (
<UserItem
user={item}
testID='invite.search_list_user_item'
onUserPress={onSelectItem}
/>
);
}, [theme.buttonBg, onSelectItem]);
const goToSelectorScreen = useCallback((() => {
const screen = Screens.INTEGRATION_SELECTOR;
const title = intl.formatMessage({id: 'invite.selected_channels', defaultMessage: 'Selected channels'});
const handleSelectChannels = (channels: Channel[]) => {
onSendOptionsChange((options) => ({
...options,
selectedChannels: channels.map(extractChannelId),
}));
};
goToScreen(screen, title, {
dataSource: 'channels',
handleSelect: handleSelectChannels,
selected: selectedChannels,
isMultiselect: true,
});
}), [intl, selectedChannels, onSendOptionsChange]);
const handleInviteAsGuestChange = useCallback(() => {
onSendOptionsChange((options) => ({
...options,
inviteAsGuest: !options.inviteAsGuest,
}));
}, [onSendOptionsChange]);
const handleIncludeCustomMessageChange = useCallback(() => {
onSendOptionsChange((options) => ({
...options,
includeCustomMessage: !options.includeCustomMessage,
}));
}, [onSendOptionsChange]);
const handleCustomMessageChange = useCallback((text: string) => {
onSendOptionsChange((options) => ({
...options,
customMessage: text,
}));
}, [onSendOptionsChange]);
const handlePasswordlessInvitesChange = useCallback(() => {
onSendOptionsChange((options) => ({
...options,
guestMagicLink: !options.guestMagicLink,
}));
}, [onSendOptionsChange]);
const renderSelectedItems = () => {
const selectedItems = [];
for (const id of Object.keys(selectedIds)) {
const selectedItem = selectedIds[id];
selectedItems.push(typeof selectedItem === 'string' ? (
<SelectedChip
id={id}
key={id}
text={selectedItem}
onRemove={handleOnRemoveItem}
testID={`invite.selected_item.${selectedItem}`}
/>
) : (
<SelectedUserChip
key={id}
user={selectedItem}
teammateNameDisplay={teammateNameDisplay}
onPress={handleOnRemoveItem}
testID='invite.selected_item'
/>
));
}
return selectedItems;
};
return (
<View
style={styles.container}
testID={testID}
>
<SelectionTeamBar
teamId={teamId}
teamDisplayName={teamDisplayName}
teamLastIconUpdate={teamLastIconUpdate}
teamInviteId={teamInviteId}
serverUrl={serverUrl}
onLayoutContainer={onLayoutSelectionTeamBar}
onClose={onClose}
/>
<KeyboardAwareScrollView
ref={keyboardAwareRef}
style={styles.contentContainer}
>
<SelectionSearchBar
term={term}
onSearchChange={onSearchChange}
onLayoutContainer={onLayoutSearchBar}
/>
{Object.keys(selectedIds).length > 0 && (
<ScrollView
style={styles.selectedItems}
contentContainerStyle={styles.selectedItemsContainer}
testID='invite.selected_items'
>
{renderSelectedItems()}
</ScrollView>
)}
<View style={styles.optionsContainer}>
{canInviteGuests && (
<OptionItem
label={intl.formatMessage({id: 'invite.invite_as_guest', defaultMessage: 'Invite as guest'})}
description={intl.formatMessage({id: 'invite.invite_as_guest_description', defaultMessage: 'Guests are limited to selected channels'})}
type='toggle'
selected={inviteAsGuest}
action={handleInviteAsGuestChange}
testID='invite.invite_as_guest'
/>
)}
{inviteAsGuest && (
<>
<OptionItem
label={intl.formatMessage({id: 'invite.selected_channels', defaultMessage: 'Selected channels'})}
type='arrow'
action={goToSelectorScreen}
info={hasChannelsSelected ? intl.formatMessage({id: 'invite.selected_channels_count', defaultMessage: '{count} {count, plural, one{channel} other{channels}}'}, {count: selectedChannels.length}) : intl.formatMessage({id: 'invite.no_channels_selected', defaultMessage: 'Required for guests'})}
isInfoDestructive={!hasChannelsSelected}
testID='invite.selected_channels'
icon={'globe'}
/>
<OptionItem
label={intl.formatMessage({id: 'invite.set_custom_message', defaultMessage: 'Set a custom message'})}
type='toggle'
selected={includeCustomMessage}
action={handleIncludeCustomMessageChange}
testID='invite.include_custom_message'
/>
{includeCustomMessage && (
<FloatingTextInput
label={intl.formatMessage({id: 'invite.custom_message', defaultMessage: 'Enter a custom message'})}
value={customMessage}
onChangeText={handleCustomMessageChange}
testID='invite.custom_message'
theme={theme}
multiline={true}
/>
)}
{allowGuestMagicLink && (
<OptionItem
label={intl.formatMessage({id: 'invite.guest_magic_link', defaultMessage: 'Use magic link'})}
description={intl.formatMessage({id: 'invite.guest_magic_link_description', defaultMessage: 'Newly created guests will join and log in without a password, using a magic link sent to their email address'})}
type='toggle'
selected={guestMagicLink}
action={handlePasswordlessInvitesChange}
testID='invite.guest_magic_link'
/>
)}
</>
)}
</View>
</KeyboardAwareScrollView>
<Animated.View style={searchListContainerStyle}>
<FlatList
data={searchResults}
keyboardShouldPersistTaps='always'
{...keyboardDismissProp}
keyExtractor={keyExtractor}
initialNumToRender={INITIAL_BATCH_TO_RENDER}
ListEmptyComponent={renderNoResults}
maxToRenderPerBatch={INITIAL_BATCH_TO_RENDER + 1}
renderItem={renderItem}
scrollEventThrottle={SCROLL_EVENT_THROTTLE}
testID='invite.search_list'
style={searchListFlatListStyle}
/>
</Animated.View>
</View>
);
}