forked from Ivasoft/mattermost-mobile
[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:
committed by
GitHub
parent
81fb2bef1a
commit
210a2f2d8a
@@ -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};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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!'}/>,
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -19,6 +19,8 @@ type Props = {
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const TITLE_HEIGHT = 38;
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
return {
|
||||
container: {
|
||||
|
||||
274
app/screens/browse_channels/browse_channels.tsx
Normal file
274
app/screens/browse_channels/browse_channels.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
app/screens/browse_channels/channel_dropdown.tsx
Normal file
110
app/screens/browse_channels/channel_dropdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
137
app/screens/browse_channels/channel_list.tsx
Normal file
137
app/screens/browse_channels/channel_list.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
112
app/screens/browse_channels/channel_list_row.tsx
Normal file
112
app/screens/browse_channels/channel_list_row.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
99
app/screens/browse_channels/dropdown_slideup.tsx
Normal file
99
app/screens/browse_channels/dropdown_slideup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
app/screens/browse_channels/index.ts
Normal file
68
app/screens/browse_channels/index.ts
Normal 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));
|
||||
331
app/screens/browse_channels/search_handler.tsx
Normal file
331
app/screens/browse_channels/search_handler.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
1
types/api/config.d.ts
vendored
1
types/api/config.d.ts
vendored
@@ -108,6 +108,7 @@ interface ClientConfig {
|
||||
ExperimentalHideTownSquareinLHS: string;
|
||||
ExperimentalNormalizeMarkdownLinks: string;
|
||||
ExperimentalPrimaryTeam: string;
|
||||
ExperimentalSharedChannels: string;
|
||||
ExperimentalTimezone: string;
|
||||
ExperimentalTownSquareIsReadOnly: string;
|
||||
ExperimentalViewArchivedChannels: string;
|
||||
|
||||
Reference in New Issue
Block a user