[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
This commit is contained in:
Elias Nahum 2022-04-25 13:27:20 -04:00 committed by GitHub
parent bb3a3112b1
commit 1132bd0e51
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 1607 additions and 527 deletions

View file

@ -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) {

View file

@ -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 (
<ProfilePicture
author={author}
size={24}
showStatus={true}
statusSize={12}
statusStyle={style.status}
/>
<View style={style.container}>
<ProfilePicture
author={author}
size={24}
showStatus={true}
statusSize={12}
statusStyle={[style.status, isInfo && style.statusInfo]}
/>
</View>
);
};

View file

@ -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 = ({
</View>
);
} else if (type === General.DM_CHANNEL) {
icon = (<DmAvatar channelName={name}/>);
icon = (
<DmAvatar
channelName={name}
isInfo={isInfo}
/>);
}
return (

View file

@ -0,0 +1,286 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/channel_list/categories/body/channel_item should match snapshot 1`] = `
<View
animatedStyle={
Object {
"value": Object {
"height": 40,
"marginVertical": 2,
"opacity": 1,
},
}
}
collapsable={false}
style={
Object {
"height": 40,
"marginVertical": 2,
"opacity": 1,
}
}
>
<View
accessible={true}
collapsable={false}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
Object {
"opacity": 1,
}
}
>
<View
style={
Array [
Object {
"alignItems": "center",
"flexDirection": "row",
"minHeight": 40,
"paddingHorizontal": 20,
},
false,
undefined,
Object {
"minHeight": 40,
},
]
}
testID="channel_list_item.hello.collapsed.false"
>
<View
style={
Object {
"flex": 1,
"flexDirection": "row",
}
}
>
<View
style={
Array [
Object {
"alignItems": "center",
"justifyContent": "center",
},
Object {
"height": 24,
"width": 24,
},
undefined,
undefined,
]
}
>
<View
style={
Array [
Object {
"alignItems": "center",
"backgroundColor": "rgba(255,255,255,0.16)",
"borderRadius": 4,
"justifyContent": "center",
},
undefined,
undefined,
Object {
"height": 24,
"width": 24,
},
]
}
>
<Text
style={
Array [
Object {
"color": "#ffffff",
"fontFamily": "OpenSans-SemiBold",
"fontSize": 12,
"fontWeight": "600",
"lineHeight": 16,
},
undefined,
undefined,
Object {
"fontSize": 12,
},
]
}
testID="undefined.gm_member_count"
>
1
</Text>
</View>
</View>
<View>
<Text
ellipsizeMode="tail"
numberOfLines={1}
style={
Array [
Object {
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
},
Object {
"color": "rgba(255,255,255,0.72)",
"marginTop": -1,
"paddingLeft": 12,
},
false,
false,
null,
null,
]
}
testID="channel_list_item.hello.display_name"
>
Hello!
</Text>
</View>
</View>
</View>
</View>
</View>
`;
exports[`components/channel_list/categories/body/channel_item should match snapshot when it has a draft 1`] = `
<View
animatedStyle={
Object {
"value": Object {
"height": 40,
"marginVertical": 2,
"opacity": 1,
},
}
}
collapsable={false}
style={
Object {
"height": 40,
"marginVertical": 2,
"opacity": 1,
}
}
>
<View
accessible={true}
collapsable={false}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
Object {
"opacity": 1,
}
}
>
<View
style={
Array [
Object {
"alignItems": "center",
"flexDirection": "row",
"minHeight": 40,
"paddingHorizontal": 20,
},
false,
undefined,
Object {
"minHeight": 40,
},
]
}
testID="channel_list_item.hello.collapsed.false"
>
<View
style={
Object {
"flex": 1,
"flexDirection": "row",
}
}
>
<View
style={
Array [
Object {
"alignItems": "center",
"justifyContent": "center",
},
Object {
"height": 24,
"width": 24,
},
undefined,
undefined,
]
}
>
<Icon
name="pencil-outline"
style={
Array [
Object {
"color": "rgba(255,255,255,0.4)",
},
undefined,
undefined,
Object {
"fontSize": 24,
"left": 2,
},
]
}
testID="undefined.draft"
/>
</View>
<View>
<Text
ellipsizeMode="tail"
numberOfLines={1}
style={
Array [
Object {
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
},
Object {
"color": "rgba(255,255,255,0.72)",
"marginTop": -1,
"paddingLeft": 12,
},
false,
false,
null,
null,
]
}
testID="channel_list_item.hello.display_name"
>
Hello!
</Text>
</View>
</View>
</View>
</View>
</View>
`;

View file

@ -32,6 +32,7 @@ describe('components/channel_list/categories/body/channel_item', () => {
const wrapper = renderWithIntlAndTheme(
<ChannelItem
channel={{displayName: 'Hello!', type: 'G', shared: false, name: 'hello', deleteAt: 0} as ChannelModel}
hasDraft={false}
isActive={false}
myChannel={myChannel}
isMuted={false}
@ -39,6 +40,26 @@ describe('components/channel_list/categories/body/channel_item', () => {
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(
<ChannelItem
channel={{displayName: 'Hello!', type: 'G', shared: false, name: 'hello', deleteAt: 0} as ChannelModel}
hasDraft={true}
isActive={false}
myChannel={myChannel}
isMuted={false}
collapsed={false}
currentUserId={'id'}
testID='channel_list_item'
isVisible={true}
onPress={() => undefined}
/>,
);

View file

@ -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 (
<Animated.View style={animatedStyle}>
<TouchableOpacity onPress={handleOnPress}>
<>
<View
style={containerStyle}
testID={`${testID}.${channel.name}.collapsed.${collapsed && !isActive}`}
>
<View style={styles.wrapper}>
<ChannelIcon
hasDraft={hasDraft}
isActive={isInfo ? false : isActive}
isInfo={isInfo}
isUnread={isBright}
isArchived={channel.deleteAt > 0}
membersCount={membersCount}
name={channel.name}
shared={channel.shared}
size={24}
type={channel.type}
isMuted={isMuted}
/>
<View>
<Text
ellipsizeMode='tail'
numberOfLines={1}
style={textStyles}
testID={`${testID}.${channel.name}.display_name`}
>
{displayName}
</Text>
{isInfo && Boolean(teamDisplayName) && !isTablet &&
<Text
ellipsizeMode='tail'
numberOfLines={1}
testID={`${testID}.${teamDisplayName}.display_name`}
style={styles.teamName}
>
{teamDisplayName}
</Text>
}
</View>
{Boolean(teammateId) && <CustomStatus userId={teammateId!}/>}
{isInfo && Boolean(teamDisplayName) && isTablet &&
<Text
ellipsizeMode='tail'
numberOfLines={1}
testID={`${testID}.${teamDisplayName}.display_name`}
style={[styles.teamName, styles.teamNameTablet]}
>
{teamDisplayName}
</Text>
}
</View>
<Badge
visible={myChannel.mentionsCount > 0}
value={myChannel.mentionsCount}
style={[styles.badge, isMuted && styles.mutedBadge, isInfo && styles.infoBadge]}
/>
</View>
</>
</TouchableOpacity>
</Animated.View>
);
};
export default ChannelListItem;

View file

@ -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 (
<CustomStatusEmoji
customStatus={customStatus!}
style={style.customStatusEmoji}
testID={`channel_list_item.custom_status.${customStatus!.emoji}-${customStatus!.text}`}
/>
);
};
export default CustomStatus;

View file

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

View file

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

View file

@ -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 (
<TouchableHighlight
onPress={onPress}
style={[styles.container, style]}
underlayColor={changeOpacity(theme.centerChannelColor, 0.32) || underlayColor}
>
<>
<CompassIcon
color={iconColor || changeOpacity(theme.centerChannelColor, 0.56)}
name={iconName}
size={24}
/>
<Text
numberOfLines={1}
style={[styles.text, textStyle]}
>
{text}
</Text>
</>
</TouchableHighlight>
);
};
export default OptionBox;

View file

@ -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<SearchRef, SearchProps>((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<SearchRef, SearchProps>((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<SearchRef, SearchProps>((props: SearchProps, ref) => {
setValue(props.defaultValue || value || '');
}, [props.defaultValue]);
const clearIcon = useMemo(() => {
return (
<CompassIcon
color={changeOpacity(props.clearIconColor || theme.centerChannelColor, Platform.select({android: 0.56, default: 0.72}))}
name={Platform.select({android: 'close', default: 'close-circle'})}
onPress={searchRef.current?.clear}
size={Platform.select({android: 24, default: 18})}
testID={searchClearButtonTestID}
/>
);
}, [searchRef.current, theme]);
const clearIcon = (
<CompassIcon
color={changeOpacity(props.clearIconColor || theme.centerChannelColor, Platform.select({android: 0.56, default: 0.72}))}
name={Platform.select({android: 'close', default: 'close-circle'})}
onPress={searchRef.current?.clear}
size={Platform.select({android: 24, default: 18})}
testID={searchClearButtonTestID}
/>
);
const searchIcon = useMemo(() => (
const searchIcon = (
<CompassIcon
color={changeOpacity(props.searchIconColor || theme.centerChannelColor, Platform.select({android: 0.56, default: 0.72}))}
name='magnify'
onPress={searchRef.current?.focus}
size={24}
/>
), [searchRef.current, theme]);
);
const cancelIcon = useMemo(() => (
const cancelIcon = (
<CompassIcon
color={changeOpacity(props.cancelButtonProps?.color || theme.centerChannelColor, Platform.select({android: 0.56, default: 0.72}))}
name='arrow-left'
// @ts-expect-error cancel is not part of TextInput does exist in SearchBar
onPress={searchRef.current?.cancel}
onPress={onCancel}
size={24}
testID={searchCancelButtonTestID}
/>
), [searchRef.current, theme]);
);
useImperativeHandle(ref, () => ({
blur: () => {

View file

@ -160,7 +160,7 @@ const UserItem = ({
isActive={false}
isArchived={false}
isInfo={true}
isUnread={true}
isUnread={false}
size={18}
shared={true}
type={General.DM_CHANNEL}

View file

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

View file

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

View file

@ -196,7 +196,7 @@ class CustomStatusModal extends NavigationComponent<Props, State> {
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;

View file

@ -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<void>;
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 (
<View style={style.noResultContainer}>
<NoResultsWithTerm term={term}/>
</View>
);
}
return null;
}, [term]);
const renderItem = useCallback(({item}: ListRenderItemInfo<ChannelModel>) => {
return (
<ChannelItem
channel={item}
collapsed={false}
isInfo={true}
onPress={onPress}
showTeamName={showTeamName}
/>
);
}, [onPress, showTeamName]);
return (
<Animated.View
entering={FadeInDown.duration(200)}
exiting={FadeOutUp.duration(200)}
style={style.flex}
>
<FlatList
contentContainerStyle={flatListStyle}
keyboardDismissMode='interactive'
keyboardShouldPersistTaps='handled'
ListEmptyComponent={renderNoResults}
renderItem={renderItem}
data={data}
showsVerticalScrollIndicator={false}
/>
</Animated.View>
);
};
export default UnfilteredList;

View file

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

View file

@ -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 (
<View style={styles.container}>
<SearchBar
autoCapitalize='none'
autoFocus={true}
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
clearIconColor={color}
searchIconColor={color}
cancelButtonProps={cancelButtonProps}
placeholderTextColor={color}
inputStyle={styles.inputStyle}
inputContainerStyle={styles.inputContainerStyle}
selectionColor={color}
onCancel={onCancel}
onChangeText={onChangeText}
value={term}
/>
{term === '' && <QuickOptions close={close}/>}
<View style={styles.listContainer}>
{term === '' &&
<UnfilteredList
close={close}
keyboardHeight={keyboardHeight}
/>
}
{Boolean(term) &&
<FilteredList
close={close}
keyboardHeight={keyboardHeight}
term={term}
/>
}
</View>
</View>
);
};
export default FindChannels;

View file

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

View file

@ -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<void>;
}
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 (
<Animated.View
entering={FadeInDown.duration(200)}
exiting={FadeOutUp.duration(200)}
style={styles.container}
>
<Animated.View style={styles.wrapper}>
{canJoinChannels &&
<>
<OptionBox
iconName='globe'
onPress={browseChannels}
text={intl.formatMessage({id: 'find_channels.directory', defaultMessage: 'Directory'})}
/>
<View style={styles.separator}/>
</>
}
<OptionBox
iconName='account-outline'
onPress={openDirectMessage}
text={intl.formatMessage({id: 'find_channels.open_dm', defaultMessage: 'Open a DM'})}
/>
{canCreateChannels &&
<>
<View style={styles.separator}/>
<OptionBox
iconName='plus'
onPress={createNewChannel}
text={intl.formatMessage({id: 'find_channels.new_channel', defaultMessage: 'New Channel'})}
/>
</>
}
</Animated.View>
</Animated.View>
);
};
export default QuickOptions;

View file

@ -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 (
<View style={styles.container}>
<Text
style={styles.heading}
testID={`find_channels.header.${sectionName}`}
>
{sectionName.toUpperCase()}
</Text>
</View>
);
};
export default FindChannelsHeader;

View file

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

View file

@ -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<void>;
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}) => (
<FindChannelsHeader sectionName={intl.formatMessage({id: section.id, defaultMessage: section.defaultMessage})}/>
), [intl.locale]);
const renderSectionItem = useCallback(({item}: SectionListRenderItemInfo<ChannelModel>) => {
return (
<ChannelItem
channel={item}
collapsed={false}
isInfo={true}
onPress={onPress}
showTeamName={showTeamName}
/>
);
}, [onPress, showTeamName]);
useEffect(() => {
setSections(buildSections(unreadChannels, recentChannels));
}, [unreadChannels, recentChannels]);
return (
<Animated.View
entering={FadeInDown.duration(200)}
exiting={FadeOutUp.duration(200)}
style={style.flex}
>
<SectionList
contentContainerStyle={sectionListStyle}
keyboardDismissMode='interactive'
keyboardShouldPersistTaps='handled'
renderItem={renderSectionItem}
renderSectionHeader={renderSectionHeader}
sections={sections}
showsVerticalScrollIndicator={false}
stickySectionHeadersEnabled={true}
/>
</Animated.View>
);
};
export default UnfilteredList;

View file

@ -356,17 +356,23 @@ exports[`components/categories_list should render team error 1`] = `
</Text>
</View>
<View
accessible={true}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
Object {
"alignItems": "center",
"backgroundColor": "rgba(255,255,255,0.12)",
"borderRadius": 8,
"flex": 1,
"flexDirection": "row",
"height": 40,
"justifyContent": "flex-start",
"marginVertical": 20,
"maxHeight": 40,
"padding": 8,
"width": "100%",
}
@ -382,35 +388,21 @@ exports[`components/categories_list should render team error 1`] = `
}
}
/>
<TextInput
placeholder="Find Channels"
placeholderTextColor="rgba(255,255,255,0.72)"
<Text
style={
Array [
Array [
Object {
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
},
Object {
"textAlignVertical": "center",
},
],
Object {
"alignContent": "center",
"alignItems": "center",
"color": "#ffffff",
"flex": 1,
"height": 40,
"marginLeft": 5,
"marginTop": -2,
},
]
Object {
"color": "rgba(255,255,255,0.72)",
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
"marginLeft": 5,
"marginTop": 1,
}
}
testID="channel_list.search_field.find_channels.input"
/>
>
Find channels...
</Text>
</View>
<View
onMoveShouldSetResponder={[Function]}

View file

@ -50,74 +50,89 @@ Object {
Object {
"alignItems": "center",
"flexDirection": "row",
"height": 40,
"minHeight": 40,
"paddingHorizontal": 20,
},
false,
undefined,
Object {
"minHeight": 40,
},
]
}
testID="category.test_category.channel_list_item.channel.collapsed.false"
>
<View
style={
Array [
Object {
"alignItems": "center",
"justifyContent": "center",
},
Object {
"height": 24,
"width": 24,
},
undefined,
undefined,
]
Object {
"flex": 1,
"flexDirection": "row",
}
}
>
<Icon
name="globe"
<View
style={
Array [
Object {
"color": "rgba(255,255,255,0.4)",
"alignItems": "center",
"justifyContent": "center",
},
undefined,
undefined,
Object {
"fontSize": 24,
"left": 1,
"height": 24,
"width": 24,
},
undefined,
undefined,
]
}
testID="undefined.public"
/>
>
<Icon
name="globe"
style={
Array [
Object {
"color": "rgba(255,255,255,0.4)",
},
undefined,
undefined,
Object {
"fontSize": 24,
"left": 1,
},
]
}
testID="undefined.public"
/>
</View>
<View>
<Text
ellipsizeMode="tail"
numberOfLines={1}
style={
Array [
Object {
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
},
Object {
"color": "rgba(255,255,255,0.72)",
"marginTop": -1,
"paddingLeft": 12,
},
false,
false,
null,
null,
]
}
testID="category.test_category.channel_list_item.channel.display_name"
>
Channel
</Text>
</View>
</View>
<Text
ellipsizeMode="tail"
numberOfLines={1}
style={
Array [
Object {
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
},
Object {
"color": "rgba(255,255,255,0.72)",
"flex": 1,
"marginTop": -1,
"paddingLeft": 12,
},
false,
false,
null,
]
}
testID="category.test_category.channel_list_item.channel.display_name"
>
Channel
</Text>
</View>
</View>
</View>

View file

@ -35,6 +35,7 @@ describe('components/channel_list/categories/body', () => {
<CategoryBody
category={category}
locale={DEFAULT_LOCALE}
onChannelSwitch={() => undefined}
/>,
{database},
);

View file

@ -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<string>;
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 (
<FlatList

View file

@ -1,140 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/channel_list/categories/body/channel_item should match snapshot 1`] = `
<View
animatedStyle={
Object {
"value": Object {
"height": 40,
"marginVertical": 2,
"opacity": 1,
},
}
}
collapsable={false}
style={
Object {
"height": 40,
"marginVertical": 2,
"opacity": 1,
}
}
>
<View
accessible={true}
collapsable={false}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
Object {
"opacity": 1,
}
}
>
<View
style={
Array [
Object {
"alignItems": "center",
"flexDirection": "row",
"height": 40,
"paddingHorizontal": 20,
},
false,
]
}
testID="channel_list_item.hello.collapsed.false"
>
<View
style={
Array [
Object {
"alignItems": "center",
"justifyContent": "center",
},
Object {
"height": 24,
"width": 24,
},
undefined,
undefined,
]
}
>
<View
style={
Array [
Object {
"alignItems": "center",
"backgroundColor": "rgba(255,255,255,0.16)",
"borderRadius": 4,
"justifyContent": "center",
},
undefined,
undefined,
Object {
"height": 24,
"width": 24,
},
]
}
>
<Text
style={
Array [
Object {
"color": "#ffffff",
"fontFamily": "OpenSans-SemiBold",
"fontSize": 12,
"fontWeight": "600",
"lineHeight": 16,
},
undefined,
undefined,
Object {
"fontSize": 12,
},
]
}
testID="undefined.gm_member_count"
>
1
</Text>
</View>
</View>
<Text
ellipsizeMode="tail"
numberOfLines={1}
style={
Array [
Object {
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
},
Object {
"color": "rgba(255,255,255,0.72)",
"flex": 1,
"marginTop": -1,
"paddingLeft": 12,
},
false,
false,
null,
]
}
testID="channel_list_item.hello.display_name"
>
Hello!
</Text>
</View>
</View>
</View>
`;

