// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {defineMessages, useIntl} from 'react-intl'; import {View} from 'react-native'; import {SafeAreaView} from 'react-native-safe-area-context'; import {fetchChannels, searchChannels} from '@actions/remote/channel'; import {fetchProfiles, searchProfiles} from '@actions/remote/user'; import FormattedText from '@components/formatted_text'; import SearchBar from '@components/search'; import ServerUserList from '@components/server_user_list'; import {General, Screens, View as ViewConstants} from '@constants'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useDidMount from '@hooks/did_mount'; import useNavButtonPressed from '@hooks/navigation_button_pressed'; import {useDebounce} from '@hooks/utils'; import SecurityManager from '@managers/security_manager'; import { buildNavigationButton, popTopScreen, setButtons, } from '@screens/navigation'; import {filterChannelsMatchingTerm} from '@utils/channel'; import {filterOptions} from '@utils/message_attachment'; import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme'; import {secureGetFromRecord} from '@utils/types'; import {typography} from '@utils/typography'; import ChannelListRow from './channel_list_row'; import CustomList from './custom_list'; import OptionListRow from './option_list_row'; import SelectedOptions from './selected_options'; import type {AvailableScreens} from '@typings/screens/navigation'; type DataType = DialogOption | Channel | UserProfile; type DataTypeList = DialogOption[] | Channel[] | UserProfile[]; type Selection = DataType | DataTypeList; type MultiselectSelectedMap = Dictionary | Dictionary | Dictionary; const VALID_DATASOURCES = [ ViewConstants.DATA_SOURCE_CHANNELS, ViewConstants.DATA_SOURCE_USERS, ViewConstants.DATA_SOURCE_DYNAMIC]; const SUBMIT_BUTTON_ID = 'submit-integration-selector-multiselect'; const close = () => { popTopScreen(); }; const extractItemKey = (dataSource: string, item: DataType): string => { switch (dataSource) { case ViewConstants.DATA_SOURCE_USERS: { const typedItem = item as UserProfile; return typedItem.id; } case ViewConstants.DATA_SOURCE_CHANNELS: { const typedItem = item as Channel; return typedItem.id; } default: { const typedItem = item as DialogOption; return typedItem.value; } } }; const toggleFromMap = (current: MultiselectSelectedMap, key: string, item: T): MultiselectSelectedMap => { const newMap = {...current}; const hasValue = Boolean(secureGetFromRecord(current, key)); if (hasValue) { delete newMap[key]; } else { newMap[key] = item; } return newMap; }; const filterSearchData = (source: string, searchData: DataTypeList, searchTerm: string) => { if (!searchData) { return []; } const lowerCasedTerm = searchTerm.toLowerCase(); if (source === ViewConstants.DATA_SOURCE_CHANNELS) { return filterChannelsMatchingTerm(searchData as Channel[], lowerCasedTerm); } else if (source === ViewConstants.DATA_SOURCE_DYNAMIC) { return searchData; } return (searchData as DialogOption[]).filter((option) => option.text && option.text.includes(lowerCasedTerm)); }; const handleIdSelection = (dataSource: string, currentIds: {[id: string]: DataType}, item: DataType) => { const newSelectedIds = {...currentIds}; const key = extractItemKey(dataSource, item); const wasSelected = secureGetFromRecord(currentIds, key); if (wasSelected) { Reflect.deleteProperty(newSelectedIds, key); } else { newSelectedIds[key] = item; } return newSelectedIds; }; export type Props = { getDynamicOptions?: (userInput?: string) => Promise; options?: PostActionOption[]; currentTeamId: string; data?: DataTypeList; dataSource: string; handleSelect: (opt: Selection) => void; isMultiselect?: boolean; selected: SelectedDialogValue; theme: Theme; componentId: AvailableScreens; } const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { return { container: { flex: 1, }, searchBar: { marginVertical: 5, height: 38, }, 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'), }, searchBarInput: { color: theme.centerChannelColor, ...typography('Body', 200, 'Regular'), }, separator: { height: 1, flex: 0, backgroundColor: changeOpacity(theme.centerChannelColor, 0.1), }, }; }); const messages = defineMessages({ loadingChannels: { id: 'mobile.integration_selector.loading_channels', defaultMessage: 'Loading channels...', }, loadingOptions: { id: 'mobile.integration_selector.loading_options', defaultMessage: 'Loading options...', }, }); function IntegrationSelector( {dataSource, data, isMultiselect = false, selected, handleSelect, currentTeamId, componentId, getDynamicOptions, options}: Props) { const serverUrl = useServerUrl(); const theme = useTheme(); const searchTimeoutId = useRef(null); const style = getStyleSheet(theme); const intl = useIntl(); // HOOKS const [integrationData, setIntegrationData] = useState(data || []); const [loading, setLoading] = useState(false); const [term, setTerm] = useState(''); const [searchResults, setSearchResults] = useState([]); // Channels and DialogOptions, will be removed const [multiselectSelected, setMultiselectSelected] = useState(() => { const multiselectItems: MultiselectSelectedMap = {}; if (isMultiselect && Array.isArray(selected) && !([ViewConstants.DATA_SOURCE_USERS, ViewConstants.DATA_SOURCE_CHANNELS].includes(dataSource))) { for (const value of selected) { const option = filteredOptions?.find((opt) => opt.value === value); if (option) { multiselectItems[value] = option; } } } return multiselectItems; }); // Users selection and in the future Channels and DialogOptions const [selectedIds, setSelectedIds] = useState<{[id: string]: DataType}>({}); const [customListData, setCustomListData] = useState([]); const page = useRef(-1); const next = useRef(VALID_DATASOURCES.includes(dataSource)); const filteredOptions = useMemo(() => { return filterOptions(options) || []; }, [options]); // Callbacks const clearSearch = useCallback(() => { setTerm(''); setSearchResults([]); }, []); // This is the button to submit multiselect options const rightButton = useMemo(() => { const base = buildNavigationButton( SUBMIT_BUTTON_ID, 'integration_selector.multiselect.submit.button', undefined, intl.formatMessage({id: 'integration_selector.multiselect.submit', defaultMessage: 'Done'}), ); base.enabled = true; base.showAsAction = 'always'; base.color = theme.sidebarHeaderTextColor; return base; }, [theme.sidebarHeaderTextColor, intl]); const handleSelectItem = useCallback((item: Selection) => { if (!isMultiselect) { handleSelect(item); close(); return; } switch (dataSource) { case ViewConstants.DATA_SOURCE_CHANNELS: { const itemKey = extractItemKey(dataSource, item as Channel); setMultiselectSelected((current) => toggleFromMap(current, itemKey, item as Channel)); return; } default: { const itemKey = extractItemKey(dataSource, item as DialogOption); setMultiselectSelected((current) => toggleFromMap(current, itemKey, item as DialogOption)); } } }, [isMultiselect, dataSource, handleSelect]); const handleRemoveOption = useCallback((item: Channel | DialogOption | UserProfile) => { const itemKey = extractItemKey(dataSource, item); if (dataSource === ViewConstants.DATA_SOURCE_USERS) { setSelectedIds((current) => { const selectedIdItems = {...current}; delete selectedIdItems[itemKey]; return selectedIdItems; }); } else { setMultiselectSelected((current) => { const multiselectSelectedItems = {...current}; if (secureGetFromRecord(multiselectSelectedItems, itemKey) !== undefined) { delete multiselectSelectedItems[itemKey]; } return multiselectSelectedItems; }); } }, [dataSource]); const getChannels = useDebounce(useCallback(async () => { if (next.current && !loading && !term) { setLoading(true); page.current += 1; const {channels: channelData} = await fetchChannels(serverUrl, currentTeamId, page.current); setLoading(false); if (channelData && channelData.length > 0) { setIntegrationData([...integrationData as Channel[], ...channelData]); } else { next.current = false; } } }, [loading, term, serverUrl, currentTeamId, integrationData]), 100); const loadMore = useCallback(async () => { if (dataSource === ViewConstants.DATA_SOURCE_CHANNELS) { await getChannels(); } // dynamic options are not paged so are not reloaded on scroll }, [getChannels, dataSource]); const searchDynamicOptions = useCallback(async (searchTerm = '') => { if (filteredOptions && filteredOptions !== integrationData && !searchTerm) { setIntegrationData(filteredOptions); } if (!getDynamicOptions) { return; } const results: DialogOption[] = await getDynamicOptions(searchTerm.toLowerCase()); const searchData = results || []; if (searchTerm) { setSearchResults(searchData); } else { setIntegrationData(searchData); } }, [filteredOptions, getDynamicOptions, integrationData]); const handleSelectProfile = useCallback((user: UserProfile): void => { if (!isMultiselect) { handleSelect(user); close(); } setSelectedIds((current) => handleIdSelection(dataSource, current, user)); }, [isMultiselect, handleSelect, dataSource]); const onHandleMultiselectSubmit = useCallback(() => { if (dataSource === ViewConstants.DATA_SOURCE_USERS) { // New multiselect handleSelect(Object.values(selectedIds) as UserProfile[]); } else { // Legacy multiselect handleSelect(Object.values(multiselectSelected)); } close(); }, [dataSource, handleSelect, selectedIds, multiselectSelected]); const onSearch = useCallback((text: string) => { if (!text) { clearSearch(); return; } setTerm(text); if (searchTimeoutId.current) { clearTimeout(searchTimeoutId.current); } searchTimeoutId.current = setTimeout(async () => { if (!dataSource) { setSearchResults(filterSearchData('', integrationData, text)); return; } setLoading(true); if (dataSource === ViewConstants.DATA_SOURCE_CHANNELS) { const isSearch = true; const {channels: receivedChannels} = await searchChannels( serverUrl, text, currentTeamId, isSearch); if (receivedChannels) { setSearchResults(receivedChannels); } } else if (dataSource === ViewConstants.DATA_SOURCE_DYNAMIC) { await searchDynamicOptions(text); } setLoading(false); }, General.SEARCH_TIMEOUT_MILLISECONDS); }, [clearSearch, dataSource, integrationData, serverUrl, currentTeamId, searchDynamicOptions]); // Effects useNavButtonPressed(SUBMIT_BUTTON_ID, componentId, onHandleMultiselectSubmit, [onHandleMultiselectSubmit]); useAndroidHardwareBackHandler(componentId, close); useEffect(() => { return () => { if (searchTimeoutId.current) { clearTimeout(searchTimeoutId.current); searchTimeoutId.current = null; } }; }, []); useDidMount(() => { if (dataSource === ViewConstants.DATA_SOURCE_CHANNELS) { getChannels(); } else { // Static and dynamic option search searchDynamicOptions(''); } }); useEffect(() => { let listData: DataTypeList = integrationData; if (term) { listData = searchResults; } if (dataSource === ViewConstants.DATA_SOURCE_DYNAMIC) { listData = (integrationData as DialogOption[]).filter((option) => option.text && option.text.toLowerCase().includes(term)); } setCustomListData(listData); // We only want to update the list data when the search results or integration data changes // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchResults, integrationData]); useEffect(() => { if (!isMultiselect) { return; } setButtons(componentId, { rightButtons: [rightButton], }); }, [rightButton, componentId, isMultiselect]); // Renders const renderLoading = useCallback(() => { if (!loading) { return null; } let text; switch (dataSource) { case ViewConstants.DATA_SOURCE_CHANNELS: text = messages.loadingChannels; break; default: text = messages.loadingOptions; break; } return ( ); }, [style, dataSource, loading]); const renderNoResults = useCallback((): JSX.Element | null => { if (loading || page.current === -1) { return null; } return ( ); }, [loading, style]); const renderChannelItem = useCallback((itemProps: any) => { const itemSelected = Boolean(multiselectSelected[itemProps.item.id]); return ( ); }, [multiselectSelected, theme, isMultiselect]); const renderOptionItem = useCallback((itemProps: any) => { const itemSelected = Boolean(secureGetFromRecord(multiselectSelected, itemProps.item.value)); return ( ); }, [multiselectSelected, theme, isMultiselect]); const getRenderItem = (): (itemProps: any) => JSX.Element => { switch (dataSource) { case ViewConstants.DATA_SOURCE_CHANNELS: return renderChannelItem; default: return renderOptionItem; } }; const renderSelectedOptions = useCallback((): React.ReactElement | null => { let selectedItems: Channel[] | DialogOption[] | UserProfile[] = Object.values(multiselectSelected); if (dataSource === ViewConstants.DATA_SOURCE_USERS) { // New multiselect selectedItems = Object.values(selectedIds) as UserProfile[]; } if (!selectedItems.length) { return null; } return ( <> ); }, [dataSource, handleRemoveOption, multiselectSelected, selectedIds, style.separator, theme]); const userFetchFunction = useCallback(async (userFetchPage: number) => { const result = await fetchProfiles(serverUrl, userFetchPage, General.PROFILE_CHUNK_SIZE); if (result.users?.length) { return result.users; } return []; }, [serverUrl]); const userSearchFunction = useCallback(async (searchTerm: string) => { const lowerCasedTerm = searchTerm.toLowerCase(); const results = await searchProfiles(serverUrl, lowerCasedTerm, {allow_inactive: false}); if (results.data) { return results.data; } return []; }, [serverUrl]); 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; }; }, []); const selectedUserIds = useMemo>(() => { if (dataSource === ViewConstants.DATA_SOURCE_USERS) { return new Set(Object.keys(selectedIds)); } return new Set(); }, [dataSource, selectedIds]); const renderDataTypeList = () => { switch (dataSource) { case ViewConstants.DATA_SOURCE_USERS: return ( ); default: return ( ); } }; const selectedOptionsComponent = renderSelectedOptions(); return ( {selectedOptionsComponent} {renderDataTypeList()} ); } export default IntegrationSelector;