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}.",