diff --git a/app/components/autocomplete_selector/index.tsx b/app/components/autocomplete_selector/index.tsx
index 8a9c1f8b55..fd7adbfd03 100644
--- a/app/components/autocomplete_selector/index.tsx
+++ b/app/components/autocomplete_selector/index.tsx
@@ -137,7 +137,7 @@ function AutoCompleteSelector({
const goToSelectorScreen = useCallback(preventDoubleTap(() => {
const screen = Screens.INTEGRATION_SELECTOR;
- goToScreen(screen, title, {dataSource, handleSelect, options, getDynamicOptions, selected, isMultiselect});
+ goToScreen(screen, title, {dataSource, handleSelect, options, getDynamicOptions, selected, isMultiselect, teammateNameDisplay});
}), [dataSource, options, getDynamicOptions]);
const handleSelect = useCallback((item?: Selection) => {
diff --git a/app/components/user_list_row/index.tsx b/app/components/user_list_row/index.tsx
index cd12ee0ae2..f3ec54fabf 100644
--- a/app/components/user_list_row/index.tsx
+++ b/app/components/user_list_row/index.tsx
@@ -32,6 +32,7 @@ type Props = {
onPress?: (user: UserProfile) => void;
onLongPress: (user: UserProfile) => void;
selectable: boolean;
+ disabled?: boolean;
selected: boolean;
tutorialWatched?: boolean;
}
@@ -105,6 +106,7 @@ export default function UserListRow({
onLongPress,
tutorialWatched = false,
selectable,
+ disabled,
selected,
}: Props) {
const theme = useTheme();
@@ -154,7 +156,11 @@ export default function UserListRow({
}, [onLongPress, user]);
const icon = useMemo(() => {
- const iconOpacity = DEFAULT_ICON_OPACITY * (selectable ? 1 : DISABLED_OPACITY);
+ if (!selectable) {
+ return null;
+ }
+
+ const iconOpacity = DEFAULT_ICON_OPACITY * (disabled ? 1 : DISABLED_OPACITY);
const color = selected ? theme.buttonBg : changeOpacity(theme.centerChannelColor, iconOpacity);
return (
@@ -165,7 +171,7 @@ export default function UserListRow({
/>
);
- }, [selectable, selected, theme]);
+ }, [selectable, disabled, selected, theme]);
let usernameDisplay = `@${username}`;
if (isMyUser) {
@@ -179,7 +185,7 @@ export default function UserListRow({
const showTeammateDisplay = teammateDisplay !== username;
const userItemTestID = `${testID}.${id}`;
- const opacity = selectable || selected ? 1 : DISABLED_OPACITY;
+ const opacity = selectable || selected || !disabled ? 1 : DISABLED_OPACITY;
return (
<>
diff --git a/app/constants/screens.ts b/app/constants/screens.ts
index ddef79467d..8c60dfb5f6 100644
--- a/app/constants/screens.ts
+++ b/app/constants/screens.ts
@@ -159,6 +159,4 @@ export const NOT_READY = [
CHANNEL_ADD_PEOPLE,
CHANNEL_MENTION,
CREATE_TEAM,
- INTEGRATION_SELECTOR,
- INTERACTIVE_DIALOG,
];
diff --git a/app/screens/index.tsx b/app/screens/index.tsx
index f61f5cb4c2..5548a400d5 100644
--- a/app/screens/index.tsx
+++ b/app/screens/index.tsx
@@ -124,6 +124,9 @@ Navigation.setLazyComponentRegistrator((screenName) => {
case Screens.INTERACTIVE_DIALOG:
screen = withServerDatabase(require('@screens/interactive_dialog').default);
break;
+ case Screens.INTEGRATION_SELECTOR:
+ screen = withServerDatabase(require('@screens/integration_selector').default);
+ break;
case Screens.IN_APP_NOTIFICATION: {
const notificationScreen = require('@screens/in_app_notification').default;
Navigation.registerComponent(Screens.IN_APP_NOTIFICATION, () =>
diff --git a/app/screens/integration_selector/channel_list_row/__snapshots__/index.test.tsx.snap b/app/screens/integration_selector/channel_list_row/__snapshots__/index.test.tsx.snap
new file mode 100644
index 0000000000..cda0c91b2c
--- /dev/null
+++ b/app/screens/integration_selector/channel_list_row/__snapshots__/index.test.tsx.snap
@@ -0,0 +1,482 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`components/integration_selector/channel_list_row should match snapshot with delete_at filled in 1`] = `
+
+
+
+
+
+
+
+ channel
+
+
+
+
+
+
+`;
+
+exports[`components/integration_selector/channel_list_row should match snapshot with open channel icon 1`] = `
+
+
+
+
+
+
+
+ channel
+
+
+
+
+
+
+`;
+
+exports[`components/integration_selector/channel_list_row should match snapshot with private channel icon 1`] = `
+
+
+
+
+
+
+
+ channel
+
+
+
+
+
+
+`;
+
+exports[`components/integration_selector/channel_list_row should match snapshot with purpose filled in 1`] = `
+
+
+
+
+
+
+
+ channel
+
+
+
+ My purpose
+
+
+
+
+
+`;
+
+exports[`components/integration_selector/channel_list_row should match snapshot with shared filled in 1`] = `
+
+
+
+
+
+
+
+ channel
+
+
+
+
+
+
+`;
diff --git a/app/screens/integration_selector/channel_list_row/index.test.tsx b/app/screens/integration_selector/channel_list_row/index.test.tsx
new file mode 100644
index 0000000000..69cc99847e
--- /dev/null
+++ b/app/screens/integration_selector/channel_list_row/index.test.tsx
@@ -0,0 +1,158 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import Database from '@nozbe/watermelondb/Database';
+import React from 'react';
+
+import {Preferences} from '@app/constants';
+import {renderWithEverything} from '@test/intl-test-helper';
+import TestHelper from '@test/test_helper';
+
+import ChannelListRow from '.';
+
+describe('components/integration_selector/channel_list_row', () => {
+ let database: Database;
+ const channel: Channel = {
+ id: '1',
+ create_at: 1111,
+ update_at: 1111,
+ delete_at: 0,
+ team_id: 'my team',
+ type: 'O',
+ display_name: 'channel',
+ name: 'channel',
+ header: 'channel',
+ purpose: '',
+ last_post_at: 1,
+ total_msg_count: 1,
+ extra_update_at: 1,
+ creator_id: '1',
+ scheme_id: null,
+ group_constrained: null,
+ shared: true,
+ };
+ beforeAll(async () => {
+ const server = await TestHelper.setupServerDatabase();
+ database = server.database;
+ });
+
+ it('should match snapshot with open channel icon', () => {
+ const wrapper = renderWithEverything(
+ {
+ // noop
+ }}
+ >
+
+ ,
+ {database},
+ );
+
+ expect(wrapper.toJSON()).toMatchSnapshot();
+ });
+
+ it('should match snapshot with private channel icon', () => {
+ channel.type = 'P';
+
+ const wrapper = renderWithEverything(
+ {
+ // noop
+ }}
+ >
+
+ ,
+ {database},
+ );
+
+ expect(wrapper.toJSON()).toMatchSnapshot();
+ });
+
+ it('should match snapshot with delete_at filled in', () => {
+ channel.delete_at = 1111;
+ channel.shared = false;
+
+ const wrapper = renderWithEverything(
+ {
+ // noop
+ }}
+ >
+
+ ,
+ {database},
+ );
+
+ expect(wrapper.toJSON()).toMatchSnapshot();
+ });
+
+ it('should match snapshot with shared filled in', () => {
+ channel.delete_at = 0;
+ channel.shared = true;
+
+ const wrapper = renderWithEverything(
+ {
+ // noop
+ }}
+ >
+
+ ,
+ {database},
+ );
+
+ expect(wrapper.toJSON()).toMatchSnapshot();
+ });
+
+ it('should match snapshot with purpose filled in', () => {
+ channel.purpose = 'My purpose';
+
+ const wrapper = renderWithEverything(
+ {
+ // noop
+ }}
+ >
+
+ ,
+ {database},
+ );
+
+ expect(wrapper.toJSON()).toMatchSnapshot();
+ });
+});
diff --git a/app/screens/integration_selector/channel_list_row/index.tsx b/app/screens/integration_selector/channel_list_row/index.tsx
new file mode 100644
index 0000000000..a0f0bbe7d9
--- /dev/null
+++ b/app/screens/integration_selector/channel_list_row/index.tsx
@@ -0,0 +1,137 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React, {useCallback} from 'react';
+import {
+ Text,
+ View,
+} from 'react-native';
+
+import {typography} from '@app/utils/typography';
+import CompassIcon from '@components/compass_icon';
+import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
+
+import CustomListRow, {Props as CustomListRowProps} from '../custom_list_row';
+
+type ChannelListRowProps = {
+ id: string;
+ theme: object;
+ channel: Channel;
+ onPress: (item: Channel) => void;
+};
+
+type Props = ChannelListRowProps & CustomListRowProps;
+
+const getIconForChannel = (selectedChannel: Channel): string => {
+ let icon = 'globe';
+
+ if (selectedChannel.type === 'P') {
+ icon = 'padlock';
+ }
+
+ if (selectedChannel.delete_at) {
+ icon = 'archive-outline';
+ } else if (selectedChannel.shared) {
+ icon = 'circle-multiple-outline';
+ }
+
+ return icon;
+};
+
+const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
+ return {
+ titleContainer: {
+ alignItems: 'center',
+ flexDirection: 'row',
+ },
+ displayName: {
+ ...typography('Body', 200, 'Regular'),
+ color: theme.centerChannelColor,
+ marginLeft: 5,
+ },
+ icon: {
+ ...typography('Body', 200, 'Regular'),
+ color: theme.centerChannelColor,
+ },
+ container: {
+ flex: 1,
+ flexDirection: 'column',
+ },
+ outerContainer: {
+ flex: 1,
+ flexDirection: 'row',
+ paddingHorizontal: 15,
+ overflow: 'hidden',
+ },
+ purpose: {
+ marginTop: 7,
+ color: changeOpacity(theme.centerChannelColor, 0.5),
+ ...typography('Body', 100, 'Regular'),
+ },
+ };
+});
+
+const ChannelListRow = ({
+ onPress, id, theme, channel, testID, enabled, selectable, selected,
+}: Props) => {
+ const style = getStyleFromTheme(theme);
+
+ const onPressRow = useCallback((): void => {
+ onPress(channel);
+ }, [onPress, channel]);
+
+ const renderPurpose = (channelPurpose: string): JSX.Element | null => {
+ if (!channelPurpose) {
+ return null;
+ }
+
+ return (
+
+ {channel.purpose}
+
+ );
+ };
+
+ const itemTestID = `${testID}.${id}`;
+ const channelDisplayNameTestID = `${testID}.display_name`;
+ const channelIcon = getIconForChannel(channel);
+
+ return (
+
+
+
+
+
+
+ {channel.display_name}
+
+
+
+ {renderPurpose(channel.purpose)}
+
+
+
+ );
+};
+
+export default ChannelListRow;
diff --git a/app/screens/integration_selector/custom_list/index.test.tsx b/app/screens/integration_selector/custom_list/index.test.tsx
new file mode 100644
index 0000000000..8d2f79e7c4
--- /dev/null
+++ b/app/screens/integration_selector/custom_list/index.test.tsx
@@ -0,0 +1,68 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import Database from '@nozbe/watermelondb/Database';
+import React from 'react';
+import {Text} from 'react-native';
+
+import {Preferences} from '@app/constants';
+import {renderWithEverything} from '@test/intl-test-helper';
+import TestHelper from '@test/test_helper';
+
+import CustomList, {FLATLIST} from '.';
+
+describe('components/integration_selector/custom_list', () => {
+ let database: Database;
+ beforeAll(async () => {
+ const server = await TestHelper.setupServerDatabase();
+ database = server.database;
+ });
+
+ it('should render', () => {
+ const channel: Channel = {
+ id: '1',
+ create_at: 1111,
+ update_at: 1111,
+ delete_at: 1111,
+ team_id: 'my team',
+ type: 'O',
+ display_name: 'channel',
+ name: 'channel',
+ header: 'channel',
+ purpose: 'channel',
+ last_post_at: 1,
+ total_msg_count: 1,
+ extra_update_at: 1,
+ creator_id: '1',
+ scheme_id: null,
+ group_constrained: null,
+ shared: true,
+ };
+ const wrapper = renderWithEverything(
+ {
+ return {'No Results'};
+ }}
+ onLoadMore={() => {
+ // noop
+ }}
+ onRowPress={() => {
+ // noop
+ }}
+ renderItem={(props: object): JSX.Element => {
+ return ({props.toString()});
+ }}
+ loadingComponent={null}
+ />,
+ {database},
+ );
+
+ expect(wrapper.toJSON()).toBeTruthy();
+ });
+});
diff --git a/app/screens/integration_selector/custom_list/index.tsx b/app/screens/integration_selector/custom_list/index.tsx
new file mode 100644
index 0000000000..d9f5671e73
--- /dev/null
+++ b/app/screens/integration_selector/custom_list/index.tsx
@@ -0,0 +1,231 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+import React, {useCallback} from 'react';
+import {
+ Text, Platform, FlatList, RefreshControl, View, SectionList,
+} from 'react-native';
+
+import {typography} from '@app/utils/typography';
+import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
+
+export const FLATLIST = 'flat';
+export const SECTIONLIST = 'section';
+const INITIAL_BATCH_TO_RENDER = 15;
+
+type UserProfileSection = {
+ id: string;
+ data: UserProfile[];
+};
+type DataType = DialogOption[] | Channel[] | UserProfile[] | UserProfileSection[];
+type ListItemProps = {
+ id: string;
+ item: DialogOption | Channel | UserProfile;
+ selected: boolean;
+ selectable?: boolean;
+ enabled: boolean;
+ onPress: (item: DialogOption) => void;
+}
+
+type Props = {
+ data: DataType;
+ canRefresh?: boolean;
+ listType?: string;
+ loading?: boolean;
+ loadingComponent?: React.ReactElement | null;
+ noResults: () => JSX.Element | null;
+ refreshing?: boolean;
+ onRefresh?: () => void;
+ onLoadMore: () => void;
+ onRowPress: (item: UserProfile | Channel | DialogOption) => void;
+ renderItem: (props: ListItemProps) => JSX.Element;
+ selectable?: boolean;
+ theme?: object;
+ shouldRenderSeparator?: boolean;
+ testID?: string;
+}
+
+const keyExtractor = (item: any): string => {
+ return item.id || item.key || item.value || item;
+};
+
+const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
+ return {
+ list: {
+ backgroundColor: theme.centerChannelBg,
+ flex: 1,
+ ...Platform.select({
+ android: {
+ marginBottom: 20,
+ },
+ }),
+ },
+ container: {
+ flexGrow: 1,
+ },
+ separator: {
+ height: 1,
+ flex: 1,
+ backgroundColor: changeOpacity(theme.centerChannelColor, 0.1),
+ },
+ listView: {
+ flex: 1,
+ backgroundColor: theme.centerChannelBg,
+ ...Platform.select({
+ android: {
+ marginBottom: 20,
+ },
+ }),
+ },
+ loadingText: {
+ color: changeOpacity(theme.centerChannelColor, 0.6),
+ },
+ searching: {
+ backgroundColor: theme.centerChannelBg,
+ height: '100%',
+ position: 'absolute',
+ width: '100%',
+ },
+ sectionContainer: {
+ backgroundColor: changeOpacity(theme.centerChannelColor, 0.07),
+ paddingLeft: 10,
+ paddingVertical: 2,
+ },
+ sectionWrapper: {
+ backgroundColor: theme.centerChannelBg,
+ },
+ sectionText: {
+ fontWeight: '600',
+ color: theme.centerChannelColor,
+ },
+ noResultContainer: {
+ flex: 1,
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ noResultText: {
+ color: changeOpacity(theme.centerChannelColor, 0.5),
+ ...typography('Body', 600, 'Regular'),
+ },
+ };
+});
+
+function CustomList({
+ data, shouldRenderSeparator, listType, loading, loadingComponent, noResults,
+ onLoadMore, onRowPress, selectable, renderItem, theme,
+ canRefresh = true, testID, refreshing = false, onRefresh,
+}: Props) {
+ const style = getStyleFromTheme(theme);
+
+ // Renders
+ const renderEmptyList = useCallback(() => {
+ return noResults || null;
+ }, [noResults]);
+
+ const renderSeparator = useCallback(() => {
+ if (!shouldRenderSeparator) {
+ return null;
+ }
+
+ return (
+
+ );
+ }, [shouldRenderSeparator, style]);
+
+ const renderListItem = useCallback(({item}: any) => {
+ const props: ListItemProps = {
+ id: item.key,
+ item,
+ selected: item.selected,
+ selectable,
+ enabled: true,
+ onPress: onRowPress,
+ };
+
+ if ('disableSelect' in item) {
+ props.enabled = !item.disableSelect;
+ }
+
+ return renderItem(props);
+ }, [onRowPress, selectable, renderItem]);
+
+ const renderFooter = useCallback((): React.ReactElement | null => {
+ if (!loading || !loadingComponent) {
+ return null;
+ }
+ return loadingComponent;
+ }, [loading, loadingComponent]);
+
+ const renderSectionHeader = useCallback(({section}: any) => {
+ return (
+
+
+ {section.id}
+
+
+ );
+ }, [style]);
+
+ const renderSectionList = () => {
+ return (
+
+ );
+ };
+
+ const renderFlatList = () => {
+ let refreshControl;
+ if (canRefresh) {
+ refreshControl = (
+ );
+ }
+
+ return (
+
+ );
+ };
+
+ if (listType === FLATLIST) {
+ return renderFlatList();
+ }
+
+ return renderSectionList();
+}
+
+export default CustomList;
diff --git a/app/screens/integration_selector/custom_list_row/__snapshots__/index.test.tsx.snap b/app/screens/integration_selector/custom_list_row/__snapshots__/index.test.tsx.snap
new file mode 100644
index 0000000000..17dfeb003e
--- /dev/null
+++ b/app/screens/integration_selector/custom_list_row/__snapshots__/index.test.tsx.snap
@@ -0,0 +1,84 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`components/integration_selector/custom_list_row should match snapshot 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+ My channel
+
+
+
+
+
+`;
diff --git a/app/screens/integration_selector/custom_list_row/index.test.tsx b/app/screens/integration_selector/custom_list_row/index.test.tsx
new file mode 100644
index 0000000000..fda5607e5b
--- /dev/null
+++ b/app/screens/integration_selector/custom_list_row/index.test.tsx
@@ -0,0 +1,48 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import Database from '@nozbe/watermelondb/Database';
+import React from 'react';
+import {Text, View} from 'react-native';
+
+import CompassIcon from '@app/components/compass_icon';
+import {renderWithEverything} from '@test/intl-test-helper';
+import TestHelper from '@test/test_helper';
+
+import CustomListRow from '.';
+
+describe('components/integration_selector/custom_list_row', () => {
+ let database: Database;
+ beforeAll(async () => {
+ const server = await TestHelper.setupServerDatabase();
+ database = server.database;
+ });
+
+ it('should match snapshot', () => {
+ const wrapper = renderWithEverything(
+ {
+ // noop
+ }}
+ enabled={true}
+ selectable={true}
+ selected={true}
+ >
+
+
+
+
+ {'My channel'}
+
+
+
+ ,
+ {database},
+ );
+
+ expect(wrapper.toJSON()).toMatchSnapshot();
+ });
+});
diff --git a/app/screens/integration_selector/custom_list_row/index.tsx b/app/screens/integration_selector/custom_list_row/index.tsx
new file mode 100644
index 0000000000..4d8286580a
--- /dev/null
+++ b/app/screens/integration_selector/custom_list_row/index.tsx
@@ -0,0 +1,100 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react';
+import {
+ GestureResponderEvent,
+ StyleSheet,
+ TouchableOpacity,
+ View,
+} from 'react-native';
+
+import CompassIcon from '@app/components/compass_icon';
+
+export type Props = {
+ id: string;
+ onPress?: (event: GestureResponderEvent) => void;
+ enabled: boolean;
+ selectable: boolean;
+ selected: boolean;
+ children: React.ReactNode;
+ testID?: string;
+};
+
+const style = StyleSheet.create({
+ touchable: {
+ flex: 1,
+ overflow: 'hidden',
+ },
+ container: {
+ flexDirection: 'row',
+ height: 65,
+ flex: 1,
+ alignItems: 'center',
+ },
+ children: {
+ flex: 1,
+ flexDirection: 'row',
+ },
+ selector: {
+ height: 28,
+ width: 28,
+ borderRadius: 14,
+ borderWidth: 1,
+ borderColor: 'rgba(61, 60, 64, 0.32)',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ selectorContainer: {
+ height: 50,
+ paddingRight: 10,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ selectorDisabled: {
+ borderColor: 'rgba(61, 60, 64, 0.16)',
+ },
+ selectorFilled: {
+ backgroundColor: '#166DE0',
+ borderWidth: 0,
+ },
+});
+
+const CustomListRow = ({
+ onPress, children, enabled, selectable, selected, id, testID,
+}: Props) => {
+ return (
+
+ {selectable &&
+
+
+ {selected &&
+
+ }
+
+
+ }
+
+
+ {children}
+
+
+ );
+};
+
+export default CustomListRow;
diff --git a/app/screens/integration_selector/index.tsx b/app/screens/integration_selector/index.tsx
new file mode 100644
index 0000000000..3c2f109866
--- /dev/null
+++ b/app/screens/integration_selector/index.tsx
@@ -0,0 +1,622 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
+import withObservables from '@nozbe/with-observables';
+import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
+import {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 UserListRow from '@components/user_list_row';
+import {General, View as ViewConstants} from '@constants';
+import {useServerUrl} from '@context/server';
+import {useTheme} from '@context/theme';
+import {debounce} from '@helpers/api/general';
+import useNavButtonPressed from '@hooks/navigation_button_pressed';
+import {observeCurrentTeamId} from '@queries/servers/system';
+import {
+ buildNavigationButton,
+ popTopScreen, setButtons,
+} from '@screens/navigation';
+import {filterChannelsMatchingTerm} from '@utils/channel';
+import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme';
+import {typography} from '@utils/typography';
+import {filterProfilesMatchingTerm} from '@utils/user';
+
+import {createProfilesSections} from '../create_direct_message/user_list';
+
+import ChannelListRow from './channel_list_row';
+import CustomList, {FLATLIST, SECTIONLIST} from './custom_list';
+import OptionListRow from './option_list_row';
+import SelectedOptions from './selected_options';
+
+import type {WithDatabaseArgs} from '@typings/database/database';
+
+type DataType = DialogOption[] | Channel[] | UserProfile[];
+type Selection = DialogOption | Channel | UserProfile | DataType;
+type MultiselectSelectedMap = Dictionary | Dictionary | Dictionary;
+type UserProfileSection = {
+ id: string;
+ data: UserProfile[];
+};
+
+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: Selection): 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 filterSearchData = (source: string, searchData: DataType, searchTerm: string) => {
+ if (!searchData) {
+ return [];
+ }
+
+ const lowerCasedTerm = searchTerm.toLowerCase();
+
+ if (source === ViewConstants.DATA_SOURCE_USERS) {
+ return filterProfilesMatchingTerm(searchData as UserProfile[], lowerCasedTerm);
+ } else 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));
+};
+
+export type Props = {
+ getDynamicOptions?: (userInput?: string) => Promise;
+ options?: PostActionOption[];
+ currentTeamId: string;
+ data?: DataType;
+ dataSource: string;
+ handleSelect: (opt: Selection) => void;
+ isMultiselect?: boolean;
+ selected?: DialogOption[];
+ theme: Theme;
+ teammateNameDisplay: string;
+ componentId: string;
+}
+
+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: {
+ backgroundColor: changeOpacity(theme.centerChannelColor, 0.2),
+ color: theme.centerChannelColor,
+ ...typography('Body', 200, 'Regular'),
+ },
+ separator: {
+ height: 1,
+ flex: 0,
+ backgroundColor: changeOpacity(theme.centerChannelColor, 0.1),
+ },
+ };
+});
+
+function IntegrationSelector(
+ {dataSource, data, isMultiselect = false, selected, handleSelect,
+ currentTeamId, componentId, getDynamicOptions, options, teammateNameDisplay}: 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([]);
+ const [multiselectSelected, setMultiselectSelected] = useState({});
+ const [customListData, setCustomListData] = useState([]);
+
+ const page = useRef(-1);
+ const next = useRef(VALID_DATASOURCES.includes(dataSource));
+
+ // 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;
+ }
+
+ const itemKey = extractItemKey(dataSource, item);
+ const currentSelected: Dictionary | Dictionary | Dictionary = multiselectSelected;
+ const multiselectSelectedItems = {...currentSelected};
+
+ switch (dataSource) {
+ case ViewConstants.DATA_SOURCE_USERS: {
+ if (currentSelected[itemKey]) {
+ delete multiselectSelectedItems[itemKey];
+ } else {
+ multiselectSelectedItems[itemKey] = item as UserProfile;
+ }
+
+ setMultiselectSelected(multiselectSelectedItems);
+ return;
+ }
+ case ViewConstants.DATA_SOURCE_CHANNELS: {
+ if (currentSelected[itemKey]) {
+ delete multiselectSelectedItems[itemKey];
+ } else {
+ multiselectSelectedItems[itemKey] = item as Channel;
+ }
+
+ setMultiselectSelected(multiselectSelectedItems);
+ return;
+ }
+ default: {
+ if (currentSelected[itemKey]) {
+ delete multiselectSelectedItems[itemKey];
+ } else {
+ multiselectSelectedItems[itemKey] = item as DialogOption;
+ }
+ setMultiselectSelected(multiselectSelectedItems);
+ }
+ }
+ }, [integrationData, multiselectSelected, isMultiselect, dataSource, handleSelect]);
+
+ const handleRemoveOption = useCallback((item: UserProfile | Channel | DialogOption) => {
+ const currentSelected: Dictionary | Dictionary | Dictionary = multiselectSelected;
+ const itemKey = extractItemKey(dataSource, item);
+ const multiselectSelectedItems = {...currentSelected};
+ delete multiselectSelectedItems[itemKey];
+ setMultiselectSelected(multiselectSelectedItems);
+ }, [dataSource, multiselectSelected]);
+
+ const getChannels = useCallback(debounce(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;
+ }
+ }
+ }, 100), [loading, term, serverUrl, currentTeamId, integrationData]);
+
+ const getProfiles = useCallback(debounce(async () => {
+ if (next.current && !loading && !term) {
+ setLoading(true);
+ page.current += 1;
+
+ const {users: profiles} = await fetchProfiles(serverUrl, page.current);
+
+ setLoading(false);
+
+ if (profiles && profiles.length > 0) {
+ setIntegrationData([...integrationData as UserProfile[], ...profiles]);
+ } else {
+ next.current = false;
+ }
+ }
+ }, 100), [loading, term, integrationData]);
+
+ const loadMore = useCallback(async () => {
+ if (dataSource === ViewConstants.DATA_SOURCE_USERS) {
+ await getProfiles();
+ } else if (dataSource === ViewConstants.DATA_SOURCE_CHANNELS) {
+ await getChannels();
+ }
+
+ // dynamic options are not paged so are not reloaded on scroll
+ }, [getProfiles, getChannels, dataSource]);
+
+ const searchDynamicOptions = useCallback(async (searchTerm = '') => {
+ if (options && options !== integrationData && !searchTerm) {
+ setIntegrationData(options);
+ }
+
+ if (!getDynamicOptions) {
+ return;
+ }
+
+ const results: DialogOption[] = await getDynamicOptions(searchTerm.toLowerCase());
+ const searchData = results || [];
+
+ if (searchTerm) {
+ setSearchResults(searchData);
+ } else {
+ setIntegrationData(searchData);
+ }
+ }, [options, getDynamicOptions, integrationData]);
+
+ const onHandleMultiselectSubmit = useCallback(() => {
+ handleSelect(getMultiselectData(multiselectSelected));
+ close();
+ }, [multiselectSelected, handleSelect]);
+
+ 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_USERS) {
+ const {data: userData} = await searchProfiles(
+ serverUrl, text.toLowerCase(),
+ {team_id: currentTeamId, allow_inactive: true});
+
+ if (userData) {
+ setSearchResults(userData);
+ }
+ } else 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);
+ }, [dataSource, integrationData, currentTeamId]);
+
+ const getMultiselectData = useCallback((multiselectSelectedElems: MultiselectSelectedMap): Selection => {
+ let myItems;
+ let multiselectItems: Selection = [];
+
+ switch (dataSource) {
+ case ViewConstants.DATA_SOURCE_USERS:
+ myItems = multiselectSelectedElems as Dictionary;
+ multiselectItems = multiselectItems as UserProfile[];
+ // eslint-disable-next-line guard-for-in
+ for (const index in myItems) {
+ multiselectItems.push(myItems[index]);
+ }
+ return multiselectItems;
+ case ViewConstants.DATA_SOURCE_CHANNELS:
+ myItems = multiselectSelectedElems as Dictionary;
+ multiselectItems = multiselectItems as Channel[];
+ // eslint-disable-next-line guard-for-in
+ for (const index in myItems) {
+ multiselectItems.push(myItems[index]);
+ }
+ return multiselectItems;
+ default:
+ myItems = multiselectSelectedElems as Dictionary;
+ multiselectItems = multiselectItems as DialogOption[];
+ // eslint-disable-next-line guard-for-in
+ for (const index in myItems) {
+ multiselectItems.push(myItems[index]);
+ }
+ return multiselectItems;
+ }
+ }, [multiselectSelected, dataSource]);
+
+ // Effects
+ useNavButtonPressed(SUBMIT_BUTTON_ID, componentId, onHandleMultiselectSubmit, [onHandleMultiselectSubmit]);
+
+ useEffect(() => {
+ return () => {
+ if (searchTimeoutId.current) {
+ clearTimeout(searchTimeoutId.current);
+ searchTimeoutId.current = null;
+ }
+ };
+ }, []);
+
+ useEffect(() => {
+ if (dataSource === ViewConstants.DATA_SOURCE_USERS) {
+ getProfiles();
+ } else if (dataSource === ViewConstants.DATA_SOURCE_CHANNELS) {
+ getChannels();
+ } else {
+ // Static and dynamic option search
+ searchDynamicOptions('');
+ }
+ }, []);
+
+ useEffect(() => {
+ let listData: (DataType | UserProfileSection[]) = integrationData;
+
+ if (term) {
+ listData = searchResults;
+ }
+
+ if (dataSource === ViewConstants.DATA_SOURCE_USERS) {
+ listData = createProfilesSections(listData as UserProfile[]);
+ }
+
+ if (dataSource === ViewConstants.DATA_SOURCE_DYNAMIC) {
+ listData = (integrationData as DialogOption[]).filter((option) => option.text && option.text.toLowerCase().includes(term));
+ }
+
+ setCustomListData(listData);
+ }, [searchResults, integrationData]);
+
+ useEffect(() => {
+ if (!isMultiselect) {
+ return;
+ }
+
+ setButtons(componentId, {
+ rightButtons: [rightButton],
+ });
+ }, [rightButton, componentId, isMultiselect]);
+
+ useEffect(() => {
+ const multiselectItems: MultiselectSelectedMap = {};
+
+ if (multiselectSelected) {
+ return;
+ }
+
+ if (isMultiselect && selected && !([ViewConstants.DATA_SOURCE_USERS, ViewConstants.DATA_SOURCE_CHANNELS].includes(dataSource))) {
+ selected.forEach((opt) => {
+ multiselectItems[opt.value] = opt;
+ });
+
+ setMultiselectSelected(multiselectItems);
+ }
+ }, [multiselectSelected]);
+
+ // Renders
+ const renderLoading = useCallback(() => {
+ if (!loading) {
+ return null;
+ }
+
+ let text;
+ switch (dataSource) {
+ case ViewConstants.DATA_SOURCE_USERS:
+ text = {
+ id: intl.formatMessage({id: 'mobile.integration_selector.loading_users'}),
+ defaultMessage: 'Loading Users...',
+ };
+ break;
+ case ViewConstants.DATA_SOURCE_CHANNELS:
+ text = {
+ id: intl.formatMessage({id: 'mobile.integration_selector.loading_channels'}),
+ defaultMessage: 'Loading Channels...',
+ };
+ break;
+ default:
+ text = {
+ id: intl.formatMessage({id: 'mobile.integration_selector.loading_options'}),
+ defaultMessage: 'Loading Options...',
+ };
+ break;
+ }
+
+ return (
+
+
+
+ );
+ }, [style, dataSource, loading, intl]);
+
+ 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(multiselectSelected[itemProps.item.value]);
+ return (
+
+ );
+ }, [multiselectSelected, theme, isMultiselect]);
+
+ const renderUserItem = useCallback((itemProps: any): JSX.Element => {
+ const itemSelected = Boolean(multiselectSelected[itemProps.item.id]);
+
+ return (
+
+ );
+ }, [multiselectSelected, theme, isMultiselect, teammateNameDisplay]);
+
+ const getRenderItem = (): (itemProps: any) => JSX.Element => {
+ switch (dataSource) {
+ case ViewConstants.DATA_SOURCE_USERS:
+ return renderUserItem;
+ case ViewConstants.DATA_SOURCE_CHANNELS:
+ return renderChannelItem;
+ default:
+ return renderOptionItem;
+ }
+ };
+
+ const renderSelectedOptions = useCallback((): React.ReactElement | null => {
+ const selectedItems: any = Object.values(multiselectSelected);
+
+ if (!selectedItems.length) {
+ return null;
+ }
+
+ return (
+ <>
+
+
+ >
+ );
+ }, [multiselectSelected, style, theme]);
+
+ const listType = dataSource === ViewConstants.DATA_SOURCE_USERS ? SECTIONLIST : FLATLIST;
+ const selectedOptionsComponent = renderSelectedOptions();
+
+ return (
+
+
+
+
+
+ {selectedOptionsComponent}
+
+
+
+ );
+}
+
+const withTeamId = withObservables([], ({database}: WithDatabaseArgs) => ({
+ currentTeamId: observeCurrentTeamId(database),
+}));
+
+export default withDatabase(withTeamId(IntegrationSelector));
diff --git a/app/screens/integration_selector/option_list_row/__snapshots__/index.test.tsx.snap b/app/screens/integration_selector/option_list_row/__snapshots__/index.test.tsx.snap
new file mode 100644
index 0000000000..ef481625d8
--- /dev/null
+++ b/app/screens/integration_selector/option_list_row/__snapshots__/index.test.tsx.snap
@@ -0,0 +1,74 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`components/integration_selector/option_list_row should match snapshot for option 1`] = `
+
+
+
+
+
+
+ my text
+
+
+
+
+
+
+`;
diff --git a/app/screens/integration_selector/option_list_row/index.test.tsx b/app/screens/integration_selector/option_list_row/index.test.tsx
new file mode 100644
index 0000000000..79f622112c
--- /dev/null
+++ b/app/screens/integration_selector/option_list_row/index.test.tsx
@@ -0,0 +1,44 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import Database from '@nozbe/watermelondb/Database';
+import React from 'react';
+
+import {Preferences} from '@app/constants';
+import {renderWithEverything} from '@test/intl-test-helper';
+import TestHelper from '@test/test_helper';
+
+import OptionListRow from '.';
+
+describe('components/integration_selector/option_list_row', () => {
+ let database: Database;
+ beforeAll(async () => {
+ const server = await TestHelper.setupServerDatabase();
+ database = server.database;
+ });
+
+ it('should match snapshot for option', () => {
+ const myItem = {
+ value: '1',
+ text: 'my text',
+ };
+ const wrapper = renderWithEverything(
+ {
+ // noop
+ }}
+ >
+
+ ,
+ {database},
+ );
+
+ expect(wrapper.toJSON()).toMatchSnapshot();
+ });
+});
diff --git a/app/screens/integration_selector/option_list_row/index.tsx b/app/screens/integration_selector/option_list_row/index.tsx
new file mode 100644
index 0000000000..3d330c65b4
--- /dev/null
+++ b/app/screens/integration_selector/option_list_row/index.tsx
@@ -0,0 +1,77 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React, {useCallback} from 'react';
+import {
+ Text,
+ View,
+} from 'react-native';
+
+import {typography} from '@app/utils/typography';
+import {makeStyleSheetFromTheme} from '@utils/theme';
+
+import CustomListRow, {Props as CustomListRowProps} from '../custom_list_row';
+
+type OptionListRowProps = {
+ id: string;
+ theme: object;
+ item: { text: string; value: string };
+ onPress: (item: DialogOption) => void;
+}
+
+type Props = OptionListRowProps & CustomListRowProps;
+
+const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
+ return {
+ container: {
+ flexDirection: 'row',
+ height: 65,
+ paddingHorizontal: 15,
+ alignItems: 'center',
+ backgroundColor: theme.centerChannelBg,
+ },
+ textContainer: {
+ marginLeft: 10,
+ justifyContent: 'center',
+ flexDirection: 'column',
+ flex: 1,
+ },
+ optionText: {
+ color: theme.centerChannelColor,
+ ...typography('Body', 200, 'Regular'),
+ },
+ };
+});
+
+const OptionListRow = ({
+ enabled, selectable, selected, theme, item, onPress, id,
+}: Props) => {
+ const {text} = item;
+ const style = getStyleFromTheme(theme);
+
+ const onPressRow = useCallback((): void => {
+ onPress(item);
+ }, [onPress, item]);
+
+ return (
+
+
+
+
+
+ {text}
+
+
+
+
+
+ );
+};
+
+export default OptionListRow;
diff --git a/app/screens/integration_selector/selected_option/__snapshots__/index.test.tsx.snap b/app/screens/integration_selector/selected_option/__snapshots__/index.test.tsx.snap
new file mode 100644
index 0000000000..86293ea8ba
--- /dev/null
+++ b/app/screens/integration_selector/selected_option/__snapshots__/index.test.tsx.snap
@@ -0,0 +1,172 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`components/integration_selector/selected_option should match snapshot for channel 1`] = `
+
+
+ channel
+
+
+
+
+
+`;
+
+exports[`components/integration_selector/selected_option should match snapshot for option 1`] = `
+
+
+ my text
+
+
+
+
+
+`;
+
+exports[`components/integration_selector/selected_option should match snapshot for userProfile 1`] = `
+
+
+ johndoe
+
+
+
+
+
+`;
diff --git a/app/screens/integration_selector/selected_option/index.test.tsx b/app/screens/integration_selector/selected_option/index.test.tsx
new file mode 100644
index 0000000000..212585953a
--- /dev/null
+++ b/app/screens/integration_selector/selected_option/index.test.tsx
@@ -0,0 +1,117 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import Database from '@nozbe/watermelondb/Database';
+import React from 'react';
+
+import {Preferences} from '@app/constants';
+import {View as ViewConstants} from '@constants';
+import {renderWithEverything} from '@test/intl-test-helper';
+import TestHelper from '@test/test_helper';
+
+import SelectedOption from '.';
+
+describe('components/integration_selector/selected_option', () => {
+ let database: Database;
+ beforeAll(async () => {
+ const server = await TestHelper.setupServerDatabase();
+ database = server.database;
+ });
+
+ it('should match snapshot for option', () => {
+ const myItem = {
+ value: '1',
+ text: 'my text',
+ };
+ const wrapper = renderWithEverything(
+ {
+ // noop
+ }}
+ />,
+ {database},
+ );
+
+ expect(wrapper.toJSON()).toMatchSnapshot();
+ });
+
+ it('should match snapshot for userProfile', () => {
+ const userProfile: UserProfile = {
+ id: '1',
+ create_at: 1111,
+ update_at: 1111,
+ delete_at: 0,
+ username: 'johndoe',
+ nickname: 'johndoe',
+ first_name: 'johndoe',
+ last_name: 'johndoe',
+ position: 'hacker',
+ roles: 'admin',
+ locale: 'en_US',
+ notify_props: {
+ channel: 'true',
+ comments: 'never',
+ desktop: 'all',
+ desktop_sound: 'true',
+ email: 'true',
+ first_name: 'true',
+ mention_keys: 'false',
+ push: 'mention',
+ push_status: 'ooo',
+ },
+ email: 'johndoe@me.com',
+ auth_service: 'dummy',
+ };
+ const wrapper = renderWithEverything(
+ {
+ // noop
+ }}
+ />,
+ {database},
+ );
+
+ expect(wrapper.toJSON()).toMatchSnapshot();
+ });
+
+ it('should match snapshot for channel', () => {
+ const channel: Channel = {
+ id: '1',
+ create_at: 1111,
+ update_at: 1111,
+ delete_at: 0,
+ team_id: 'my team',
+ type: 'O',
+ display_name: 'channel',
+ name: 'channel',
+ header: 'channel',
+ purpose: '',
+ last_post_at: 1,
+ total_msg_count: 1,
+ extra_update_at: 1,
+ creator_id: '1',
+ scheme_id: null,
+ group_constrained: null,
+ shared: true,
+ };
+ const wrapper = renderWithEverything(
+ {
+ // noop
+ }}
+ />,
+ {database},
+ );
+
+ expect(wrapper.toJSON()).toMatchSnapshot();
+ });
+});
diff --git a/app/screens/integration_selector/selected_option/index.tsx b/app/screens/integration_selector/selected_option/index.tsx
new file mode 100644
index 0000000000..3bb0f6ac94
--- /dev/null
+++ b/app/screens/integration_selector/selected_option/index.tsx
@@ -0,0 +1,88 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React, {useCallback} from 'react';
+import {
+ Text,
+ TouchableOpacity,
+ View,
+} from 'react-native';
+
+import {typography} from '@app/utils/typography';
+import CompassIcon from '@components/compass_icon';
+import {View as ViewConstants} from '@constants';
+import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
+
+type Props = {
+ theme: Theme;
+ option: DialogOption | UserProfile | Channel;
+ dataSource: string;
+ onRemove: (opt: DialogOption | UserProfile | Channel) => void;
+}
+
+const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
+ return {
+ container: {
+ alignItems: 'center',
+ flexDirection: 'row',
+ height: 27,
+ borderRadius: 3,
+ backgroundColor: changeOpacity(theme.centerChannelColor, 0.2),
+ marginBottom: 4,
+ marginRight: 10,
+ paddingLeft: 10,
+ },
+ remove: {
+ paddingHorizontal: 10,
+ },
+ text: {
+ color: theme.centerChannelColor,
+ maxWidth: '90%',
+ ...typography('Body', 100, 'Regular'),
+ },
+ };
+});
+
+const SelectedOption = ({theme, option, onRemove, dataSource}: Props) => {
+ const style = getStyleFromTheme(theme);
+ const onPress = useCallback(
+ () => onRemove(option),
+ [onRemove, option],
+ );
+
+ let text;
+ switch (dataSource) {
+ case ViewConstants.DATA_SOURCE_USERS:
+ text = (option as UserProfile).username;
+ break;
+ case ViewConstants.DATA_SOURCE_CHANNELS:
+ text = (option as Channel).display_name;
+ break;
+ default:
+ text = (option as DialogOption).text;
+ break;
+ }
+
+ return (
+
+
+ {text}
+
+
+
+
+
+ );
+};
+
+export default SelectedOption;
diff --git a/app/screens/integration_selector/selected_options/__snapshots__/index.test.tsx.snap b/app/screens/integration_selector/selected_options/__snapshots__/index.test.tsx.snap
new file mode 100644
index 0000000000..57e7383a07
--- /dev/null
+++ b/app/screens/integration_selector/selected_options/__snapshots__/index.test.tsx.snap
@@ -0,0 +1,241 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`components/integration_selector/selected_options should match snapshot for channels 1`] = `
+
+
+
+
+
+ channel
+
+
+
+
+
+
+
+
+`;
+
+exports[`components/integration_selector/selected_options should match snapshot for options 1`] = `
+
+
+
+
+
+ my text
+
+
+
+
+
+
+
+
+`;
+
+exports[`components/integration_selector/selected_options should match snapshot for users 1`] = `
+
+
+
+
+
+ johndoe
+
+
+
+
+
+
+
+
+`;
diff --git a/app/screens/integration_selector/selected_options/index.test.tsx b/app/screens/integration_selector/selected_options/index.test.tsx
new file mode 100644
index 0000000000..2216489918
--- /dev/null
+++ b/app/screens/integration_selector/selected_options/index.test.tsx
@@ -0,0 +1,119 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import Database from '@nozbe/watermelondb/Database';
+import React from 'react';
+
+import {Preferences} from '@app/constants';
+import {View as ViewConstants} from '@constants';
+import {renderWithEverything} from '@test/intl-test-helper';
+import TestHelper from '@test/test_helper';
+
+import SelectedOptions from '.';
+
+describe('components/integration_selector/selected_options', () => {
+ let database: Database;
+ beforeAll(async () => {
+ const server = await TestHelper.setupServerDatabase();
+ database = server.database;
+ });
+
+ it('should match snapshot for users', () => {
+ const userProfile: UserProfile = {
+ id: '1',
+ create_at: 1111,
+ update_at: 1111,
+ delete_at: 1111,
+ username: 'johndoe',
+ nickname: 'johndoe',
+ first_name: 'johndoe',
+ last_name: 'johndoe',
+ position: 'hacker',
+ roles: 'admin',
+ locale: 'en_US',
+ notify_props: {
+ channel: 'true',
+ comments: 'never',
+ desktop: 'all',
+ desktop_sound: 'true',
+ email: 'true',
+ first_name: 'true',
+ mention_keys: 'false',
+ push: 'mention',
+ push_status: 'ooo',
+ },
+ email: 'johndoe@me.com',
+ auth_service: 'dummy',
+ };
+ const wrapper = renderWithEverything(
+ {
+ // noop
+ }}
+ />,
+ {database},
+ );
+
+ expect(wrapper.toJSON()).toMatchSnapshot();
+ });
+
+ it('should match snapshot for channels', () => {
+ const channel: Channel = {
+ id: '1',
+ create_at: 1111,
+ update_at: 1111,
+ delete_at: 0,
+ team_id: 'my team',
+ type: 'O',
+ display_name: 'channel',
+ name: 'channel',
+ header: 'channel',
+ purpose: '',
+ last_post_at: 1,
+ total_msg_count: 1,
+ extra_update_at: 1,
+ creator_id: '1',
+ scheme_id: null,
+ group_constrained: null,
+ shared: true,
+ };
+
+ const wrapper = renderWithEverything(
+ {
+ // noop
+ }}
+ />,
+ {database},
+ );
+
+ expect(wrapper.toJSON()).toMatchSnapshot();
+ });
+
+ it('should match snapshot for options', () => {
+ const myItem = {
+ value: '1',
+ text: 'my text',
+ };
+
+ const wrapper = renderWithEverything(
+ {
+ // noop
+ }}
+ />,
+ {database},
+ );
+
+ expect(wrapper.toJSON()).toMatchSnapshot();
+ });
+});
diff --git a/app/screens/integration_selector/selected_options/index.tsx b/app/screens/integration_selector/selected_options/index.tsx
new file mode 100644
index 0000000000..69ea7f640e
--- /dev/null
+++ b/app/screens/integration_selector/selected_options/index.tsx
@@ -0,0 +1,79 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react';
+import {View, ScrollView} from 'react-native';
+
+import {View as ViewConstants} from '@constants';
+import {makeStyleSheetFromTheme} from '@utils/theme';
+
+import SelectedOption from '../selected_option';
+
+type Props = {
+ theme: Theme;
+ selectedOptions: DialogOption[] | UserProfile[] | Channel[];
+ dataSource: string;
+ onRemove: (opt: DialogOption | UserProfile | Channel) => void;
+}
+
+const getStyleFromTheme = makeStyleSheetFromTheme(() => {
+ return {
+ container: {
+ marginLeft: 5,
+ marginBottom: 5,
+ maxHeight: 100,
+ flexGrow: 0,
+ },
+ users: {
+ alignItems: 'flex-start',
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ },
+ };
+});
+
+const SelectedOptions = ({
+ theme, selectedOptions, onRemove, dataSource,
+}: Props) => {
+ const style = getStyleFromTheme(theme);
+ const options: React.ReactNode[] = selectedOptions.map((optionItem) => {
+ let key: string;
+
+ switch (dataSource) {
+ case ViewConstants.DATA_SOURCE_USERS:
+ key = (optionItem as UserProfile).id;
+ break;
+ case ViewConstants.DATA_SOURCE_CHANNELS:
+ key = (optionItem as Channel).id;
+ break;
+ default:
+ key = (optionItem as DialogOption).value;
+ break;
+ }
+
+ return (
+ );
+ });
+
+ // eslint-disable-next-line no-warning-comments
+ // TODO Consider using a Virtualized List since the number of elements is potentially unbounded.
+ // https://mattermost.atlassian.net/browse/MM-48420
+ return (
+
+
+ {options}
+
+
+ );
+};
+
+export default SelectedOptions;
diff --git a/app/utils/channel/index.ts b/app/utils/channel/index.ts
index 96aeec86da..a87dd6b61d 100644
--- a/app/utils/channel/index.ts
+++ b/app/utils/channel/index.ts
@@ -142,3 +142,18 @@ export function compareNotifyProps(propsA: Partial, propsB:
return true;
}
+
+export function filterChannelsMatchingTerm(channels: Channel[], term: string): Channel[] {
+ const lowercasedTerm = term.toLowerCase();
+
+ return channels.filter((channel: Channel): boolean => {
+ if (!channel) {
+ return false;
+ }
+ const name = (channel.name || '').toLowerCase();
+ const displayName = (channel.display_name || '').toLowerCase();
+
+ return name.startsWith(lowercasedTerm) ||
+ displayName.startsWith(lowercasedTerm);
+ });
+}
diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json
index 7831966783..dd6ee008d1 100644
--- a/assets/base/i18n/en.json
+++ b/assets/base/i18n/en.json
@@ -448,6 +448,9 @@
"mobile.files_paste.error_dismiss": "Dismiss",
"mobile.files_paste.error_title": "Paste failed",
"mobile.gallery.title": "{index} of {total}",
+ "mobile.integration_selector.loading_users": "Loading users...",
+ "mobile.integration_selector.loading_channels": "Loading channels...",
+ "mobile.integration_selector.loading_options": "Loading options...",
"mobile.ios.photos_permission_denied_description": "Upload photos and videos to your server or save them to your device. Open Settings to grant {applicationName} Read and Write access to your photo and video library.",
"mobile.ios.photos_permission_denied_title": "{applicationName} would like to access your photos",
"mobile.join_channel.error": "We couldn't join the channel {displayName}.",