From 210a2f2d8a12eb836ace5655dc6d85eb9d142f4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Espino=20Garc=C3=ADa?= Date: Thu, 10 Feb 2022 12:45:07 +0100 Subject: [PATCH] [Gekidou] [MM-39717] Add Channel Browser screen (#5868) * Add Channel Browser screen * Fix tests * Fix lint * Address feedback * Fix test * Remove cancel and fix bottom sheet size * Address feedback * Address feedback * Keep loading when not many items are visible. * Separate search_handler from browse channels * Search channels directly instead of filtering from the loaded * Address feeback * Reduce the size of search_handler.tsx * Add title and closeButtonId to bottomSheet * Let the default value be public and set it before the if Co-authored-by: Elias Nahum --- app/actions/remote/channel.ts | 74 ++++ app/client/rest/base.ts | 4 + app/client/rest/channels.ts | 8 + .../__snapshots__/index.test.tsx.snap | 1 + .../header/__snapshots__/header.test.tsx.snap | 1 + .../channel_list/header/header.test.tsx | 4 +- app/components/channel_list/header/header.tsx | 12 + app/components/post_draft/index.ts | 5 +- app/components/slide_up_panel_item/index.tsx | 8 +- app/constants/general.ts | 1 + app/constants/screens.ts | 2 + app/screens/bottom_sheet/content.tsx | 2 + .../browse_channels/browse_channels.tsx | 274 +++++++++++++++ .../browse_channels/channel_dropdown.tsx | 110 ++++++ app/screens/browse_channels/channel_list.tsx | 137 ++++++++ .../browse_channels/channel_list_row.tsx | 112 ++++++ .../browse_channels/dropdown_slideup.tsx | 99 ++++++ app/screens/browse_channels/index.ts | 68 ++++ .../browse_channels/search_handler.tsx | 331 ++++++++++++++++++ app/screens/index.tsx | 6 +- types/api/config.d.ts | 1 + 21 files changed, 1249 insertions(+), 11 deletions(-) create mode 100644 app/screens/browse_channels/browse_channels.tsx create mode 100644 app/screens/browse_channels/channel_dropdown.tsx create mode 100644 app/screens/browse_channels/channel_list.tsx create mode 100644 app/screens/browse_channels/channel_list_row.tsx create mode 100644 app/screens/browse_channels/dropdown_slideup.tsx create mode 100644 app/screens/browse_channels/index.ts create mode 100644 app/screens/browse_channels/search_handler.tsx diff --git a/app/actions/remote/channel.ts b/app/actions/remote/channel.ts index 13221c658b..eeadf40089 100644 --- a/app/actions/remote/channel.ts +++ b/app/actions/remote/channel.ts @@ -520,6 +520,58 @@ export const switchToChannelByName = async (serverUrl: string, channelName: stri } }; +export const fetchChannels = async (serverUrl: string, teamId: string, page = 0, perPage: number = General.CHANNELS_CHUNK_SIZE) => { + let client: Client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return {error}; + } + + try { + const channels = await client.getChannels(teamId, page, perPage); + + return {channels}; + } catch (error) { + forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); + return {error}; + } +}; + +export const fetchArchivedChannels = async (serverUrl: string, teamId: string, page = 0, perPage: number = General.CHANNELS_CHUNK_SIZE) => { + let client: Client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return {error}; + } + + try { + const channels = await client.getArchivedChannels(teamId, page, perPage); + + return {channels}; + } catch (error) { + forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); + return {error}; + } +}; + +export const fetchSharedChannels = async (serverUrl: string, teamId: string, page = 0, perPage: number = General.CHANNELS_CHUNK_SIZE) => { + let client: Client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return {error}; + } + try { + const channels = await client.getSharedChannels(teamId, page, perPage); + + return {channels}; + } catch (error) { + forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); + return {error}; + } +}; export async function getChannelMemberCountsByGroup(serverUrl: string, channelId: string, includeTimezones: boolean) { let client: Client; try { @@ -634,3 +686,25 @@ export const switchToPenultimateChannel = async (serverUrl: string) => { return {error}; } }; + +export const searchChannels = async (serverUrl: string, term: string) => { + const database = DatabaseManager.serverDatabases[serverUrl]?.database; + if (!database) { + return {error: `${serverUrl} database not found`}; + } + + let client: Client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return {error}; + } + + try { + const currentTeamId = await queryCurrentTeamId(database); + const channels = await client.autocompleteChannels(currentTeamId, term); + return {channels}; + } catch (error) { + return {error}; + } +}; diff --git a/app/client/rest/base.ts b/app/client/rest/base.ts index f825d33f53..9cf4031278 100644 --- a/app/client/rest/base.ts +++ b/app/client/rest/base.ts @@ -117,6 +117,10 @@ export default class ClientBase { return `${this.getChannelsRoute()}/${channelId}`; } + getSharedChannelsRoute() { + return `${this.urlVersion}/sharedchannels`; + } + getChannelMembersRoute(channelId: string) { return `${this.getChannelRoute(channelId)}/members`; } diff --git a/app/client/rest/channels.ts b/app/client/rest/channels.ts index 1e2036c8a6..ab9bb0389d 100644 --- a/app/client/rest/channels.ts +++ b/app/client/rest/channels.ts @@ -22,6 +22,7 @@ export interface ClientChannelsMix { getChannelByNameAndTeamName: (teamName: string, channelName: string, includeDeleted?: boolean) => Promise; getChannels: (teamId: string, page?: number, perPage?: number) => Promise; getArchivedChannels: (teamId: string, page?: number, perPage?: number) => Promise; + getSharedChannels: (teamId: string, page?: number, perPage?: number) => Promise; getMyChannels: (teamId: string, includeDeleted?: boolean, lastDeleteAt?: number) => Promise; getMyChannelMember: (channelId: string) => Promise; getMyChannelMembers: (teamId: string) => Promise; @@ -184,6 +185,13 @@ const ClientChannels = (superclass: any) => class extends superclass { ); }; + getSharedChannels = async (teamId: string, page = 0, perPage = PER_PAGE_DEFAULT) => { + return this.doFetch( + `${this.getSharedChannelsRoute()}/${teamId}${buildQueryString({page, per_page: perPage})}`, + {method: 'get'}, + ); + }; + getMyChannels = async (teamId: string, includeDeleted = false, lastDeleteAt = 0) => { return this.doFetch( `${this.getUserRoute('me')}/teams/${teamId}/channels${buildQueryString({ diff --git a/app/components/channel_list/__snapshots__/index.test.tsx.snap b/app/components/channel_list/__snapshots__/index.test.tsx.snap index 6cc69754eb..dc232fdca8 100644 --- a/app/components/channel_list/__snapshots__/index.test.tsx.snap +++ b/app/components/channel_list/__snapshots__/index.test.tsx.snap @@ -163,6 +163,7 @@ exports[`components/channel_list should match snapshot 1`] = ` > { it('Channel List Header Component should match snapshot', () => { - const {toJSON} = render( + const {toJSON} = renderWithIntl(
, ); diff --git a/app/components/channel_list/header/header.tsx b/app/components/channel_list/header/header.tsx index ac5e92ccc9..a7f2a7d5cd 100644 --- a/app/components/channel_list/header/header.tsx +++ b/app/components/channel_list/header/header.tsx @@ -2,13 +2,16 @@ // See LICENSE.txt for license information. import React, {useEffect} from 'react'; +import {useIntl} from 'react-intl'; import {Text, View} from 'react-native'; import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; import CompassIcon from '@components/compass_icon'; import TouchableWithFeedback from '@components/touchable_with_feedback'; +import {Screens} from '@constants'; import {useServerDisplayName} from '@context/server'; import {useTheme} from '@context/theme'; +import {showModal} from '@screens/navigation'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {typography} from '@utils/typography'; @@ -61,6 +64,8 @@ const ChannelListHeader = ({displayName, iconPad}: Props) => { marginLeft: withTiming(marginLeft.value, {duration: 350}), }), []); + const intl = useIntl(); + useEffect(() => { marginLeft.value = iconPad ? 44 : 0; }, [iconPad]); @@ -84,6 +89,13 @@ const ChannelListHeader = ({displayName, iconPad}: Props) => { { + const title = intl.formatMessage({id: 'browse_channels.title', defaultMessage: 'More Channels'}); + const closeButton = await CompassIcon.getImageSource('close', 24, theme.sidebarHeaderTextColor); + showModal(Screens.BROWSE_CHANNELS, title, { + closeButton, + }); + }} /> diff --git a/app/components/post_draft/index.ts b/app/components/post_draft/index.ts index c2f745e014..4a48761e88 100644 --- a/app/components/post_draft/index.ts +++ b/app/components/post_draft/index.ts @@ -43,10 +43,7 @@ const enhanced = withObservables([], (ownProps: WithDatabaseArgs & OwnProps) => ); const canPost = combineLatest([channel, currentUser]).pipe(switchMap(([c, u]) => from$(hasPermissionForChannel(c, u, Permissions.CREATE_POST, false)))); - let channelIsArchived = of$(ownProps.channelIsArchived); - if (!channelIsArchived) { - channelIsArchived = channel.pipe(switchMap((c) => of$(c.deleteAt !== 0))); - } + const channelIsArchived = channel.pipe(switchMap((c) => (ownProps.channelIsArchived ? of$(true) : of$(c.deleteAt !== 0)))); const experimentalTownSquareIsReadOnly = database.get(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe( switchMap(({value}: {value: ClientConfig}) => of$(value.ExperimentalTownSquareIsReadOnly === 'true')), diff --git a/app/components/slide_up_panel_item/index.tsx b/app/components/slide_up_panel_item/index.tsx index e8144d57c8..be4aee9ddb 100644 --- a/app/components/slide_up_panel_item/index.tsx +++ b/app/components/slide_up_panel_item/index.tsx @@ -15,6 +15,7 @@ import {isValidUrl} from '@utils/url'; type SlideUpPanelProps = { destructive?: boolean; icon?: string | Source; + rightIcon?: boolean; imageStyles?: StyleProp; onPress: () => void; textStyles?: TextStyle; @@ -65,7 +66,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => { }; }); -const SlideUpPanelItem = ({destructive, icon, imageStyles, onPress, testID, text, textStyles}: SlideUpPanelProps) => { +const SlideUpPanelItem = ({destructive, icon, imageStyles, onPress, testID, text, textStyles, rightIcon = false}: SlideUpPanelProps) => { const theme = useTheme(); const handleOnPress = useCallback(preventDoubleTap(onPress, 500), []); const style = getStyleSheet(theme); @@ -109,12 +110,15 @@ const SlideUpPanelItem = ({destructive, icon, imageStyles, onPress, testID, text underlayColor={changeOpacity(theme.centerChannelColor, 0.5)} > - {Boolean(image) && + {Boolean(image) && !rightIcon && {image} } {text} + {Boolean(image) && rightIcon && + {image} + } ); diff --git a/app/constants/general.ts b/app/constants/general.ts index 91ca21098c..c1d43b5738 100644 --- a/app/constants/general.ts +++ b/app/constants/general.ts @@ -4,6 +4,7 @@ export default { PAGE_SIZE_DEFAULT: 60, POST_CHUNK_SIZE: 60, + CHANNELS_CHUNK_SIZE: 50, STATUS_INTERVAL: 60000, AUTOCOMPLETE_LIMIT_DEFAULT: 25, MENTION: 'mention', diff --git a/app/constants/screens.ts b/app/constants/screens.ts index aabd61bd2e..596def55c8 100644 --- a/app/constants/screens.ts +++ b/app/constants/screens.ts @@ -6,6 +6,7 @@ export const ACCOUNT = 'Account'; export const EMOJI_PICKER = 'AddReaction'; export const APP_FORM = 'AppForm'; export const BOTTOM_SHEET = 'BottomSheet'; +export const BROWSE_CHANNELS = 'BrowseChannels'; export const CHANNEL = 'Channel'; export const CHANNEL_ADD_PEOPLE = 'ChannelAddPeople'; export const CHANNEL_DETAILS = 'ChannelDetails'; @@ -34,6 +35,7 @@ export default { EMOJI_PICKER, APP_FORM, BOTTOM_SHEET, + BROWSE_CHANNELS, CHANNEL, CHANNEL_ADD_PEOPLE, CHANNEL_EDIT, diff --git a/app/screens/bottom_sheet/content.tsx b/app/screens/bottom_sheet/content.tsx index 44a3f94e93..f6a2aeb2b0 100644 --- a/app/screens/bottom_sheet/content.tsx +++ b/app/screens/bottom_sheet/content.tsx @@ -19,6 +19,8 @@ type Props = { title?: string; } +export const TITLE_HEIGHT = 38; + const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { return { container: { diff --git a/app/screens/browse_channels/browse_channels.tsx b/app/screens/browse_channels/browse_channels.tsx new file mode 100644 index 0000000000..8e0738c37c --- /dev/null +++ b/app/screens/browse_channels/browse_channels.tsx @@ -0,0 +1,274 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useEffect, useState} from 'react'; +import {IntlShape, useIntl} from 'react-intl'; +import {Keyboard, View} from 'react-native'; +import {ImageResource, Navigation, OptionsTopBarButton} from 'react-native-navigation'; +import {SafeAreaView} from 'react-native-safe-area-context'; + +import {joinChannel, switchToChannelById} from '@actions/remote/channel'; +import Loading from '@components/loading'; +import SearchBar from '@components/search_bar'; +import {General} from '@constants'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import {dismissModal, goToScreen, setButtons} from '@screens/navigation'; +import {alertErrorWithFallback} from '@utils/draft'; +import { + changeOpacity, + makeStyleSheetFromTheme, + getKeyboardAppearanceFromTheme, +} from '@utils/theme'; + +import ChannelDropdown from './channel_dropdown'; +import ChannelList from './channel_list'; + +const CLOSE_BUTTON_ID = 'close-browse-channels'; +const CREATE_BUTTON_ID = 'create-pub-channel'; + +export const PUBLIC = 'public'; +export const SHARED = 'shared'; +export const ARCHIVED = 'archived'; + +const makeLeftButton = (icon: ImageResource): OptionsTopBarButton => { + return { + id: CLOSE_BUTTON_ID, + icon, + testID: 'close.browse_channels.button', + }; +}; + +const makeRightButton = (theme: Theme, formatMessage: IntlShape['formatMessage'], enabled: boolean): OptionsTopBarButton => { + return { + color: theme.sidebarHeaderTextColor, + id: CREATE_BUTTON_ID, + text: formatMessage({id: 'mobile.create_channel', defaultMessage: 'Create'}), + showAsAction: 'always', + testID: 'browse_channels.create.button', + enabled, + }; +}; + +const close = () => { + Keyboard.dismiss(); + dismissModal(); +}; + +const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + flex: 1, + }, + searchBar: { + marginHorizontal: 12, + borderRadius: 8, + marginTop: 12, + backgroundColor: changeOpacity(theme.centerChannelColor, 0.08), + }, + searchBarInput: { + color: theme.centerChannelColor, + }, + loadingContainer: { + flex: 1, + justifyContent: 'center' as const, + alignItems: 'center' as const, + }, + loading: { + height: 32, + width: 32, + justifyContent: 'center' as const, + }, + }; +}); + +type Props = { + + // Screen Props (do not change during the lifetime of the screen) + componentId: string; + categoryId?: string; + closeButton: ImageResource; + + // Properties not changing during the lifetime of the screen) + currentUserId: string; + currentTeamId: string; + + // Calculated Props + canCreateChannels: boolean; + sharedChannelsEnabled: boolean; + canShowArchivedChannels: boolean; + + // SearchHandler Props + typeOfChannels: string; + changeChannelType: (channelType: string) => void; + term: string; + searchChannels: (term: string) => void; + stopSearch: () => void; + loading: boolean; + onEndReached: () => void; + channels: Channel[]; +} + +export default function BrowseChannels(props: Props) { + const { + componentId, + canCreateChannels, + sharedChannelsEnabled, + closeButton, + currentUserId, + currentTeamId, + canShowArchivedChannels, + categoryId, + typeOfChannels, + changeChannelType: changeTypeOfChannels, + term, + searchChannels, + stopSearch, + channels, + loading, + onEndReached, + } = props; + const intl = useIntl(); + const theme = useTheme(); + const style = getStyleFromTheme(theme); + const serverUrl = useServerUrl(); + + const [adding, setAdding] = useState(false); + + const setHeaderButtons = useCallback((createEnabled: boolean) => { + const buttons = { + leftButtons: [makeLeftButton(closeButton)], + rightButtons: [] as OptionsTopBarButton[], + }; + + if (canCreateChannels) { + buttons.rightButtons = [makeRightButton(theme, intl.formatMessage, createEnabled)]; + } + + setButtons(componentId, buttons); + }, [closeButton, canCreateChannels, intl.locale, theme, componentId]); + + const onSelectChannel = useCallback(async (channel: Channel) => { + setHeaderButtons(false); + setAdding(true); + + const result = await joinChannel(serverUrl, currentUserId, currentTeamId, channel.id, '', false); + + if (result.error) { + alertErrorWithFallback( + intl, + result.error, + { + id: 'mobile.join_channel.error', + defaultMessage: "We couldn't join the channel {displayName}.", + }, + { + displayName: channel.display_name, + }, + ); + setHeaderButtons(true); + setAdding(false); + } else { + switchToChannelById(serverUrl, channel.id, currentTeamId); + close(); + } + }, [setHeaderButtons, intl.locale]); + + useEffect(() => { + const unsubscribe = Navigation.events().registerComponentListener({ + navigationButtonPressed: ({buttonId}: { buttonId: string }) => { + switch (buttonId) { + case CLOSE_BUTTON_ID: + close(); + break; + case CREATE_BUTTON_ID: { + // TODO part of https://mattermost.atlassian.net/browse/MM-39733 + // Update this to use the proper constant and the proper props. + const screen = 'CreateChannel'; + const title = intl.formatMessage({id: 'mobile.create_channel.public', defaultMessage: 'New Public Channel'}); + const passProps = { + channelType: General.OPEN_CHANNEL, + categoryId, + }; + + goToScreen(screen, title, passProps); + break; + } + } + }, + }, componentId); + return () => { + unsubscribe.remove(); + }; + }, [intl.locale, categoryId]); + + useEffect(() => { + // Update header buttons in case anything related to the header changes + setHeaderButtons(!adding); + }, [theme, canCreateChannels, adding]); + + let content; + if (adding) { + content = ( + + ); + } else { + let channelDropdown; + if (canShowArchivedChannels || sharedChannelsEnabled) { + channelDropdown = ( + + ); + } + + content = ( + <> + + + + {channelDropdown} + + + ); + } + + return ( + + {content} + + ); +} diff --git a/app/screens/browse_channels/channel_dropdown.tsx b/app/screens/browse_channels/channel_dropdown.tsx new file mode 100644 index 0000000000..8174b8a1c2 --- /dev/null +++ b/app/screens/browse_channels/channel_dropdown.tsx @@ -0,0 +1,110 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {useIntl} from 'react-intl'; +import {View, Text} from 'react-native'; + +import CompassIcon from '@components/compass_icon'; +import {ITEM_HEIGHT} from '@components/slide_up_panel_item'; +import {useTheme} from '@context/theme'; +import {TITLE_HEIGHT} from '@screens/bottom_sheet/content'; +import {bottomSheet} from '@screens/navigation'; +import {makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +import {ARCHIVED, SHARED} from './browse_channels'; +import DropdownSlideup from './dropdown_slideup'; + +type Props = { + typeOfChannels: string; + onPress: (channelType: string) => void; + canShowArchivedChannels: boolean; + sharedChannelsEnabled: boolean; +} + +const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => { + return { + channelDropdown: { + ...typography('Body', 100, 'SemiBold'), + lineHeight: 20, + color: theme.centerChannelColor, + marginLeft: 20, + marginTop: 12, + marginBottom: 4, + }, + channelDropdownIcon: { + color: theme.centerChannelColor, + }, + }; +}); + +const BOTTOM_SHEET_HEIGHT_BASE = 55; // Magic number + +export default function ChannelDropdown({ + typeOfChannels, + onPress, + canShowArchivedChannels, + sharedChannelsEnabled, +}: Props) { + const intl = useIntl(); + const theme = useTheme(); + const style = getStyleFromTheme(theme); + + // Depends on all props, so no need to use a callback. + const handleDropdownClick = () => { + const renderContent = () => { + return ( + + ); + }; + + let items = 1; + if (canShowArchivedChannels) { + items += 1; + } + if (sharedChannelsEnabled) { + items += 1; + } + + bottomSheet({ + title: intl.formatMessage({id: 'browse_channels.dropdownTitle', defaultMessage: 'Show'}), + renderContent, + snapPoints: [(items * ITEM_HEIGHT) + TITLE_HEIGHT + BOTTOM_SHEET_HEIGHT_BASE, 10], + closeButtonId: 'close', + theme, + }); + }; + + let channelDropdownText = intl.formatMessage({id: 'browse_channels.showPublicChannels', defaultMessage: 'Show: Public Channels'}); + if (typeOfChannels === SHARED) { + channelDropdownText = intl.formatMessage({id: 'browse_channels.showSharedChannels', defaultMessage: 'Show: Shared Channels'}); + } else if (typeOfChannels === ARCHIVED) { + channelDropdownText = intl.formatMessage({id: 'browse_channels.showArchivedChannels', defaultMessage: 'Show: Archived Channels'}); + } + return ( + + + {channelDropdownText} + {' '} + + + + ); +} diff --git a/app/screens/browse_channels/channel_list.tsx b/app/screens/browse_channels/channel_list.tsx new file mode 100644 index 0000000000..d380d8a994 --- /dev/null +++ b/app/screens/browse_channels/channel_list.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 {View, FlatList} from 'react-native'; + +import FormattedText from '@components/formatted_text'; +import Loading from '@components/loading'; +import {useTheme} from '@context/theme'; +import { + changeOpacity, + makeStyleSheetFromTheme, +} from '@utils/theme'; + +import ChannelListRow from './channel_list_row'; + +type Props = { + onEndReached: () => void; + loading: boolean; + isSearch: boolean; + channels: Channel[]; + onSelectChannel: (channel: Channel) => void; +} + +const channelKeyExtractor = (channel: Channel) => { + return channel.id; +}; + +const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => { + return { + noResultContainer: { + flexGrow: 1, + alignItems: 'center' as const, + justifyContent: 'center' as const, + }, + noResultText: { + fontSize: 26, + color: changeOpacity(theme.centerChannelColor, 0.5), + }, + loadingContainer: { + flex: 1, + justifyContent: 'center' as const, + alignItems: 'center' as const, + }, + loading: { + height: 32, + width: 32, + justifyContent: 'center' as const, + }, + listContainer: { + paddingHorizontal: 20, + flexGrow: 1, + }, + separator: { + height: 1, + backgroundColor: changeOpacity(theme.centerChannelColor, 0.08), + width: '100%', + }, + }; +}); + +export default function ChannelList({ + onEndReached, + onSelectChannel, + loading, + isSearch, + channels, +}: Props) { + const theme = useTheme(); + const style = getStyleFromTheme(theme); + + const renderItem = useCallback(({item}: {item: Channel}) => { + return ( + + ); + }, [onSelectChannel]); + + const renderLoading = useCallback(() => { + return ( + + ); + + //Style is covered by the theme + }, [theme]); + + const renderNoResults = useCallback(() => { + if (isSearch) { + return ( + + + + ); + } + + return ( + + + + ); + }, [style, isSearch]); + + const renderSeparator = useCallback(() => ( + + ), [theme]); + + return ( + + ); +} diff --git a/app/screens/browse_channels/channel_list_row.tsx b/app/screens/browse_channels/channel_list_row.tsx new file mode 100644 index 0000000000..ec34342529 --- /dev/null +++ b/app/screens/browse_channels/channel_list_row.tsx @@ -0,0 +1,112 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import { + Text, + TouchableOpacity, + View, +} from 'react-native'; + +import CompassIcon from '@components/compass_icon'; +import {useTheme} from '@context/theme'; +import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme'; +import {typography} from '@utils/typography'; + +type Props = { + channel: Channel; + onPress: (channel: Channel) => void; + testID?: string; +} + +const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => { + return { + titleContainer: { + marginLeft: 16, + flexDirection: 'column', + }, + displayName: { + color: theme.centerChannelColor, + ...typography('Body', 200), + }, + icon: { + padding: 2, + color: changeOpacity(theme.centerChannelColor, 0.56), + }, + container: { + flex: 1, + flexDirection: 'row', + }, + outerContainer: { + paddingVertical: 9, + }, + purpose: { + color: changeOpacity(theme.centerChannelColor, 0.64), + ...typography('Body', 75), + }, + }; +}); + +export default function ChannelListRow({ + channel, + onPress, + testID, +}: Props) { + const theme = useTheme(); + const style = getStyleFromTheme(theme); + + const handlePress = () => { + onPress(channel); + }; + + let purposeComponent; + if (channel.purpose) { + purposeComponent = ( + + {channel.purpose} + + ); + } + + const itemTestID = `${testID}.${channel.id}`; + const channelDisplayNameTestID = `${testID}.display_name`; + let icon = 'globe'; + if (channel.delete_at) { + icon = 'archive-outline'; + } else if (channel.shared) { + icon = 'circle-multiple-outline'; + } + + return ( + + + + + + + + {channel.display_name} + + {purposeComponent} + + + + + ); +} diff --git a/app/screens/browse_channels/dropdown_slideup.tsx b/app/screens/browse_channels/dropdown_slideup.tsx new file mode 100644 index 0000000000..e51bac744c --- /dev/null +++ b/app/screens/browse_channels/dropdown_slideup.tsx @@ -0,0 +1,99 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import {useIntl} from 'react-intl'; +import {DeviceEventEmitter} from 'react-native'; + +import SlideUpPanelItem from '@components/slide_up_panel_item'; +import NavigationConstants from '@constants/navigation'; +import {useTheme} from '@context/theme'; +import {useIsTablet} from '@hooks/device'; +import BottomSheetContent from '@screens/bottom_sheet/content'; +import { + makeStyleSheetFromTheme, + +} from '@utils/theme'; + +import {ARCHIVED, PUBLIC, SHARED} from './browse_channels'; + +type Props = { + onPress: (channelType: string) => void; + canShowArchivedChannels?: boolean; + sharedChannelsEnabled?: boolean; + selected: string; +} + +const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => { + return { + checkIcon: { + color: theme.buttonBg, + }, + }; +}); + +export default function DropdownSlideup({ + onPress, + canShowArchivedChannels, + selected, + sharedChannelsEnabled, +}: Props) { + const intl = useIntl(); + const theme = useTheme(); + const style = getStyleFromTheme(theme); + const isTablet = useIsTablet(); + + const commonProps = { + rightIcon: true, + imageStyles: style.checkIcon, + }; + + const handlePublicPress = useCallback(() => { + DeviceEventEmitter.emit(NavigationConstants.NAVIGATION_CLOSE_MODAL); + onPress(PUBLIC); + }, [onPress]); + + const handleArchivedPress = useCallback(() => { + DeviceEventEmitter.emit(NavigationConstants.NAVIGATION_CLOSE_MODAL); + onPress(ARCHIVED); + }, [onPress]); + + const handleSharedPress = useCallback(() => { + DeviceEventEmitter.emit(NavigationConstants.NAVIGATION_CLOSE_MODAL); + onPress(SHARED); + }, [onPress]); + + return ( + + + {canShowArchivedChannels && ( + + )} + {sharedChannelsEnabled && ( + + )} + + ); +} diff --git a/app/screens/browse_channels/index.ts b/app/screens/browse_channels/index.ts new file mode 100644 index 0000000000..b23b1a8367 --- /dev/null +++ b/app/screens/browse_channels/index.ts @@ -0,0 +1,68 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Q} from '@nozbe/watermelondb'; +import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; +import withObservables from '@nozbe/with-observables'; +import {of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import {Permissions} from '@constants'; +import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database'; +import {MyChannelModel} from '@database/models/server'; +import {hasPermission} from '@utils/role'; + +import SearchHandler from './search_handler'; + +import type {WithDatabaseArgs} from '@typings/database/database'; +import type RoleModel from '@typings/database/models/servers/role'; +import type SystemModel from '@typings/database/models/servers/system'; +import type UserModel from '@typings/database/models/servers/user'; + +const {SERVER: {SYSTEM, USER, ROLE, MY_CHANNEL}} = MM_TABLES; + +const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { + const config = database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe( + switchMap(({value}) => of$(value as ClientConfig)), + ); + + const sharedChannelsEnabled = config.pipe( + switchMap((v) => of$(v.ExperimentalSharedChannels === 'true')), + ); + + const canShowArchivedChannels = config.pipe( + switchMap((v) => of$(v.ExperimentalViewArchivedChannels === 'true')), + ); + + const currentTeamId = database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID).pipe( + switchMap(({value}) => of$(value)), + ); + const currentUserId = database.get(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).pipe( + switchMap(({value}) => of$(value)), + ); + + const joinedChannels = database.get(MY_CHANNEL).query().observe(); + + const currentUser = currentUserId.pipe( + switchMap((id) => database.get(USER).findAndObserve(id)), + ); + const rolesArray = currentUser.pipe( + switchMap((u) => of$(u.roles.split(' '))), + ); + const roles = rolesArray.pipe( + switchMap((values) => database.get(ROLE).query(Q.where('name', Q.oneOf(values))).observe()), + ); + + const canCreateChannels = roles.pipe(switchMap((r) => of$(hasPermission(r, Permissions.CREATE_PUBLIC_CHANNEL, false)))); + + return { + canCreateChannels, + currentUserId, + currentTeamId, + joinedChannels, + sharedChannelsEnabled, + canShowArchivedChannels, + }; +}); + +export default withDatabase(enhanced(SearchHandler)); diff --git a/app/screens/browse_channels/search_handler.tsx b/app/screens/browse_channels/search_handler.tsx new file mode 100644 index 0000000000..501c545458 --- /dev/null +++ b/app/screens/browse_channels/search_handler.tsx @@ -0,0 +1,331 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useEffect, useReducer, useRef, useState} from 'react'; +import {ImageResource} from 'react-native-navigation'; + +import {fetchArchivedChannels, fetchChannels, fetchSharedChannels, searchChannels} from '@actions/remote/channel'; +import {General} from '@constants'; +import {useServerUrl} from '@context/server'; +import useDidUpdate from '@hooks/did_update'; + +import BrowseChannels, {ARCHIVED, PUBLIC, SHARED} from './browse_channels'; + +import type MyChannelModel from '@typings/database/models/servers/my_channel'; + +type Props = { + + // Screen Props (do not change during the lifetime of the screen) + componentId: string; + categoryId?: string; + closeButton: ImageResource; + + // Properties not changing during the lifetime of the screen) + currentUserId: string; + currentTeamId: string; + + // Calculated Props + canCreateChannels: boolean; + joinedChannels?: MyChannelModel[]; + sharedChannelsEnabled: boolean; + canShowArchivedChannels: boolean; +} + +const MIN_CHANNELS_LOADED = 10; + +const LOAD = 'load'; +const STOP = 'stop'; + +const filterChannelsByType = (channels: Channel[], joinedChannels: MyChannelModel[], channelType: string) => { + const ids = joinedChannels.map((c) => c.id); + let filter: (c: Channel) => boolean; + switch (channelType) { + case ARCHIVED: + filter = (c) => c.delete_at !== 0; + break; + case SHARED: + filter = (c) => c.delete_at === 0 && c.shared && !ids.includes(c.id); + break; + case PUBLIC: + default: + filter = (c) => c.delete_at === 0 && !c.shared && !ids.includes(c.id); + break; + } + return channels.filter(filter); +}; + +const filterJoinedChannels = (joinedChannels: MyChannelModel[], allChannels: Channel[] | undefined) => { + const ids = joinedChannels.map((c) => c.id); + return allChannels?.filter((c) => !ids.includes(c.id)); +}; + +type State = { + channels: Channel[]; + archivedChannels: Channel[]; + sharedChannels: Channel[]; + loading: boolean; +} + +type Action = { + type: string; + data: Channel[]; +} + +const LoadAction: Action = {type: LOAD, data: []}; +const StopAction: Action = {type: STOP, data: []}; +const addAction = (t: string, data: Channel[]) => { + return {type: t, data}; +}; + +const reducer = (state: State, action: Action) => { + switch (action.type) { + case PUBLIC: + return { + ...state, + channels: [...state.channels, ...action.data], + loading: false, + }; + case ARCHIVED: + return { + ...state, + archivedChannels: [...state.archivedChannels, ...action.data], + loading: false, + }; + case SHARED: + return { + ...state, + sharedChannels: [...state.sharedChannels, ...action.data], + loading: false, + }; + case LOAD: + if (state.loading) { + return state; + } + return { + ...state, + loading: true, + }; + case STOP: + if (state.loading) { + return { + ...state, + loading: false, + }; + } + return state; + default: + return state; + } +}; + +const initialState = {channels: [], archivedChannels: [], sharedChannels: [], loading: false}; +const defaultJoinedChannels: MyChannelModel[] = []; +const defaultSearchResults: Channel[] = []; + +export default function SearchHandler(props: Props) { + const { + joinedChannels = defaultJoinedChannels, + currentTeamId, + ...passProps + } = props; + const serverUrl = useServerUrl(); + + const [{channels, archivedChannels, sharedChannels, loading}, dispatch] = useReducer(reducer, initialState); + + const [visibleChannels, setVisibleChannels] = useState([]); + const [term, setTerm] = useState(''); + + const [typeOfChannels, setTypeOfChannels] = useState(PUBLIC); + + const publicPage = useRef(-1); + const sharedPage = useRef(-1); + const archivedPage = useRef(-1); + const nextPublic = useRef(true); + const nextShared = useRef(true); + const nextArchived = useRef(true); + const loadedChannels = useRef<(data: Channel[] | undefined, typeOfChannels: string) => Promise>(async () => {/* Do nothing */}); + + const searchTimeout = useRef(); + const [searchResults, setSearchResults] = useState(defaultSearchResults); + + const isSearch = Boolean(term); + + const doGetChannels = (t: string) => { + let next: (typeof nextPublic | typeof nextShared | typeof nextArchived); + let fetch: (typeof fetchChannels | typeof fetchSharedChannels | typeof fetchArchivedChannels); + let page: (typeof publicPage | typeof sharedPage | typeof archivedPage); + + switch (t) { + case SHARED: + next = nextShared; + fetch = fetchSharedChannels; + page = sharedPage; + break; + case ARCHIVED: + next = nextArchived; + fetch = fetchArchivedChannels; + page = archivedPage; + break; + case PUBLIC: + default: + next = nextPublic; + fetch = fetchChannels; + page = publicPage; + } + + if (next.current) { + dispatch(LoadAction); + fetch( + serverUrl, + currentTeamId, + page.current + 1, + General.CHANNELS_CHUNK_SIZE, + ).then( + ({channels: receivedChannels}) => loadedChannels.current(receivedChannels, t), + ).catch( + () => dispatch(StopAction), + ); + } + }; + + const onEndReached = useCallback(() => { + if (!loading && !term) { + doGetChannels(typeOfChannels); + } + }, [typeOfChannels, loading, term]); + + let activeChannels: Channel[]; + switch (typeOfChannels) { + case ARCHIVED: + activeChannels = archivedChannels; + break; + case SHARED: + activeChannels = sharedChannels; + break; + default: + activeChannels = channels; + } + + const stopSearch = useCallback(() => { + setVisibleChannels(activeChannels); + setSearchResults(defaultSearchResults); + setTerm(''); + }, [activeChannels]); + + const doSearchChannels = useCallback((text: string) => { + if (text) { + setSearchResults(defaultSearchResults); + if (searchTimeout.current) { + clearTimeout(searchTimeout.current); + } + searchTimeout.current = setTimeout(async () => { + const results = await searchChannels(serverUrl, text); + if (results.channels) { + setSearchResults(results.channels); + } + dispatch(StopAction); + }, 500); + setTerm(text); + setVisibleChannels(searchResults); + dispatch(LoadAction); + } else { + stopSearch(); + } + }, [activeChannels, visibleChannels, joinedChannels, stopSearch]); + + const changeChannelType = useCallback((channelType: string) => { + setTypeOfChannels(channelType); + }, []); + + useEffect(() => { + loadedChannels.current = async (data: Channel[] | undefined, t: string) => { + let next: (typeof nextPublic | typeof nextShared | typeof nextArchived); + let page: (typeof publicPage | typeof sharedPage | typeof archivedPage); + let shouldFilterJoined: boolean; + switch (t) { + case SHARED: + page = sharedPage; + next = nextShared; + shouldFilterJoined = true; + break; + case ARCHIVED: + page = archivedPage; + next = nextArchived; + shouldFilterJoined = false; + break; + case PUBLIC: + default: + page = publicPage; + next = nextPublic; + shouldFilterJoined = true; + } + page.current += 1; + next.current = Boolean(data?.length); + let filtered = data; + if (shouldFilterJoined) { + filtered = filterJoinedChannels(joinedChannels, data); + } + if (filtered?.length) { + dispatch(addAction(t, filtered)); + } else if (data?.length) { + doGetChannels(t); + } else { + dispatch(StopAction); + } + }; + return () => { + loadedChannels.current = async () => {/* Do nothing */}; + }; + }, [joinedChannels]); + + useEffect(() => { + if (!isSearch) { + doGetChannels(typeOfChannels); + } + }, [typeOfChannels, isSearch]); + + useDidUpdate(() => { + if (isSearch) { + setVisibleChannels(filterChannelsByType(searchResults, joinedChannels, typeOfChannels)); + } else { + setVisibleChannels(activeChannels); + } + }, [activeChannels, isSearch && searchResults, isSearch && typeOfChannels, joinedChannels]); + + // Make sure enough channels are loaded to allow the FlatList to scroll, + // and let it call the onReachEnd function. + useDidUpdate(() => { + if (loading || isSearch || visibleChannels.length >= MIN_CHANNELS_LOADED) { + return; + } + let next; + switch (typeOfChannels) { + case PUBLIC: + next = nextPublic.current; + break; + case SHARED: + next = nextShared.current; + break; + default: + next = nextArchived.current; + } + if (next) { + doGetChannels(typeOfChannels); + } + }, [visibleChannels.length >= MIN_CHANNELS_LOADED, loading, isSearch]); + + return ( + + ); +} diff --git a/app/screens/index.tsx b/app/screens/index.tsx index 564685d902..ad338fbf87 100644 --- a/app/screens/index.tsx +++ b/app/screens/index.tsx @@ -152,9 +152,9 @@ Navigation.setLazyComponentRegistrator((screenName) => { case Screens.MFA: screen = withIntl(require('@screens/mfa').default); break; - // case 'MoreChannels': - // screen = require('@screens/more_channels').default; - // break; + case Screens.BROWSE_CHANNELS: + screen = withServerDatabase(require('@screens/browse_channels').default); + break; // case 'MoreDirectMessages': // screen = require('@screens/more_dms').default; // break; diff --git a/types/api/config.d.ts b/types/api/config.d.ts index 4d1aeb4b9f..5c5fbb64b4 100644 --- a/types/api/config.d.ts +++ b/types/api/config.d.ts @@ -108,6 +108,7 @@ interface ClientConfig { ExperimentalHideTownSquareinLHS: string; ExperimentalNormalizeMarkdownLinks: string; ExperimentalPrimaryTeam: string; + ExperimentalSharedChannels: string; ExperimentalTimezone: string; ExperimentalTownSquareIsReadOnly: string; ExperimentalViewArchivedChannels: string;