From 1132bd0e510d0c83b65f65d246670ae9a3c4f2cb Mon Sep 17 00:00:00 2001 From: Elias Nahum Date: Mon, 25 Apr 2022 13:27:20 -0400 Subject: [PATCH] [Gekidou] find channels (default state) (#6191) * Find channels unfiltered state * ux feedback review * Update snapshots after ux review * Fix channel item textStyles * Center Channel item content * feedback review --- app/actions/remote/user.ts | 5 +- .../channel_icon/dm_avatar/dm_avatar.tsx | 24 +- app/components/channel_icon/index.tsx | 33 +- .../__snapshots__/channel_item.test.tsx.snap | 286 ++++++++++++++++++ .../channel_item/channel_item.test.tsx | 21 ++ app/components/channel_item/channel_item.tsx | 256 ++++++++++++++++ .../custom_status/custom_status.tsx | 39 +++ .../channel_item/custom_status/index.ts | 37 +++ .../body => components}/channel_item/index.ts | 32 +- app/components/option_box/index.tsx | 66 ++++ app/components/search/index.tsx | 39 ++- app/components/user_item/user_item.tsx | 2 +- app/constants/screens.ts | 3 + app/queries/servers/channel.ts | 15 + app/screens/custom_status/index.tsx | 2 +- .../filtered_list/filtered_list.tsx | 84 +++++ .../find_channels/filtered_list/index.ts | 27 ++ app/screens/find_channels/index.tsx | 118 ++++++++ .../find_channels/quick_options/index.ts | 45 +++ .../quick_options/quick_options.tsx | 105 +++++++ .../find_channels/unfiltered_list/header.tsx | 46 +++ .../find_channels/unfiltered_list/index.ts | 54 ++++ .../unfiltered_list/unfiltered_list.tsx | 111 +++++++ .../__snapshots__/index.test.tsx.snap | 52 ++-- .../__snapshots__/category_body.test.tsx.snap | 111 ++++--- .../categories/body/category_body.test.tsx | 1 + .../categories/body/category_body.tsx | 9 +- .../__snapshots__/channel_item.test.tsx.snap | 140 --------- .../body/channel_item/channel_item.tsx | 176 ----------- .../categories_list/categories/categories.tsx | 18 +- .../categories/unreads/unreads.test.tsx | 1 + .../categories/unreads/unreads.tsx | 28 +- .../search/__snapshots__/index.test.tsx.snap | 52 ++-- .../categories_list/search/index.tsx | 75 +++-- app/screens/index.tsx | 7 +- app/utils/user/index.ts | 6 +- assets/base/i18n/en.json | 8 +- 37 files changed, 1607 insertions(+), 527 deletions(-) create mode 100644 app/components/channel_item/__snapshots__/channel_item.test.tsx.snap rename app/{screens/home/channel_list/categories_list/categories/body => components}/channel_item/channel_item.test.tsx (67%) create mode 100644 app/components/channel_item/channel_item.tsx create mode 100644 app/components/channel_item/custom_status/custom_status.tsx create mode 100644 app/components/channel_item/custom_status/index.ts rename app/{screens/home/channel_list/categories_list/categories/body => components}/channel_item/index.ts (75%) create mode 100644 app/components/option_box/index.tsx create mode 100644 app/screens/find_channels/filtered_list/filtered_list.tsx create mode 100644 app/screens/find_channels/filtered_list/index.ts create mode 100644 app/screens/find_channels/index.tsx create mode 100644 app/screens/find_channels/quick_options/index.ts create mode 100644 app/screens/find_channels/quick_options/quick_options.tsx create mode 100644 app/screens/find_channels/unfiltered_list/header.tsx create mode 100644 app/screens/find_channels/unfiltered_list/index.ts create mode 100644 app/screens/find_channels/unfiltered_list/unfiltered_list.tsx delete mode 100644 app/screens/home/channel_list/categories_list/categories/body/channel_item/__snapshots__/channel_item.test.tsx.snap delete mode 100644 app/screens/home/channel_list/categories_list/categories/body/channel_item/channel_item.tsx diff --git a/app/actions/remote/user.ts b/app/actions/remote/user.ts index 574420373..7b211f13d 100644 --- a/app/actions/remote/user.ts +++ b/app/actions/remote/user.ts @@ -574,7 +574,7 @@ export const setStatus = async (serverUrl: string, status: UserStatus) => { } }; -export const updateCustomStatus = async (serverUrl: string, user: UserModel, customStatus: UserCustomStatus) => { +export const updateCustomStatus = async (serverUrl: string, customStatus: UserCustomStatus) => { let client: Client; try { client = NetworkManager.getClient(serverUrl); @@ -583,6 +583,9 @@ export const updateCustomStatus = async (serverUrl: string, user: UserModel, cus } try { + if (!customStatus.duration) { + delete customStatus.expires_at; + } await client.updateCustomStatus(customStatus); return {data: true}; } catch (error) { diff --git a/app/components/channel_icon/dm_avatar/dm_avatar.tsx b/app/components/channel_icon/dm_avatar/dm_avatar.tsx index 290962d25..f09ac42f2 100644 --- a/app/components/channel_icon/dm_avatar/dm_avatar.tsx +++ b/app/components/channel_icon/dm_avatar/dm_avatar.tsx @@ -2,6 +2,7 @@ // See LICENSE.txt for license information. import React from 'react'; +import {View} from 'react-native'; import ProfilePicture from '@components/profile_picture'; import {useTheme} from '@context/theme'; @@ -11,26 +12,33 @@ import type UserModel from '@typings/database/models/servers/user'; type Props = { author?: UserModel; + isInfo?: boolean; } const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + container: {marginLeft: 4}, status: { backgroundColor: theme.sidebarBg, borderWidth: 0, }, + statusInfo: { + backgroundColor: theme.centerChannelBg, + }, })); -const DmAvatar = ({author}: Props) => { +const DmAvatar = ({author, isInfo}: Props) => { const theme = useTheme(); const style = getStyleSheet(theme); return ( - + + + ); }; diff --git a/app/components/channel_icon/index.tsx b/app/components/channel_icon/index.tsx index c7929ad9b..54b832c8c 100644 --- a/app/components/channel_icon/index.tsx +++ b/app/components/channel_icon/index.tsx @@ -44,6 +44,9 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { color: theme.sidebarUnreadText, }, iconInfo: { + color: changeOpacity(theme.centerChannelColor, 0.72), + }, + iconInfoUnread: { color: theme.centerChannelColor, }, groupBox: { @@ -72,6 +75,9 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { color: theme.sidebarUnreadText, }, groupInfo: { + color: changeOpacity(theme.centerChannelColor, 0.72), + }, + groupInfoUnread: { color: theme.centerChannelColor, }, muted: { @@ -81,19 +87,10 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { }); const ChannelIcon = ({ - hasDraft = false, - isActive = false, - isArchived = false, - isInfo = false, - isUnread = false, - isMuted = false, - membersCount = 0, - name, - shared, - size = 12, - style, - testID, - type, + hasDraft = false, isActive = false, isArchived = false, + isInfo = false, isUnread = false, isMuted = false, + membersCount = 0, name, + shared, size = 12, style, testID, type, }: ChannelIconProps) => { const theme = useTheme(); const styles = getStyleSheet(theme); @@ -119,9 +116,9 @@ const ChannelIcon = ({ } if (isInfo) { - activeIcon = styles.iconInfo; + activeIcon = isUnread ? styles.iconInfoUnread : styles.iconInfo; activeGroupBox = styles.groupBoxInfo; - activeGroup = styles.groupInfo; + activeGroup = isUnread ? styles.groupInfoUnread : styles.groupInfo; } if (isMuted) { @@ -186,7 +183,11 @@ const ChannelIcon = ({ ); } else if (type === General.DM_CHANNEL) { - icon = (); + icon = ( + ); } return ( diff --git a/app/components/channel_item/__snapshots__/channel_item.test.tsx.snap b/app/components/channel_item/__snapshots__/channel_item.test.tsx.snap new file mode 100644 index 000000000..ff68ef6d8 --- /dev/null +++ b/app/components/channel_item/__snapshots__/channel_item.test.tsx.snap @@ -0,0 +1,286 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`components/channel_list/categories/body/channel_item should match snapshot 1`] = ` + + + + + + + + 1 + + + + + + Hello! + + + + + + +`; + +exports[`components/channel_list/categories/body/channel_item should match snapshot when it has a draft 1`] = ` + + + + + + + + + + Hello! + + + + + + +`; diff --git a/app/screens/home/channel_list/categories_list/categories/body/channel_item/channel_item.test.tsx b/app/components/channel_item/channel_item.test.tsx similarity index 67% rename from app/screens/home/channel_list/categories_list/categories/body/channel_item/channel_item.test.tsx rename to app/components/channel_item/channel_item.test.tsx index 3031d821a..9340b34f5 100644 --- a/app/screens/home/channel_list/categories_list/categories/body/channel_item/channel_item.test.tsx +++ b/app/components/channel_item/channel_item.test.tsx @@ -32,6 +32,7 @@ describe('components/channel_list/categories/body/channel_item', () => { const wrapper = renderWithIntlAndTheme( { currentUserId={'id'} testID='channel_list_item' isVisible={true} + onPress={() => undefined} + />, + ); + + expect(wrapper.toJSON()).toMatchSnapshot(); + }); + + it('should match snapshot when it has a draft', () => { + const wrapper = renderWithIntlAndTheme( + undefined} />, ); diff --git a/app/components/channel_item/channel_item.tsx b/app/components/channel_item/channel_item.tsx new file mode 100644 index 000000000..73d50454c --- /dev/null +++ b/app/components/channel_item/channel_item.tsx @@ -0,0 +1,256 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useEffect, useMemo} from 'react'; +import {useIntl} from 'react-intl'; +import {StyleSheet, Text, TouchableOpacity, View} from 'react-native'; +import Animated, {Easing, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; + +import Badge from '@components/badge'; +import ChannelIcon from '@components/channel_icon'; +import {General} from '@constants'; +import {useTheme} from '@context/theme'; +import {useIsTablet} from '@hooks/device'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; +import {getUserIdFromChannelName} from '@utils/user'; + +import CustomStatus from './custom_status'; + +import type ChannelModel from '@typings/database/models/servers/channel'; +import type MyChannelModel from '@typings/database/models/servers/my_channel'; + +type Props = { + channel: ChannelModel; + collapsed: boolean; + currentUserId: string; + hasDraft: boolean; + isActive: boolean; + isInfo?: boolean; + isMuted: boolean; + isVisible: boolean; + myChannel?: MyChannelModel; + onPress: (channelId: string) => void; + teamDisplayName?: string; + testID?: string; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + container: { + flexDirection: 'row', + paddingHorizontal: 20, + minHeight: 40, + alignItems: 'center', + }, + infoItem: { + paddingHorizontal: 0, + }, + wrapper: { + flex: 1, + flexDirection: 'row', + }, + icon: { + fontSize: 24, + lineHeight: 28, + color: changeOpacity(theme.sidebarText, 0.72), + }, + text: { + marginTop: -1, + color: changeOpacity(theme.sidebarText, 0.72), + paddingLeft: 12, + }, + highlight: { + color: theme.sidebarText, + }, + textInfo: { + color: theme.centerChannelColor, + paddingRight: 20, + }, + muted: { + color: changeOpacity(theme.sidebarText, 0.4), + }, + badge: { + position: 'relative', + left: 0, + top: 0, + alignSelf: undefined, + }, + infoBadge: { + color: theme.buttonColor, + backgroundColor: theme.buttonBg, + borderColor: theme.centerChannelBg, + }, + mutedBadge: { + opacity: 0.4, + }, + activeItem: { + backgroundColor: changeOpacity(theme.sidebarTextActiveColor, 0.1), + borderLeftColor: theme.sidebarTextActiveBorder, + borderLeftWidth: 5, + marginLeft: 0, + paddingLeft: 14, + }, + textActive: { + color: theme.sidebarText, + }, + teamName: { + color: changeOpacity(theme.centerChannelColor, 0.64), + paddingLeft: 12, + marginTop: 4, + ...typography('Body', 75), + }, + teamNameTablet: { + marginLeft: -12, + paddingLeft: 0, + marginTop: 0, + paddingBottom: 0, + top: 5, + }, +})); + +const textStyle = StyleSheet.create({ + bright: typography('Body', 200, 'SemiBold'), + regular: typography('Body', 200, 'Regular'), +}); + +const ChannelListItem = ({ + channel, collapsed, currentUserId, hasDraft, + isActive, isInfo, isMuted, isVisible, + myChannel, onPress, teamDisplayName, testID}: Props) => { + const {formatMessage} = useIntl(); + const theme = useTheme(); + const isTablet = useIsTablet(); + const styles = getStyleSheet(theme); + + // Make it brighter if it's not muted, and highlighted or has unreads + const isBright = !isMuted && (myChannel && (myChannel.isUnread || myChannel.mentionsCount > 0)); + + const shouldCollapse = (collapsed && !isBright) && !isActive; + const sharedValue = useSharedValue(shouldCollapse); + const height = useMemo(() => { + let h = 40; + if (isInfo) { + h = (teamDisplayName && !isTablet) ? 58 : 44; + } + return h; + }, [teamDisplayName, isInfo, isTablet]); + + useEffect(() => { + sharedValue.value = shouldCollapse; + }, [shouldCollapse]); + + const animatedStyle = useAnimatedStyle(() => { + return { + marginVertical: withTiming(sharedValue.value ? 0 : 2, {duration: 500}), + height: withTiming(sharedValue.value ? 0 : height, {duration: 500}), + opacity: withTiming(sharedValue.value ? 0 : 1, {duration: 500, easing: Easing.inOut(Easing.exp)}), + }; + }, [height]); + + const handleOnPress = useCallback(() => { + onPress(myChannel?.id || channel.id); + }, [channel.id, myChannel?.id]); + + const membersCount = useMemo(() => { + if (channel.type === General.GM_CHANNEL) { + return channel.name.split(',').length; + } + return 0; + }, [channel.type, channel.name]); + + const textStyles = useMemo(() => [ + isBright ? textStyle.bright : textStyle.regular, + styles.text, + isBright && styles.highlight, + isMuted && styles.muted, + isActive && !isInfo ? styles.textActive : null, + isInfo ? styles.textInfo : null, + ], [isBright, styles, isMuted, isActive, isInfo]); + + const containerStyle = useMemo(() => [ + styles.container, + isActive && !isInfo && styles.activeItem, + isInfo && styles.infoItem, + {minHeight: height}, + ], + [height, isActive, isInfo, styles]); + + if ((channel.deleteAt > 0 && !isActive) || !myChannel || !isVisible) { + return null; + } + + const teammateId = (channel.type === General.DM_CHANNEL) ? getUserIdFromChannelName(currentUserId, channel.name) : undefined; + const isOwnDirectMessage = (channel.type === General.DM_CHANNEL) && currentUserId === teammateId; + + let displayName = channel.displayName; + if (isOwnDirectMessage) { + displayName = formatMessage({id: 'channel_header.directchannel.you', defaultMessage: '{displayName} (you)'}, {displayName}); + } + + return ( + + + <> + + + 0} + membersCount={membersCount} + name={channel.name} + shared={channel.shared} + size={24} + type={channel.type} + isMuted={isMuted} + /> + + + {displayName} + + {isInfo && Boolean(teamDisplayName) && !isTablet && + + {teamDisplayName} + + } + + {Boolean(teammateId) && } + {isInfo && Boolean(teamDisplayName) && isTablet && + + {teamDisplayName} + + } + + 0} + value={myChannel.mentionsCount} + style={[styles.badge, isMuted && styles.mutedBadge, isInfo && styles.infoBadge]} + /> + + + + + ); +}; + +export default ChannelListItem; diff --git a/app/components/channel_item/custom_status/custom_status.tsx b/app/components/channel_item/custom_status/custom_status.tsx new file mode 100644 index 000000000..04ad605c2 --- /dev/null +++ b/app/components/channel_item/custom_status/custom_status.tsx @@ -0,0 +1,39 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {StyleSheet} from 'react-native'; + +import CustomStatusEmoji from '@components/custom_status/custom_status_emoji'; + +type Props = { + customStatus?: UserCustomStatus; + customStatusExpired: boolean; + isCustomStatusEnabled: boolean; +} + +const style = StyleSheet.create({ + customStatusEmoji: { + color: '#000', + marginHorizontal: 5, + marginTop: 1, + }, +}); + +const CustomStatus = ({customStatus, customStatusExpired, isCustomStatusEnabled}: Props) => { + const showCustomStatusEmoji = Boolean(isCustomStatusEnabled && customStatus && !customStatusExpired); + + if (!showCustomStatusEmoji) { + return null; + } + + return ( + + ); +}; + +export default CustomStatus; diff --git a/app/components/channel_item/custom_status/index.ts b/app/components/channel_item/custom_status/index.ts new file mode 100644 index 000000000..797d8def5 --- /dev/null +++ b/app/components/channel_item/custom_status/index.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; +import withObservables from '@nozbe/with-observables'; +import {of as of$} from 'rxjs'; +import {map, switchMap} from 'rxjs/operators'; + +import {observeConfig} from '@queries/servers/system'; +import {observeUser} from '@queries/servers/user'; +import {getUserCustomStatus, isCustomStatusExpired} from '@utils/user'; + +import CustomStatus from './custom_status'; + +import type {WithDatabaseArgs} from '@typings/database/database'; + +type HeaderInputProps = { + userId: string; +}; + +const enhanced = withObservables( + ['userId'], + ({userId, database}: WithDatabaseArgs & HeaderInputProps) => { + const config = observeConfig(database); + const user = observeUser(database, userId); + const isCustomStatusEnabled = config.pipe(map((cfg) => cfg?.EnableCustomUserStatuses === 'true')); + const customStatus = user.pipe(switchMap((u) => (u?.isBot ? of$(undefined) : of$(getUserCustomStatus(u))))); + const customStatusExpired = user.pipe(switchMap((u) => (u?.isBot ? of$(false) : of$(isCustomStatusExpired(u))))); + + return { + customStatus, + customStatusExpired, + isCustomStatusEnabled, + }; + }); + +export default withDatabase(enhanced(CustomStatus)); diff --git a/app/screens/home/channel_list/categories_list/categories/body/channel_item/index.ts b/app/components/channel_item/index.ts similarity index 75% rename from app/screens/home/channel_list/categories_list/categories/body/channel_item/index.ts rename to app/components/channel_item/index.ts index f72e09222..e6063e330 100644 --- a/app/screens/home/channel_list/categories_list/categories/body/channel_item/index.ts +++ b/app/components/channel_item/index.ts @@ -10,6 +10,7 @@ import {switchMap, distinctUntilChanged} from 'rxjs/operators'; import {Preferences} from '@constants'; import {getPreferenceAsBool} from '@helpers/api/preference'; import {observeMyChannel} from '@queries/servers/channel'; +import {queryDraft} from '@queries/servers/drafts'; import {queryPreferencesByCategoryAndName} from '@queries/servers/preference'; import {observeCurrentChannelId, observeCurrentUserId} from '@queries/servers/system'; import ChannelModel from '@typings/database/models/servers/channel'; @@ -20,12 +21,23 @@ import ChannelItem from './channel_item'; import type {WithDatabaseArgs} from '@typings/database/database'; import type PreferenceModel from '@typings/database/models/servers/preference'; +type EnhanceProps = WithDatabaseArgs & { + channel: ChannelModel; + isInfo?: boolean; + isUnreads?: boolean; + showTeamName?: boolean; +} + const observeIsMutedSetting = (mc: MyChannelModel) => mc.settings.observe().pipe(switchMap((s) => of$(s?.notifyProps?.mark_unread === 'mention'))); -const enhance = withObservables(['channel', 'isUnreads'], ({channel, isUnreads, database}: {channel: ChannelModel; isUnreads?: boolean} & WithDatabaseArgs) => { +const enhance = withObservables(['channel', 'isUnreads', 'showTeamName'], ({channel, database, isInfo, isUnreads, showTeamName}: EnhanceProps) => { const currentUserId = observeCurrentUserId(database); const myChannel = observeMyChannel(database, channel.id); + const hasDraft = queryDraft(database, channel.id).observeWithColumns(['message', 'files']).pipe( + switchMap((draft) => of$(draft.length > 0)), + ); + const isActive = observeCurrentChannelId(database).pipe(switchMap((id) => of$(id ? id === channel.id : false)), distinctUntilChanged()); const unreadsOnTop = queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_SIDEBAR_SETTINGS, Preferences.CHANNEL_SIDEBAR_GROUP_UNREADS). observeWithColumns(['value']). @@ -38,6 +50,11 @@ const enhance = withObservables(['channel', 'isUnreads'], ({channel, isUnreads, if (!mc) { return of$(false); } + + if (isInfo) { + return of$(true); + } + if (isUnreads) { return of$(u); } @@ -55,13 +72,22 @@ const enhance = withObservables(['channel', 'isUnreads'], ({channel, isUnreads, }), ); + let teamDisplayName = of$(''); + if (channel.teamId && showTeamName) { + teamDisplayName = channel.team.observe().pipe( + switchMap((team) => of$(team?.displayName || '')), + ); + } + return { + channel: channel.observe(), currentUserId, - isMuted, + hasDraft, isActive, + isMuted, isVisible, myChannel, - channel: channel.observe(), + teamDisplayName, }; }); diff --git a/app/components/option_box/index.tsx b/app/components/option_box/index.tsx new file mode 100644 index 000000000..3401c4ccf --- /dev/null +++ b/app/components/option_box/index.tsx @@ -0,0 +1,66 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Text, TextStyle, TouchableHighlight, ViewStyle} from 'react-native'; + +import CompassIcon from '@components/compass_icon'; +import {useTheme} from '@context/theme'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +type OptionBoxProps = { + iconColor?: string; + iconName: string; + onPress: () => void; + text: string; + textStyle?: TextStyle; + style?: ViewStyle; + underlayColor?: string; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + container: { + alignItems: 'center', + backgroundColor: changeOpacity(theme.centerChannelColor, 0.04), + borderRadius: 4, + flex: 1, + maxHeight: 60, + justifyContent: 'center', + minWidth: 115, + }, + text: { + color: changeOpacity(theme.centerChannelColor, 0.56), + paddingHorizontal: 5, + ...typography('Body', 50, 'SemiBold'), + }, +})); + +const OptionBox = ({iconColor, iconName, onPress, style, text, textStyle, underlayColor}: OptionBoxProps) => { + const theme = useTheme(); + const styles = getStyleSheet(theme); + + return ( + + <> + + + {text} + + + + ); +}; + +export default OptionBox; diff --git a/app/components/search/index.tsx b/app/components/search/index.tsx index cdb3cf339..57343823f 100644 --- a/app/components/search/index.tsx +++ b/app/components/search/index.tsx @@ -6,7 +6,7 @@ import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; import {useIntl} from 'react-intl'; -import {ActivityIndicatorProps, Platform, StyleProp, TextInput, TextInputProps, TextStyle, TouchableOpacityProps, ViewStyle} from 'react-native'; +import {ActivityIndicatorProps, Keyboard, Platform, StyleProp, TextInput, TextInputProps, TextStyle, TouchableOpacityProps, ViewStyle} from 'react-native'; import {SearchBar} from 'react-native-elements'; import CompassIcon from '@components/compass_icon'; @@ -82,9 +82,10 @@ const Search = forwardRef((props: SearchProps, ref) => { const searchInputTestID = `${props.testID}.search.input`; const onCancel = useCallback(() => { + Keyboard.dismiss(); setValue(''); props.onCancel?.(); - }, []); + }, [props.onCancel]); const onClear = useCallback(() => { setValue(''); @@ -94,7 +95,7 @@ const Search = forwardRef((props: SearchProps, ref) => { const onChangeText = useCallback((text: string) => { setValue(text); props.onChangeText?.(text); - }, []); + }, [props.onChangeText]); const cancelButtonProps = useMemo(() => ({ buttonTextStyle: { @@ -107,38 +108,34 @@ const Search = forwardRef((props: SearchProps, ref) => { setValue(props.defaultValue || value || ''); }, [props.defaultValue]); - const clearIcon = useMemo(() => { - return ( - - ); - }, [searchRef.current, theme]); + const clearIcon = ( + + ); - const searchIcon = useMemo(() => ( + const searchIcon = ( - ), [searchRef.current, theme]); + ); - const cancelIcon = useMemo(() => ( + const cancelIcon = ( - ), [searchRef.current, theme]); + ); useImperativeHandle(ref, () => ({ blur: () => { diff --git a/app/components/user_item/user_item.tsx b/app/components/user_item/user_item.tsx index a6e91217f..a69c6d368 100644 --- a/app/components/user_item/user_item.tsx +++ b/app/components/user_item/user_item.tsx @@ -160,7 +160,7 @@ const UserItem = ({ isActive={false} isArchived={false} isInfo={true} - isUnread={true} + isUnread={false} size={18} shared={true} type={General.DM_CHANNEL} diff --git a/app/constants/screens.ts b/app/constants/screens.ts index 196448a86..bb9517269 100644 --- a/app/constants/screens.ts +++ b/app/constants/screens.ts @@ -19,6 +19,7 @@ export const CUSTOM_STATUS_CLEAR_AFTER = 'CustomStatusClearAfter'; export const EDIT_POST = 'EditPost'; export const EDIT_PROFILE = 'EditProfile'; export const EDIT_SERVER = 'EditServer'; +export const FIND_CHANNELS = 'FindChannels'; export const FORGOT_PASSWORD = 'ForgotPassword'; export const GALLERY = 'Gallery'; export const HOME = 'Home'; @@ -59,6 +60,7 @@ export default { EDIT_POST, EDIT_PROFILE, EDIT_SERVER, + FIND_CHANNELS, FORGOT_PASSWORD, GALLERY, HOME, @@ -85,5 +87,6 @@ export const MODAL_SCREENS_WITHOUT_BACK = [ CREATE_DIRECT_MESSAGE, EMOJI_PICKER, EDIT_POST, + FIND_CHANNELS, PERMALINK, ]; diff --git a/app/queries/servers/channel.ts b/app/queries/servers/channel.ts index cc75653f5..155700b6a 100644 --- a/app/queries/servers/channel.ts +++ b/app/queries/servers/channel.ts @@ -416,3 +416,18 @@ export function observeMyChannelMentionCount(database: Database, teamId?: string distinctUntilChanged(), ); } + +export function queryMyChannelsByUnread(database: Database, isUnread: boolean, sortBy: 'last_viewed_at' | 'last_post_at', take: number, excludeIds?: string[]) { + const clause: Q.Clause[] = [Q.where('is_unread', Q.eq(isUnread))]; + + if (excludeIds?.length) { + clause.push(Q.where('id', Q.notIn(excludeIds))); + } + + return queryAllMyChannel(database).extend( + Q.on(CHANNEL, Q.where('delete_at', Q.eq(0))), + ...clause, + Q.sortBy(sortBy, Q.desc), + Q.take(take), + ); +} diff --git a/app/screens/custom_status/index.tsx b/app/screens/custom_status/index.tsx index 93a8e6f91..a7d78bc98 100644 --- a/app/screens/custom_status/index.tsx +++ b/app/screens/custom_status/index.tsx @@ -196,7 +196,7 @@ class CustomStatusModal extends NavigationComponent { status.duration = duration; status.expires_at = expiresAt; } - const {error} = await updateCustomStatus(serverUrl, currentUser, status); + const {error} = await updateCustomStatus(serverUrl, status); if (error) { DeviceEventEmitter.emit(SET_CUSTOM_STATUS_FAILURE); return; diff --git a/app/screens/find_channels/filtered_list/filtered_list.tsx b/app/screens/find_channels/filtered_list/filtered_list.tsx new file mode 100644 index 000000000..ff049c16c --- /dev/null +++ b/app/screens/find_channels/filtered_list/filtered_list.tsx @@ -0,0 +1,84 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useMemo, useState} from 'react'; +import {FlatList, ListRenderItemInfo, StyleSheet, View} from 'react-native'; +import Animated, {FadeInDown, FadeOutUp} from 'react-native-reanimated'; + +import {switchToChannelById} from '@actions/remote/channel'; +import ChannelItem from '@components/channel_item'; +import NoResultsWithTerm from '@components/no_results_with_term'; +import {useServerUrl} from '@context/server'; + +import type ChannelModel from '@typings/database/models/servers/channel'; + +type Props = { + close: () => Promise; + keyboardHeight: number; + showTeamName: boolean; + term: string; +} + +const style = StyleSheet.create({ + flex: {flex: 1}, + noResultContainer: { + flexGrow: 1, + alignItems: 'center', + justifyContent: 'center', + }, +}); + +const UnfilteredList = ({close, keyboardHeight, showTeamName, term}: Props) => { + const serverUrl = useServerUrl(); + const [data] = useState([]); + const flatListStyle = useMemo(() => ({flexGrow: 1, paddingBottom: keyboardHeight}), [keyboardHeight]); + + const onPress = useCallback(async (channelId: string) => { + await close(); + switchToChannelById(serverUrl, channelId); + }, [serverUrl, close]); + + const renderNoResults = useCallback(() => { + if (term) { + return ( + + + + ); + } + + return null; + }, [term]); + + const renderItem = useCallback(({item}: ListRenderItemInfo) => { + return ( + + ); + }, [onPress, showTeamName]); + + return ( + + + + ); +}; + +export default UnfilteredList; diff --git a/app/screens/find_channels/filtered_list/index.ts b/app/screens/find_channels/filtered_list/index.ts new file mode 100644 index 000000000..4084d2f00 --- /dev/null +++ b/app/screens/find_channels/filtered_list/index.ts @@ -0,0 +1,27 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; +import withObservables from '@nozbe/with-observables'; +import {of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import {queryJoinedTeams} from '@queries/servers/team'; + +import FilteredList from './filtered_list'; + +import type {WithDatabaseArgs} from '@typings/database/database'; + +type EnhanceProps = WithDatabaseArgs & { + term: string; +} + +const enhanced = withObservables([], ({database}: EnhanceProps) => { + const teamsCount = queryJoinedTeams(database).observeCount(); + + return { + showTeamName: teamsCount.pipe(switchMap((count) => of$(count > 1))), + }; +}); + +export default withDatabase(enhanced(FilteredList)); diff --git a/app/screens/find_channels/index.tsx b/app/screens/find_channels/index.tsx new file mode 100644 index 000000000..b21dca031 --- /dev/null +++ b/app/screens/find_channels/index.tsx @@ -0,0 +1,118 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import {DeviceEventEmitter, Keyboard, View} from 'react-native'; +import {Navigation} from 'react-native-navigation'; + +import SearchBar from '@components/search'; +import {Events} from '@constants'; +import {useTheme} from '@context/theme'; +import {useKeyboardHeight} from '@hooks/device'; +import {dismissModal} from '@screens/navigation'; +import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +import FilteredList from './filtered_list'; +import QuickOptions from './quick_options'; +import UnfilteredList from './unfiltered_list'; + +type Props = { + closeButtonId: string; + componentId: string; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + container: { + flex: 1, + marginHorizontal: 20, + marginTop: 20, + }, + inputContainerStyle: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.12), + }, + inputStyle: { + color: theme.centerChannelColor, + }, + listContainer: { + flex: 1, + marginTop: 8, + }, +})); + +const FindChannels = ({closeButtonId, componentId}: Props) => { + const theme = useTheme(); + const [term, setTerm] = useState(''); + const styles = getStyleSheet(theme); + const color = useMemo(() => changeOpacity(theme.centerChannelColor, 0.72), [theme]); + const keyboardHeight = useKeyboardHeight(); + + const cancelButtonProps = useMemo(() => ({ + color, + buttonTextStyle: { + ...typography('Body', 100), + }, + }), [color]); + + const close = useCallback(() => { + Keyboard.dismiss(); + DeviceEventEmitter.emit(Events.PAUSE_KEYBOARD_TRACKING_VIEW, false); + return dismissModal({componentId}); + }, []); + + const onCancel = useCallback(() => { + dismissModal({componentId}); + }, []); + + const onChangeText = useCallback((text) => { + setTerm(text); + }, []); + + useEffect(() => { + const navigationEvents = Navigation.events().registerNavigationButtonPressedListener(({buttonId}) => { + if (closeButtonId && buttonId === closeButtonId) { + close(); + } + }); + + return () => navigationEvents.remove(); + }, []); + + return ( + + + {term === '' && } + + {term === '' && + + } + {Boolean(term) && + + } + + + ); +}; + +export default FindChannels; diff --git a/app/screens/find_channels/quick_options/index.ts b/app/screens/find_channels/quick_options/index.ts new file mode 100644 index 000000000..08ef67ae5 --- /dev/null +++ b/app/screens/find_channels/quick_options/index.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; +import withObservables from '@nozbe/with-observables'; +import {combineLatest, of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import {Permissions} from '@constants'; +import {observePermissionForTeam} from '@queries/servers/role'; +import {observeCurrentTeam} from '@queries/servers/team'; +import {observeCurrentUser} from '@queries/servers/user'; + +import QuickOptions from './quick_options'; + +import type {WithDatabaseArgs} from '@typings/database/database'; + +const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { + const team = observeCurrentTeam(database); + + const currentUser = observeCurrentUser(database); + + const canJoinChannels = combineLatest([currentUser, team]).pipe( + switchMap(([u, t]) => (t && u ? observePermissionForTeam(t, u, Permissions.JOIN_PUBLIC_CHANNELS, true) : of$(false))), + ); + + const canCreatePublicChannels = combineLatest([currentUser, team]).pipe( + switchMap(([u, t]) => (t && u ? observePermissionForTeam(t, u, Permissions.CREATE_PUBLIC_CHANNEL, true) : of$(false))), + ); + + const canCreatePrivateChannels = combineLatest([currentUser, team]).pipe( + switchMap(([u, t]) => (t && u ? observePermissionForTeam(t, u, Permissions.CREATE_PRIVATE_CHANNEL, false) : of$(false))), + ); + + const canCreateChannels = combineLatest([canCreatePublicChannels, canCreatePrivateChannels]).pipe( + switchMap(([open, priv]) => of$(open || priv)), + ); + + return { + canCreateChannels, + canJoinChannels, + }; +}); + +export default withDatabase(enhanced(QuickOptions)); diff --git a/app/screens/find_channels/quick_options/quick_options.tsx b/app/screens/find_channels/quick_options/quick_options.tsx new file mode 100644 index 000000000..f233d8704 --- /dev/null +++ b/app/screens/find_channels/quick_options/quick_options.tsx @@ -0,0 +1,105 @@ +// 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 {StyleSheet, View} from 'react-native'; +import Animated, {FadeInDown, FadeOutUp} from 'react-native-reanimated'; + +import CompassIcon from '@components/compass_icon'; +import OptionBox from '@components/option_box'; +import {Screens} from '@constants'; +import {useTheme} from '@context/theme'; +import {showModal} from '@screens/navigation'; + +type Props = { + canCreateChannels: boolean; + canJoinChannels: boolean; + close: () => Promise; +} + +const OPTIONS_HEIGHT = 60; + +const styles = StyleSheet.create({ + container: { + marginTop: 20, + alignItems: 'center', + }, + wrapper: { + flexDirection: 'row', + height: OPTIONS_HEIGHT, + }, + separator: { + width: 8, + }, +}); + +const QuickOptions = ({canCreateChannels, canJoinChannels, close}: Props) => { + const theme = useTheme(); + const intl = useIntl(); + + const browseChannels = useCallback(async () => { + const title = intl.formatMessage({id: 'browse_channels.title', defaultMessage: 'Browse channels'}); + const closeButton = await CompassIcon.getImageSource('close', 24, theme.sidebarHeaderTextColor); + + await close(); + showModal(Screens.BROWSE_CHANNELS, title, { + closeButton, + }, {modal: {swipeToDismiss: false}}); + }, [intl, theme]); + + const createNewChannel = useCallback(async () => { + const title = intl.formatMessage({id: 'mobile.create_channel.title', defaultMessage: 'New channel'}); + + await close(); + showModal(Screens.CREATE_OR_EDIT_CHANNEL, title, undefined, {modal: {swipeToDismiss: false}}); + }, [intl]); + + const openDirectMessage = useCallback(async () => { + const title = intl.formatMessage({id: 'create_direct_message.title', defaultMessage: 'Create Direct Message'}); + const closeButton = await CompassIcon.getImageSource('close', 24, theme.sidebarHeaderTextColor); + + await close(); + showModal(Screens.CREATE_DIRECT_MESSAGE, title, { + closeButton, + }, {modal: {swipeToDismiss: false}}); + }, [intl, theme]); + + return ( + + + {canJoinChannels && + <> + + + + } + + {canCreateChannels && + <> + + + + } + + + ); +}; + +export default QuickOptions; diff --git a/app/screens/find_channels/unfiltered_list/header.tsx b/app/screens/find_channels/unfiltered_list/header.tsx new file mode 100644 index 000000000..c3ea16abe --- /dev/null +++ b/app/screens/find_channels/unfiltered_list/header.tsx @@ -0,0 +1,46 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Text, View} from 'react-native'; + +import {useTheme} from '@context/theme'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + container: { + paddingVertical: 8, + paddingTop: 12, + paddingLeft: 2, + flexDirection: 'row', + alignItems: 'flex-start', + backgroundColor: theme.centerChannelBg, + }, + heading: { + color: changeOpacity(theme.centerChannelColor, 0.64), + ...typography('Heading', 75, 'SemiBold'), + }, +})); + +type Props = { + sectionName: string; +} + +const FindChannelsHeader = ({sectionName}: Props) => { + const theme = useTheme(); + const styles = getStyleSheet(theme); + + return ( + + + {sectionName.toUpperCase()} + + + ); +}; + +export default FindChannelsHeader; diff --git a/app/screens/find_channels/unfiltered_list/index.ts b/app/screens/find_channels/unfiltered_list/index.ts new file mode 100644 index 000000000..382e61388 --- /dev/null +++ b/app/screens/find_channels/unfiltered_list/index.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Database} 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 {queryMyChannelsByUnread} from '@queries/servers/channel'; +import {queryJoinedTeams} from '@queries/servers/team'; + +import UnfilteredList from './unfiltered_list'; + +import type {WithDatabaseArgs} from '@typings/database/database'; +import type ChannelModel from '@typings/database/models/servers/channel'; +import type MyChannelModel from '@typings/database/models/servers/my_channel'; + +const MAX_UNREAD_CHANNELS = 10; +const MAX_CHANNELS = 20; + +const observeChannels = async (myChannels: MyChannelModel[]) => { + const channels = await Promise.all(myChannels.map((m) => m.channel.fetch())); + return channels.filter((c): c is ChannelModel => c !== null); +}; + +const observeRecentChannels = (database: Database, unreads: ChannelModel[]) => { + const count = MAX_CHANNELS - unreads.length; + const unreadIds = unreads.map((u) => u.id); + return queryMyChannelsByUnread(database, false, 'last_viewed_at', count, unreadIds).observe().pipe( + switchMap((myChannels) => observeChannels(myChannels)), + ); +}; + +const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { + const teamsCount = queryJoinedTeams(database).observeCount(); + + const unreadChannels = queryMyChannelsByUnread(database, true, 'last_post_at', MAX_UNREAD_CHANNELS). + observeWithColumns(['last_post_at']).pipe( + switchMap((myChannels) => observeChannels(myChannels)), + ); + + const recentChannels = unreadChannels.pipe( + switchMap((unreads) => observeRecentChannels(database, unreads)), + ); + + return { + recentChannels, + showTeamName: teamsCount.pipe(switchMap((count) => of$(count > 1))), + unreadChannels, + }; +}); + +export default withDatabase(enhanced(UnfilteredList)); diff --git a/app/screens/find_channels/unfiltered_list/unfiltered_list.tsx b/app/screens/find_channels/unfiltered_list/unfiltered_list.tsx new file mode 100644 index 000000000..8d7116fb1 --- /dev/null +++ b/app/screens/find_channels/unfiltered_list/unfiltered_list.tsx @@ -0,0 +1,111 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import {useIntl} from 'react-intl'; +import {SectionList, SectionListRenderItemInfo, StyleSheet} from 'react-native'; +import Animated, {FadeInDown, FadeOutUp} from 'react-native-reanimated'; + +import {switchToChannelById} from '@actions/remote/channel'; +import ChannelItem from '@components/channel_item'; +import {useServerUrl} from '@context/server'; +import {t} from '@i18n'; + +import FindChannelsHeader from './header'; + +import type ChannelModel from '@typings/database/models/servers/channel'; + +type Props = { + close: () => Promise; + keyboardHeight: number; + recentChannels: ChannelModel[]; + showTeamName: boolean; + unreadChannels: ChannelModel[]; +} + +const sectionNames = { + unreads: { + id: t('mobile.channel_list.unreads'), + defaultMessage: 'UNREADS', + }, + recent: { + id: t('mobile.channel_list.recent'), + defaultMessage: 'RECENT', + }, +}; + +const style = StyleSheet.create({ + flex: {flex: 1}, +}); + +const buildSections = (unreadChannels: ChannelModel[], recentChannels: ChannelModel[]) => { + const sections = []; + if (unreadChannels.length) { + sections.push({ + ...sectionNames.unreads, + data: unreadChannels, + }); + } + + if (recentChannels.length) { + sections.push({ + ...sectionNames.recent, + data: recentChannels, + }); + } + + return sections; +}; + +const UnfilteredList = ({close, keyboardHeight, recentChannels, showTeamName, unreadChannels}: Props) => { + const intl = useIntl(); + const serverUrl = useServerUrl(); + const [sections, setSections] = useState(buildSections(unreadChannels, recentChannels)); + const sectionListStyle = useMemo(() => ({paddingBottom: keyboardHeight}), [keyboardHeight]); + + const onPress = useCallback(async (channelId: string) => { + await close(); + switchToChannelById(serverUrl, channelId); + }, [serverUrl, close]); + + const renderSectionHeader = useCallback(({section}) => ( + + ), [intl.locale]); + + const renderSectionItem = useCallback(({item}: SectionListRenderItemInfo) => { + return ( + + ); + }, [onPress, showTeamName]); + + useEffect(() => { + setSections(buildSections(unreadChannels, recentChannels)); + }, [unreadChannels, recentChannels]); + + return ( + + + + ); +}; + +export default UnfilteredList; diff --git a/app/screens/home/channel_list/categories_list/__snapshots__/index.test.tsx.snap b/app/screens/home/channel_list/categories_list/__snapshots__/index.test.tsx.snap index e16d81c76..4082f6b81 100644 --- a/app/screens/home/channel_list/categories_list/__snapshots__/index.test.tsx.snap +++ b/app/screens/home/channel_list/categories_list/__snapshots__/index.test.tsx.snap @@ -356,17 +356,23 @@ exports[`components/categories_list should render team error 1`] = ` - + > + Find channels... + - + > + + + + + Channel + + - - Channel - diff --git a/app/screens/home/channel_list/categories_list/categories/body/category_body.test.tsx b/app/screens/home/channel_list/categories_list/categories/body/category_body.test.tsx index 7383e3263..2e7b3d3e6 100644 --- a/app/screens/home/channel_list/categories_list/categories/body/category_body.test.tsx +++ b/app/screens/home/channel_list/categories_list/categories/body/category_body.test.tsx @@ -35,6 +35,7 @@ describe('components/channel_list/categories/body', () => { undefined} />, {database}, ); diff --git a/app/screens/home/channel_list/categories_list/categories/body/category_body.tsx b/app/screens/home/channel_list/categories_list/categories/body/category_body.tsx index 72ae000f8..bcf68333f 100644 --- a/app/screens/home/channel_list/categories_list/categories/body/category_body.tsx +++ b/app/screens/home/channel_list/categories_list/categories/body/category_body.tsx @@ -4,11 +4,10 @@ import React, {useCallback, useMemo} from 'react'; import {FlatList} from 'react-native'; +import ChannelItem from '@components/channel_item'; import {DMS_CATEGORY} from '@constants/categories'; import ChannelModel from '@typings/database/models/servers/channel'; -import ChannelItem from './channel_item'; - import type CategoryModel from '@typings/database/models/servers/category'; type Props = { @@ -16,11 +15,12 @@ type Props = { hiddenChannelIds: Set; category: CategoryModel; limit: number; + onChannelSwitch: (channelId: string) => void; }; const extractKey = (item: ChannelModel) => item.id; -const CategoryBody = ({sortedChannels, category, hiddenChannelIds, limit}: Props) => { +const CategoryBody = ({sortedChannels, category, hiddenChannelIds, limit, onChannelSwitch}: Props) => { const ids = useMemo(() => { let filteredChannels = sortedChannels; @@ -41,9 +41,10 @@ const CategoryBody = ({sortedChannels, category, hiddenChannelIds, limit}: Props channel={item} collapsed={category.collapsed} testID={`category.${category.displayName.replace(/ /g, '_').toLocaleLowerCase()}.channel_list_item`} + onPress={onChannelSwitch} /> ); - }, [category.collapsed]); + }, [category.collapsed, onChannelSwitch]); return ( - - - - - - 1 - - - - - Hello! - - - - -`; diff --git a/app/screens/home/channel_list/categories_list/categories/body/channel_item/channel_item.tsx b/app/screens/home/channel_list/categories_list/categories/body/channel_item/channel_item.tsx deleted file mode 100644 index 153b6430b..000000000 --- a/app/screens/home/channel_list/categories_list/categories/body/channel_item/channel_item.tsx +++ /dev/null @@ -1,176 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React, {useCallback, useEffect, useMemo} from 'react'; -import {useIntl} from 'react-intl'; -import {StyleSheet, Text, TouchableOpacity, View} from 'react-native'; -import Animated, {Easing, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; - -import {switchToChannelById} from '@actions/remote/channel'; -import Badge from '@components/badge'; -import ChannelIcon from '@components/channel_icon'; -import {General} from '@constants'; -import {useServerUrl} from '@context/server'; -import {useTheme} from '@context/theme'; -import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; -import {typography} from '@utils/typography'; -import {getUserIdFromChannelName} from '@utils/user'; - -import type ChannelModel from '@typings/database/models/servers/channel'; -import type MyChannelModel from '@typings/database/models/servers/my_channel'; - -const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ - container: { - flexDirection: 'row', - paddingHorizontal: 20, - height: 40, - alignItems: 'center', - }, - icon: { - fontSize: 24, - lineHeight: 28, - color: changeOpacity(theme.sidebarText, 0.72), - }, - text: { - marginTop: -1, - color: changeOpacity(theme.sidebarText, 0.72), - paddingLeft: 12, - flex: 1, - }, - highlight: { - color: theme.sidebarText, - }, - muted: { - color: changeOpacity(theme.sidebarText, 0.4), - }, - badge: { - position: 'relative', - left: 0, - top: 0, - alignSelf: undefined, - }, - mutedBadge: { - opacity: 0.4, - }, - activeItem: { - backgroundColor: changeOpacity(theme.sidebarTextActiveColor, 0.1), - borderLeftColor: theme.sidebarTextActiveBorder, - borderLeftWidth: 5, - marginLeft: 0, - paddingLeft: 14, - }, - textActive: { - color: theme.sidebarText, - }, - -})); - -const textStyle = StyleSheet.create({ - bright: typography('Body', 200, 'SemiBold'), - regular: typography('Body', 200, 'Regular'), -}); - -type Props = { - channel: ChannelModel; - isActive: boolean; - isMuted: boolean; - myChannel?: MyChannelModel; - collapsed: boolean; - currentUserId: string; - isVisible: boolean; - testID?: string; -} - -const ChannelListItem = ({channel, isActive, currentUserId, isMuted, isVisible, myChannel, collapsed, testID}: Props) => { - const {formatMessage} = useIntl(); - const theme = useTheme(); - const styles = getStyleSheet(theme); - const serverUrl = useServerUrl(); - - const isOwnDirectMessage = (channel.type === General.DM_CHANNEL) && currentUserId === getUserIdFromChannelName(currentUserId, channel.name); - - // Make it brighter if it's not muted, and highlighted or has unreads - const isUnread = !isMuted && (myChannel && (myChannel.isUnread || myChannel.mentionsCount > 0)); - - const shouldCollapse = (collapsed && !isUnread) && !isActive; - const sharedValue = useSharedValue(shouldCollapse); - - useEffect(() => { - sharedValue.value = shouldCollapse; - }, [shouldCollapse]); - - const animatedStyle = useAnimatedStyle(() => { - return { - marginVertical: withTiming(sharedValue.value ? 0 : 2, {duration: 500}), - height: withTiming(sharedValue.value ? 0 : 40, {duration: 500}), - opacity: withTiming(sharedValue.value ? 0 : 1, {duration: 500, easing: Easing.inOut(Easing.exp)}), - }; - }); - - const switchChannels = useCallback(() => { - if (myChannel) { - switchToChannelById(serverUrl, myChannel.id); - } - }, [myChannel?.id, serverUrl]); - - const membersCount = useMemo(() => { - if (channel.type === General.GM_CHANNEL) { - return channel.displayName.split(',').length; - } - return 0; - }, [channel.type, channel.displayName]); - - const textStyles = useMemo(() => [ - isUnread ? textStyle.bright : textStyle.regular, - styles.text, - isUnread && styles.highlight, - isMuted && styles.muted, - isActive ? styles.textActive : null, - ], [isUnread, styles, isMuted]); - - let displayName = channel.displayName; - if (isOwnDirectMessage) { - displayName = formatMessage({id: 'channel_header.directchannel.you', defaultMessage: '{displayName} (you)'}, {displayName}); - } - - if ((channel.deleteAt > 0 && !isActive) || !myChannel || !isVisible) { - return null; - } - - return ( - - - - 0} - membersCount={membersCount} - name={channel.name} - shared={channel.shared} - size={24} - type={channel.type} - isMuted={isMuted} - /> - - {displayName} - - 0} - value={myChannel.mentionsCount} - style={[styles.badge, isMuted && styles.mutedBadge]} - /> - - - - ); -}; - -export default ChannelListItem; diff --git a/app/screens/home/channel_list/categories_list/categories/categories.tsx b/app/screens/home/channel_list/categories_list/categories/categories.tsx index 91210c373..e20d7a821 100644 --- a/app/screens/home/channel_list/categories_list/categories/categories.tsx +++ b/app/screens/home/channel_list/categories_list/categories/categories.tsx @@ -5,6 +5,9 @@ import React, {useCallback, useEffect, useMemo, useRef} from 'react'; import {useIntl} from 'react-intl'; import {FlatList, StyleSheet} from 'react-native'; +import {switchToChannelById} from '@actions/remote/channel'; +import {useServerUrl} from '@context/server'; + import CategoryBody from './body'; import LoadCategoriesError from './error'; import CategoryHeader from './header'; @@ -31,10 +34,20 @@ const extractKey = (item: CategoryModel | 'UNREADS') => (item === 'UNREADS' ? 'U const Categories = ({categories, currentTeamId, unreadsOnTop}: Props) => { const intl = useIntl(); const listRef = useRef(null); + const serverUrl = useServerUrl(); + + const onChannelSwitch = useCallback(async (channelId: string) => { + switchToChannelById(serverUrl, channelId); + }, [serverUrl]); const renderCategory = useCallback((data: {item: CategoryModel | 'UNREADS'}) => { if (data.item === 'UNREADS') { - return ; + return ( + + ); } return ( <> @@ -42,10 +55,11 @@ const Categories = ({categories, currentTeamId, unreadsOnTop}: Props) => { ); - }, [intl.locale]); + }, [currentTeamId, intl.locale, onChannelSwitch]); useEffect(() => { listRef.current?.scrollToOffset({animated: false, offset: 0}); diff --git a/app/screens/home/channel_list/categories_list/categories/unreads/unreads.test.tsx b/app/screens/home/channel_list/categories_list/categories/unreads/unreads.test.tsx index e98f08644..cc22fdb88 100644 --- a/app/screens/home/channel_list/categories_list/categories/unreads/unreads.test.tsx +++ b/app/screens/home/channel_list/categories_list/categories/unreads/unreads.test.tsx @@ -21,6 +21,7 @@ describe('components/channel_list/categories/body', () => { const wrapper = renderWithEverything( undefined} />, {database}, ); diff --git a/app/screens/home/channel_list/categories_list/categories/unreads/unreads.tsx b/app/screens/home/channel_list/categories_list/categories/unreads/unreads.tsx index c301b77c4..564f692f2 100644 --- a/app/screens/home/channel_list/categories_list/categories/unreads/unreads.tsx +++ b/app/screens/home/channel_list/categories_list/categories/unreads/unreads.tsx @@ -1,16 +1,15 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React from 'react'; +import React, {useCallback} from 'react'; import {useIntl} from 'react-intl'; import {FlatList, Text} from 'react-native'; +import ChannelItem from '@components/channel_item'; import {useTheme} from '@context/theme'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {typography} from '@utils/typography'; -import ChannelItem from '../body/channel_item'; - import type ChannelModel from '@typings/database/models/servers/channel'; const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ @@ -23,24 +22,27 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ }, })); -const renderItem = ({item}: {item: ChannelModel}) => { - return ( - - ); -}; type UnreadCategoriesProps = { unreadChannels: ChannelModel[]; + onChannelSwitch: (channelId: string) => void; } -const UnreadCategories = ({unreadChannels}: UnreadCategoriesProps) => { +const UnreadCategories = ({onChannelSwitch, unreadChannels}: UnreadCategoriesProps) => { const theme = useTheme(); const styles = getStyleSheet(theme); const intl = useIntl(); + const renderItem = useCallback(({item}: {item: ChannelModel}) => { + return ( + + ); + }, [onChannelSwitch]); + if (!unreadChannels.length) { return null; } diff --git a/app/screens/home/channel_list/categories_list/search/__snapshots__/index.test.tsx.snap b/app/screens/home/channel_list/categories_list/search/__snapshots__/index.test.tsx.snap index a1f41d51f..d26b4a3b3 100644 --- a/app/screens/home/channel_list/categories_list/search/__snapshots__/index.test.tsx.snap +++ b/app/screens/home/channel_list/categories_list/search/__snapshots__/index.test.tsx.snap @@ -2,17 +2,23 @@ exports[`Search Field should match snapshot 1`] = ` - + > + Find channels... + `; diff --git a/app/screens/home/channel_list/categories_list/search/index.tsx b/app/screens/home/channel_list/categories_list/search/index.tsx index f629782fe..ae21ec9be 100644 --- a/app/screens/home/channel_list/categories_list/search/index.tsx +++ b/app/screens/home/channel_list/categories_list/search/index.tsx @@ -1,27 +1,29 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React from 'react'; -import {TextInput, StyleSheet, View} from 'react-native'; +import React, {useCallback} from 'react'; +import {useIntl} from 'react-intl'; +import {DeviceEventEmitter, TouchableHighlight} from 'react-native'; +import {Options} from 'react-native-navigation'; import CompassIcon from '@components/compass_icon'; +import FormattedText from '@components/formatted_text'; +import {Events, Screens} from '@constants'; import {useTheme} from '@context/theme'; +import {showModal} from '@screens/navigation'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {typography} from '@utils/typography'; const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ container: { - flex: 1, flexDirection: 'row', justifyContent: 'flex-start', - alignItems: 'center', width: '100%', backgroundColor: changeOpacity(theme.sidebarText, 0.12), borderRadius: 8, padding: 8, marginVertical: 20, height: 40, - maxHeight: 40, }, icon: { width: 24, @@ -29,40 +31,57 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ color: changeOpacity(theme.sidebarText, 0.72), }, input: { - flex: 1, - height: 40, - color: theme.sidebarText, - alignItems: 'center', - alignContent: 'center', + color: changeOpacity(theme.sidebarText, 0.72), marginLeft: 5, - marginTop: -2, + marginTop: 1, + ...typography('Body', 200), }, })); -const textStyles = StyleSheet.create([ - typography('Body', 200), - {textAlignVertical: 'center'}, -]); - const SearchField = () => { const theme = useTheme(); + const intl = useIntl(); const styles = getStyleSheet(theme); + const onPress = useCallback(() => { + const options: Options = {modal: {swipeToDismiss: false}}; + const closeButtonId = 'close-find-channels'; + const closeButton = CompassIcon.getImageSourceSync('close', 24, theme.sidebarHeaderTextColor); + options.topBar = { + leftButtons: [{ + id: closeButtonId, + icon: closeButton, + testID: closeButtonId, + }], + }; + + DeviceEventEmitter.emit(Events.PAUSE_KEYBOARD_TRACKING_VIEW, true); + showModal( + Screens.FIND_CHANNELS, + intl.formatMessage({id: 'find_channels.title', defaultMessage: 'Find Channels'}), + {closeButtonId}, + options, + ); + }, [intl.locale, theme]); + return ( - - - - + <> + + + + ); }; diff --git a/app/screens/index.tsx b/app/screens/index.tsx index 367de5d6b..72a6efb16 100644 --- a/app/screens/index.tsx +++ b/app/screens/index.tsx @@ -101,9 +101,10 @@ Navigation.setLazyComponentRegistrator((screenName) => { screen = withIntl(require('@screens/edit_server').default); break; case Screens.EMOJI_PICKER: - screen = withServerDatabase( - require('@screens/emoji_picker').default, - ); + screen = withServerDatabase(require('@screens/emoji_picker').default); + break; + case Screens.FIND_CHANNELS: + screen = withServerDatabase(require('@screens/find_channels').default); break; case Screens.FORGOT_PASSWORD: screen = withIntl(require('@screens/forgot_password').default); diff --git a/app/utils/user/index.ts b/app/utils/user/index.ts index 62469c310..cb54066d0 100644 --- a/app/utils/user/index.ts +++ b/app/utils/user/index.ts @@ -135,7 +135,11 @@ export const getUserCustomStatus = (user?: UserModel | UserProfile): UserCustomS } }; -export function isCustomStatusExpired(user: UserModel) { +export function isCustomStatusExpired(user?: UserModel) { + if (!user) { + return true; + } + const customStatus = getUserCustomStatus(user); if (!customStatus) { diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 8272f4029..511ca92b4 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -69,6 +69,7 @@ "center_panel.archived.closeChannel": "Close Channel", "channel": "{count, plural, one {# member} other {# members}}", "channel_header.directchannel.you": "{displayName} (you)", + "channel_list.find_channels": "Find channels...", "channel_loader.someone": "Someone", "channel_modal.descriptionHelp": "Describe how this channel should be used.", "channel_modal.header": "Header", @@ -168,6 +169,10 @@ "emoji_skin.medium_light_skin_tone": "medium light skin tone", "emoji_skin.medium_skin_tone": "medium skin tone", "file_upload.fileAbove": "Files must be less than {max}", + "find_channels.directory": "Directory", + "find_channels.new_channel": "New Channel", + "find_channels.open_dm": "Open a DM", + "find_channels.title": "Find Channels", "gallery.copy_link.failed": "Failed to copy link to clipboard", "gallery.downloading": "Downloading...", "gallery.footer.channel_name": "Shared in {channelName}", @@ -240,6 +245,7 @@ "mobile.camera_photo_permission_denied_title": "{applicationName} would like to access your camera", "mobile.channel_info.alertNo": "No", "mobile.channel_info.alertYes": "Yes", + "mobile.channel_list.recent": "RECENT", "mobile.channel_list.unreads": "UNREADS", "mobile.commands.error_title": "Error Executing Command", "mobile.components.select_server_view.connect": "Connect", @@ -520,7 +526,7 @@ "threads.following": "Following", "threads.followMessage": "Follow Message", "threads.followThread": "Follow Thread", - "threads.newReplies": "{count} new {count, plural, one {reply} other {replies}}", + "threads.replies": "{count} {count, plural, one {reply} other {replies}}", "threads.unfollowMessage": "Unfollow Message", "threads.unfollowThread": "Unfollow Thread", "user.edit_profile.email.auth_service": "Login occurs through {service}. Email cannot be updated. Email address used for notifications is {email}.",