[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 <nahumhbl@gmail.com>
This commit is contained in:
Daniel Espino García
2022-02-10 12:45:07 +01:00
committed by GitHub
parent 81fb2bef1a
commit 210a2f2d8a
21 changed files with 1249 additions and 11 deletions

View File

@@ -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};
}
};

View File

@@ -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`;
}

View File

@@ -22,6 +22,7 @@ export interface ClientChannelsMix {
getChannelByNameAndTeamName: (teamName: string, channelName: string, includeDeleted?: boolean) => Promise<Channel>;
getChannels: (teamId: string, page?: number, perPage?: number) => Promise<Channel[]>;
getArchivedChannels: (teamId: string, page?: number, perPage?: number) => Promise<Channel[]>;
getSharedChannels: (teamId: string, page?: number, perPage?: number) => Promise<Channel[]>;
getMyChannels: (teamId: string, includeDeleted?: boolean, lastDeleteAt?: number) => Promise<Channel[]>;
getMyChannelMember: (channelId: string) => Promise<ChannelMembership>;
getMyChannelMembers: (teamId: string) => Promise<ChannelMembership[]>;
@@ -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({

View File

@@ -163,6 +163,7 @@ exports[`components/channel_list should match snapshot 1`] = `
>
<Icon
name="plus"
onPress={[Function]}
style={
Object {
"color": "rgba(255,255,255,0.8)",

View File

@@ -126,6 +126,7 @@ exports[`components/channel_list/header Channel List Header Component should mat
>
<Icon
name="plus"
onPress={[Function]}
style={
Object {
"color": "rgba(255,255,255,0.8)",

View File

@@ -3,13 +3,13 @@
import React from 'react';
import {render} from '@test/intl-test-helper';
import {renderWithIntl} from '@test/intl-test-helper';
import Header from './header';
describe('components/channel_list/header', () => {
it('Channel List Header Component should match snapshot', () => {
const {toJSON} = render(
const {toJSON} = renderWithIntl(
<Header displayName={'Test!'}/>,
);

View File

@@ -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) => {
<CompassIcon
style={styles.plusIcon}
name={'plus'}
onPress={async () => {
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,
});
}}
/>
</TouchableWithFeedback>
</View>

View File

@@ -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<SystemModel>(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CONFIG).pipe(
switchMap(({value}: {value: ClientConfig}) => of$(value.ExperimentalTownSquareIsReadOnly === 'true')),

View File

@@ -15,6 +15,7 @@ import {isValidUrl} from '@utils/url';
type SlideUpPanelProps = {
destructive?: boolean;
icon?: string | Source;
rightIcon?: boolean;
imageStyles?: StyleProp<TextStyle>;
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)}
>
<View style={style.row}>
{Boolean(image) &&
{Boolean(image) && !rightIcon &&
<View style={iconStyle}>{image}</View>
}
<View style={style.textContainer}>
<Text style={[style.text, destructive ? style.destructive : null, textStyles]}>{text}</Text>
</View>
{Boolean(image) && rightIcon &&
<View style={iconStyle}>{image}</View>
}
</View>
</TouchableWithFeedback>
);

View File

@@ -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',

View File

@@ -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,

View File

@@ -19,6 +19,8 @@ type Props = {
title?: string;
}
export const TITLE_HEIGHT = 38;
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {

View File

@@ -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 = (
<Loading
containerStyle={style.loadingContainer}
style={style.loading}
color={theme.buttonBg}
/>
);
} else {
let channelDropdown;
if (canShowArchivedChannels || sharedChannelsEnabled) {
channelDropdown = (
<ChannelDropdown
onPress={changeTypeOfChannels}
typeOfChannels={typeOfChannels}
canShowArchivedChannels={canShowArchivedChannels}
sharedChannelsEnabled={sharedChannelsEnabled}
/>
);
}
content = (
<>
<View
testID='browse_channels.screen'
style={style.searchBar}
>
<SearchBar
testID='browse_channels.search_bar'
placeholder={intl.formatMessage({id: 'search_bar.search', defaultMessage: 'Search'})}
cancelTitle={intl.formatMessage({id: 'mobile.post.cancel', defaultMessage: 'Cancel'})}
backgroundColor='transparent'
inputHeight={33}
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.5)}
tintColorSearch={changeOpacity(theme.centerChannelColor, 0.5)}
tintColorDelete={changeOpacity(theme.centerChannelColor, 0.5)}
titleCancelColor={theme.centerChannelColor}
inputStyle={style.searchBarInput}
onChangeText={searchChannels}
onSearchButtonPress={searchChannels}
onCancelButtonPress={stopSearch}
autoCapitalize='none'
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
value={term}
/>
</View>
{channelDropdown}
<ChannelList
channels={channels}
onEndReached={onEndReached}
isSearch={Boolean(term)}
loading={loading}
onSelectChannel={onSelectChannel}
/>
</>
);
}
return (
<SafeAreaView style={style.container}>
{content}
</SafeAreaView>
);
}

View File

@@ -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 (
<DropdownSlideup
canShowArchivedChannels={canShowArchivedChannels}
onPress={onPress}
sharedChannelsEnabled={sharedChannelsEnabled}
selected={typeOfChannels}
/>
);
};
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 (
<View
testID='browse_channels.channel.dropdown'
>
<Text
accessibilityRole={'button'}
style={style.channelDropdown}
onPress={handleDropdownClick}
testID={`browse_channels.channel.dropdown.${typeOfChannels}`}
>
{channelDropdownText}
{' '}
<CompassIcon
name='menu-down'
size={18}
style={style.channelDropdownIcon}
/>
</Text>
</View>
);
}

View File

@@ -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 (
<ChannelListRow
channel={item}
testID='browse_channels.custom_list.channel_item'
onPress={onSelectChannel}
/>
);
}, [onSelectChannel]);
const renderLoading = useCallback(() => {
return (
<Loading
containerStyle={style.loadingContainer}
style={style.loading}
color={theme.buttonBg}
/>
);
//Style is covered by the theme
}, [theme]);
const renderNoResults = useCallback(() => {
if (isSearch) {
return (
<View style={style.noResultContainer}>
<FormattedText
id='mobile.custom_list.no_results'
defaultMessage='No Results'
style={style.noResultText}
/>
</View>
);
}
return (
<View style={style.noResultContainer}>
<FormattedText
id='browse_channels.noMore'
defaultMessage='No more channels to join'
style={style.noResultText}
/>
</View>
);
}, [style, isSearch]);
const renderSeparator = useCallback(() => (
<View
style={style.separator}
/>
), [theme]);
return (
<FlatList
data={channels}
renderItem={renderItem}
testID='browse_channels.flat_list'
ListEmptyComponent={loading ? renderLoading : renderNoResults}
onEndReached={onEndReached}
ListFooterComponent={loading && channels.length ? renderLoading : null}
contentContainerStyle={style.listContainer}
ItemSeparatorComponent={renderSeparator}
keyExtractor={channelKeyExtractor}
/>
);
}

View File

@@ -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 = (
<Text
style={style.purpose}
ellipsizeMode='tail'
numberOfLines={1}
>
{channel.purpose}
</Text>
);
}
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 (
<View style={style.outerContainer}>
<TouchableOpacity
onPress={handlePress}
>
<View
style={style.container}
testID={itemTestID}
>
<CompassIcon
name={icon}
size={20}
style={style.icon}
/>
<View style={style.titleContainer}>
<Text
style={style.displayName}
testID={channelDisplayNameTestID}
>
{channel.display_name}
</Text>
{purposeComponent}
</View>
</View>
</TouchableOpacity>
</View>
);
}

View File

@@ -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 (
<BottomSheetContent
showButton={false}
showTitle={!isTablet}
title={intl.formatMessage({id: 'browse_channels.dropdownTitle', defaultMessage: 'Show'})}
>
<SlideUpPanelItem
onPress={handlePublicPress}
testID='browse_channels.dropdownTitle.public'
text={intl.formatMessage({id: 'browse_channels.publicChannels', defaultMessage: 'Public Channels'})}
icon={selected === PUBLIC ? 'check' : undefined}
{...commonProps}
/>
{canShowArchivedChannels && (
<SlideUpPanelItem
onPress={handleArchivedPress}
testID='browse_channels.dropdownTitle.public'
text={intl.formatMessage({id: 'browse_channels.archivedChannels', defaultMessage: 'Archived Channels'})}
icon={selected === ARCHIVED ? 'check' : undefined}
{...commonProps}
/>
)}
{sharedChannelsEnabled && (
<SlideUpPanelItem
onPress={handleSharedPress}
testID='browse_channels.dropdownTitle.public'
text={intl.formatMessage({id: 'browse_channels.sharedChannels', defaultMessage: 'Shared Channels'})}
icon={selected === SHARED ? 'check' : undefined}
{...commonProps}
/>
)}
</BottomSheetContent>
);
}

View File

@@ -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<SystemModel>(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<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID).pipe(
switchMap(({value}) => of$(value)),
);
const currentUserId = database.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_USER_ID).pipe(
switchMap(({value}) => of$(value)),
);
const joinedChannels = database.get<MyChannelModel>(MY_CHANNEL).query().observe();
const currentUser = currentUserId.pipe(
switchMap((id) => database.get<UserModel>(USER).findAndObserve(id)),
);
const rolesArray = currentUser.pipe(
switchMap((u) => of$(u.roles.split(' '))),
);
const roles = rolesArray.pipe(
switchMap((values) => database.get<RoleModel>(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));

View File

@@ -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<Channel[]>([]);
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<void>>(async () => {/* Do nothing */});
const searchTimeout = useRef<NodeJS.Timeout>();
const [searchResults, setSearchResults] = useState<Channel[]>(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 (
<BrowseChannels
{...passProps}
currentTeamId={currentTeamId}
changeChannelType={changeChannelType}
channels={visibleChannels}
loading={loading}
onEndReached={onEndReached}
searchChannels={doSearchChannels}
stopSearch={stopSearch}
term={term}
typeOfChannels={typeOfChannels}
/>
);
}

View File

@@ -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;

View File

@@ -108,6 +108,7 @@ interface ClientConfig {
ExperimentalHideTownSquareinLHS: string;
ExperimentalNormalizeMarkdownLinks: string;
ExperimentalPrimaryTeam: string;
ExperimentalSharedChannels: string;
ExperimentalTimezone: string;
ExperimentalTownSquareIsReadOnly: string;
ExperimentalViewArchivedChannels: string;