View file

@ -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 (
<Animated.View style={animatedStyle}>
<TouchableOpacity onPress={switchChannels}>
<View
style={[styles.container, isActive && styles.activeItem]}
testID={`${testID}.${channel.name}.collapsed.${collapsed && !isActive}`}
>
<ChannelIcon
isActive={isActive}
isArchived={channel.deleteAt > 0}
membersCount={membersCount}
name={channel.name}
shared={channel.shared}
size={24}
type={channel.type}
isMuted={isMuted}
/>
<Text
ellipsizeMode='tail'
numberOfLines={1}
style={textStyles}
testID={`${testID}.${channel.name}.display_name`}
>
{displayName}
</Text>
<Badge
visible={myChannel.mentionsCount > 0}
value={myChannel.mentionsCount}
style={[styles.badge, isMuted && styles.mutedBadge]}
/>
</View>
</TouchableOpacity>
</Animated.View>
);
};
export default ChannelListItem;

View file

@ -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<FlatList>(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 <UnreadCategories currentTeamId={currentTeamId}/>;
return (
<UnreadCategories
currentTeamId={currentTeamId}
onChannelSwitch={onChannelSwitch}
/>
);
}
return (
<>
@ -42,10 +55,11 @@ const Categories = ({categories, currentTeamId, unreadsOnTop}: Props) => {
<CategoryBody
category={data.item}
locale={intl.locale}
onChannelSwitch={onChannelSwitch}
/>
</>
);
}, [intl.locale]);
}, [currentTeamId, intl.locale, onChannelSwitch]);
useEffect(() => {
listRef.current?.scrollToOffset({animated: false, offset: 0});

View file

@ -21,6 +21,7 @@ describe('components/channel_list/categories/body', () => {
const wrapper = renderWithEverything(
<UnreadsCategory
unreadChannels={[]}
onChannelSwitch={() => undefined}
/>,
{database},
);

View file

@ -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 (
<ChannelItem
channel={item}
collapsed={false}
isUnreads={true}
/>
);
};
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 (
<ChannelItem
channel={item}
collapsed={false}
isUnreads={true}
onPress={onChannelSwitch}
/>
);
}, [onChannelSwitch]);
if (!unreadChannels.length) {
return null;
}

View file

@ -2,17 +2,23 @@
exports[`Search Field should match snapshot 1`] = `
<View
accessible={true}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
Object {
"alignItems": "center",
"backgroundColor": "rgba(255,255,255,0.12)",
"borderRadius": 8,
"flex": 1,
"flexDirection": "row",
"height": 40,
"justifyContent": "flex-start",
"marginVertical": 20,
"maxHeight": 40,
"padding": 8,
"width": "100%",
}
@ -28,34 +34,20 @@ exports[`Search Field should match snapshot 1`] = `
}
}
/>
<TextInput
placeholder="Find Channels"
placeholderTextColor="rgba(255,255,255,0.72)"
<Text
style={
Array [
Array [
Object {
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
},
Object {
"textAlignVertical": "center",
},
],
Object {
"alignContent": "center",
"alignItems": "center",
"color": "#ffffff",
"flex": 1,
"height": 40,
"marginLeft": 5,
"marginTop": -2,
},
]
Object {
"color": "rgba(255,255,255,0.72)",
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
"marginLeft": 5,
"marginTop": 1,
}
}
testID="channel_list.search_field.find_channels.input"
/>
>
Find channels...
</Text>
</View>
`;

View file

@ -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 (
<View
<TouchableHighlight
style={styles.container}
onPress={onPress}
underlayColor={changeOpacity(theme.sidebarText, 0.32)}
>
<CompassIcon
name='magnify'
style={styles.icon}
/>
<TextInput
style={[textStyles, styles.input]}
placeholder='Find Channels'
placeholderTextColor={changeOpacity(theme.sidebarText, 0.72)}
testID='channel_list.search_field.find_channels.input'
/>
</View>
<>
<CompassIcon
name='magnify'
style={styles.icon}
/>
<FormattedText
defaultMessage='Find channels...'
id='channel_list.find_channels'
style={styles.input}
/>
</>
</TouchableHighlight>
);
};

View file

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

View file

@ -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) {

View file

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