Gekidou - Sidebar UI components (#5955)

* Squash, rebase, test fixes

* PR Feedback addressed

* fix snapshot tests with SafeAreaProvider

* Addresses feedback

* Adds Category & Channel error

* Tests fixed

* MyChannel count

* finalize DM and GM avatars and icon as well as other small fixes

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
This commit is contained in:
Shaz Amjad 2022-03-01 08:25:00 +11:00 committed by GitHub
parent bd7ce77197
commit 066775ca82
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
43 changed files with 1335 additions and 956 deletions

View file

@ -14,6 +14,7 @@ import NetworkManager from '@init/network_manager';
import {prepareMyChannelsForTeam, queryChannelById, queryChannelByName, queryMyChannel} from '@queries/servers/channel';
import {queryCommonSystemValues, queryCurrentTeamId, queryCurrentUserId} from '@queries/servers/system';
import {prepareMyTeams, queryNthLastChannelFromTeam, queryMyTeamById, queryTeamById, queryTeamByName} from '@queries/servers/team';
import {queryCurrentUser} from '@queries/servers/user';
import {getDirectChannelName} from '@utils/channel';
import {PERMALINK_GENERIC_TEAM_NAME_REDIRECT} from '@utils/url';
import {displayGroupMessageName, displayUsername} from '@utils/user';
@ -267,9 +268,9 @@ export const fetchMyChannel = async (serverUrl: string, teamId: string, channelI
}
};
export const fetchMissingSidebarInfo = async (serverUrl: string, directChannels: Channel[], locale?: string, teammateDisplayNameSetting?: string, exludeUserId?: string, fetchOnly = false) => {
export const fetchMissingSidebarInfo = async (serverUrl: string, directChannels: Channel[], locale?: string, teammateDisplayNameSetting?: string, currentUserId?: string, fetchOnly = false) => {
const channelIds = directChannels.sort((a, b) => b.last_post_at - a.last_post_at).map((dc) => dc.id);
const result = await fetchProfilesPerChannels(serverUrl, channelIds, exludeUserId, false);
const result = await fetchProfilesPerChannels(serverUrl, channelIds, currentUserId, false);
if (result.error) {
return {error: result.error};
}
@ -280,7 +281,7 @@ export const fetchMissingSidebarInfo = async (serverUrl: string, directChannels:
result.data.forEach((data) => {
if (data.users) {
if (data.users.length > 1) {
displayNameByChannel[data.channelId] = displayGroupMessageName(data.users, locale, teammateDisplayNameSetting, exludeUserId);
displayNameByChannel[data.channelId] = displayGroupMessageName(data.users, locale, teammateDisplayNameSetting, currentUserId);
} else {
displayNameByChannel[data.channelId] = displayUsername(data.users[0], locale, teammateDisplayNameSetting);
}
@ -295,6 +296,15 @@ export const fetchMissingSidebarInfo = async (serverUrl: string, directChannels:
}
});
if (currentUserId) {
const ownDirectChannel = directChannels.find((dm) => dm.name === getDirectChannelName(currentUserId, currentUserId));
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (ownDirectChannel && database) {
const currentUser = await queryCurrentUser(database);
ownDirectChannel.display_name = displayUsername(currentUser, locale, teammateDisplayNameSetting);
}
}
if (!fetchOnly) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (operator) {

View file

@ -0,0 +1,37 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import ProfilePicture from '@components/profile_picture';
import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme} from '@utils/theme';
import type UserModel from '@typings/database/models/servers/user';
type Props = {
author?: UserModel;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
status: {
backgroundColor: theme.sidebarBg,
borderWidth: 0,
},
}));
const DmAvatar = ({author}: Props) => {
const theme = useTheme();
const style = getStyleSheet(theme);
return (
<ProfilePicture
author={author}
size={24}
showStatus={true}
statusSize={12}
statusStyle={style.status}
/>
);
};
export default DmAvatar;

View file

@ -0,0 +1,45 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Q} from '@nozbe/watermelondb';
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import {getUserIdFromChannelName} from '@utils/user';
import DmAvatar from './dm_avatar';
import type {WithDatabaseArgs} from '@typings/database/database';
import type SystemModel from '@typings/database/models/servers/system';
import type UserModel from '@typings/database/models/servers/user';
const {SERVER: {USER, SYSTEM}} = MM_TABLES;
const {CURRENT_USER_ID} = SYSTEM_IDENTIFIERS;
const enhance = withObservables(['channelName'], ({channelName, database}: {channelName: string} & WithDatabaseArgs) => {
const currentUserId = database.get<SystemModel>(SYSTEM).findAndObserve(CURRENT_USER_ID).pipe(
switchMap(({value}) => of$(value)),
);
const authorId = currentUserId.pipe(
switchMap((userId) => of$(getUserIdFromChannelName(userId, channelName))),
);
const author = authorId.pipe(
switchMap((id) => {
return database.get<UserModel>(USER).query(Q.where('id', id)).observe().pipe(
// eslint-disable-next-line max-nested-callbacks
switchMap((users) => (users[0] ? of$(users[0]) : of$(undefined))),
);
}),
);
return {
author,
};
});
export default withDatabase(enhance(DmAvatar));

View file

@ -8,6 +8,9 @@ import CompassIcon from '@components/compass_icon';
import General from '@constants/general';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import DmAvatar from './dm_avatar';
type ChannelIconProps = {
hasDraft?: boolean;
@ -16,6 +19,7 @@ type ChannelIconProps = {
isInfo?: boolean;
isUnread?: boolean;
membersCount?: number;
name: string;
shared: boolean;
size?: number;
style?: StyleProp<ViewStyle>;
@ -58,8 +62,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
},
group: {
color: theme.sidebarText,
fontSize: 10,
fontFamily: 'OpenSans-SemiBold',
...typography('Body', 75, 'SemiBold'),
},
groupActive: {
color: theme.sidebarTextActiveColor,
@ -80,6 +83,7 @@ const ChannelIcon = ({
isInfo = false,
isUnread = false,
membersCount = 0,
name,
shared,
size = 12,
style,
@ -159,10 +163,9 @@ const ChannelIcon = ({
);
} else if (type === General.GM_CHANNEL) {
const fontSize = size - 12;
const boxSize = size - 4;
icon = (
<View
style={[styles.groupBox, unreadGroupBox, activeGroupBox, {width: boxSize, height: boxSize}]}
style={[styles.groupBox, unreadGroupBox, activeGroupBox, {width: size, height: size}]}
>
<Text
style={[styles.group, unreadGroup, activeGroup, {fontSize}]}
@ -173,7 +176,7 @@ const ChannelIcon = ({
</View>
);
} else if (type === General.DM_CHANNEL) {
//todo: Implement ProfilePicture component
icon = (<DmAvatar channelName={name}/>);
}
return (

View file

@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/channel_list should match snapshot 1`] = `
exports[`components/channel_list should render channels error 1`] = `
<View
animatedStyle={
Object {
@ -15,7 +15,8 @@ exports[`components/channel_list should match snapshot 1`] = `
"backgroundColor": "#1e325c",
"flex": 1,
"maxWidth": "100%",
"paddingHorizontal": 20,
"paddingLeft": 18,
"paddingRight": 20,
"paddingVertical": 10,
}
}
@ -70,7 +71,299 @@ exports[`components/channel_list should match snapshot 1`] = `
]
}
>
Couldn't load
Couldn't load Test Team!
</Text>
<Text
style={
Array [
Object {
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
},
Object {
"color": "#ffffff",
"marginTop": 4,
"textAlign": "center",
},
]
}
>
There was a problem loading content for this team.
</Text>
<View
onMoveShouldSetResponder={[Function]}
onMoveShouldSetResponderCapture={[Function]}
onResponderEnd={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderReject={[Function]}
onResponderRelease={[Function]}
onResponderStart={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
onStartShouldSetResponderCapture={[Function]}
>
<View
accessible={true}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
Array [
Object {
"marginTop": 24,
},
Array [
Object {
"alignItems": "center",
"borderRadius": 4,
"flex": 0,
"justifyContent": "center",
},
Object {
"height": 48,
"paddingHorizontal": 24,
"paddingVertical": 14,
},
Object {
"backgroundColor": "#ffffff",
},
],
]
}
>
<Text
style={
Array [
Object {
"alignItems": "center",
"fontFamily": "OpenSans-SemiBold",
"fontWeight": "600",
"justifyContent": "center",
"padding": 1,
"textAlignVertical": "center",
},
Object {
"fontSize": 16,
"lineHeight": 16,
"marginTop": 1,
},
Object {
"color": "#1c58d9",
},
]
}
>
Retry
</Text>
</View>
</View>
</View>
</View>
`;
exports[`components/channel_list should render team error 1`] = `
<View
animatedStyle={
Object {
"value": Object {
"maxWidth": "100%",
},
}
}
collapsable={false}
style={
Object {
"backgroundColor": "#1e325c",
"flex": 1,
"maxWidth": "100%",
"paddingLeft": 18,
"paddingRight": 20,
"paddingVertical": 10,
}
}
>
<View
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%",
}
}
>
<Icon
name="magnify"
style={
Object {
"color": "rgba(255,255,255,0.72)",
"fontSize": 24,
"width": 24,
}
}
/>
<TextInput
placeholder="Find Channels"
placeholderTextColor="rgba(255,255,255,0.72)"
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,
},
]
}
/>
</View>
<View
onMoveShouldSetResponder={[Function]}
onMoveShouldSetResponderCapture={[Function]}
onResponderEnd={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderReject={[Function]}
onResponderRelease={[Function]}
onResponderStart={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
onStartShouldSetResponderCapture={[Function]}
>
<View
accessible={true}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
>
<View
style={
Object {
"display": "flex",
"flexDirection": "row",
}
}
>
<Icon
name="message-text-outline"
style={
Object {
"color": "#ffffff",
"fontSize": 24,
"lineHeight": 28,
}
}
/>
<Text
style={
Array [
Array [
Object {
"fontFamily": "OpenSans-SemiBold",
"fontSize": 16,
"fontWeight": "600",
"lineHeight": 24,
},
],
Object {
"color": "#ffffff",
"paddingLeft": 12,
},
]
}
>
Threads
</Text>
</View>
</View>
</View>
<View
style={
Object {
"alignItems": "center",
"flex": 1,
"justifyContent": "center",
"padding": 20,
}
}
>
<View
style={
Object {
"alignItems": "center",
"backgroundColor": "rgba(255,255,255,0.08)",
"borderRadius": 60,
"height": 120,
"justifyContent": "center",
"width": 120,
}
}
>
<Icon
name="alert-circle-outline"
style={
Object {
"color": "rgba(255,255,255,0.48)",
"fontSize": 72,
"lineHeight": 72,
}
}
/>
</View>
<Text
style={
Array [
Object {
"fontFamily": "Metropolis-SemiBold",
"fontSize": 20,
"fontWeight": "600",
"lineHeight": 28,
},
Object {
"color": "#ffffff",
"marginTop": 20,
"textAlign": "center",
},
]
}
>
Couldn't load categories for
</Text>
<Text
style={

View file

@ -1,430 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Category List Component should match snapshot 1`] = `
<RCTScrollView
ListHeaderComponent={[Function]}
data={
Array [
Object {
"channels": Array [
Object {
"id": "1",
"name": "Just a channel",
},
Object {
"highlight": true,
"id": "2",
"name": "Highlighted!!!",
},
],
"id": "1",
"title": "My first Category",
},
Object {
"channels": Array [
Object {
"id": "1",
"name": "Just a channel",
},
Object {
"highlight": true,
"id": "2",
"name": "Highlighted!!!",
},
],
"id": "2",
"title": "Another cat",
},
]
}
getItem={[Function]}
getItemCount={[Function]}
keyExtractor={[Function]}
onContentSizeChange={[Function]}
onLayout={[Function]}
onMomentumScrollBegin={[Function]}
onMomentumScrollEnd={[Function]}
onScroll={[Function]}
onScrollBeginDrag={[Function]}
onScrollEndDrag={[Function]}
removeClippedSubviews={false}
renderItem={[Function]}
scrollEventThrottle={50}
stickyHeaderIndices={Array []}
style={
Object {
"flex": 1,
}
}
viewabilityConfigCallbackPairs={Array []}
>
<View>
<View
onLayout={[Function]}
>
<View
onMoveShouldSetResponder={[Function]}
onMoveShouldSetResponderCapture={[Function]}
onResponderEnd={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderReject={[Function]}
onResponderRelease={[Function]}
onResponderStart={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
onStartShouldSetResponderCapture={[Function]}
>
<View
accessible={true}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
>
<View
style={
Object {
"display": "flex",
"flexDirection": "row",
}
}
>
<Icon
name="message-text-outline"
style={
Object {
"color": "#ffffff",
"fontSize": 24,
"lineHeight": 28,
}
}
/>
<Text
style={
Array [
Array [
Object {
"fontFamily": "OpenSans-SemiBold",
"fontSize": 16,
"fontWeight": "600",
"lineHeight": 24,
},
],
Object {
"color": "#ffffff",
"paddingLeft": 12,
},
]
}
>
Threads
</Text>
</View>
</View>
</View>
</View>
<View
onLayout={[Function]}
style={null}
>
<View
style={
Object {
"marginTop": 12,
"paddingVertical": 8,
}
}
>
<Text
style={
Object {
"color": "rgba(255,255,255,0.64)",
"fontFamily": "OpenSans-SemiBold",
"fontSize": 12,
"fontWeight": "600",
"lineHeight": 16,
}
}
>
MY FIRST CATEGORY
</Text>
</View>
<View
data={
Array [
Object {
"id": "1",
"name": "Just a channel",
},
Object {
"highlight": true,
"id": "2",
"name": "Highlighted!!!",
},
]
}
getItem={[Function]}
getItemCount={[Function]}
keyExtractor={[Function]}
onContentSizeChange={[Function]}
onLayout={[Function]}
onMomentumScrollBegin={[Function]}
onMomentumScrollEnd={[Function]}
onScroll={[Function]}
onScrollBeginDrag={[Function]}
onScrollEndDrag={[Function]}
removeClippedSubviews={false}
renderItem={[Function]}
scrollEventThrottle={50}
stickyHeaderIndices={Array []}
viewabilityConfigCallbackPairs={Array []}
>
<View
onLayout={[Function]}
style={null}
>
<View
style={
Object {
"flexDirection": "row",
"paddingVertical": 4,
}
}
>
<Icon
name="globe"
style={
Object {
"color": "rgba(255,255,255,0.72)",
"fontSize": 24,
"lineHeight": 28,
}
}
/>
<Text
style={
Array [
Object {
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
},
Object {
"color": "rgba(255,255,255,0.72)",
"marginTop": 1,
"paddingLeft": 12,
},
undefined,
]
}
>
Just a channel
</Text>
</View>
</View>
<View
onLayout={[Function]}
style={null}
>
<View
style={
Object {
"flexDirection": "row",
"paddingVertical": 4,
}
}
>
<Icon
name="globe"
style={
Object {
"color": "rgba(255,255,255,0.72)",
"fontSize": 24,
"lineHeight": 28,
}
}
/>
<Text
style={
Array [
Object {
"fontFamily": "OpenSans-SemiBold",
"fontSize": 16,
"fontWeight": "600",
"lineHeight": 24,
},
Object {
"color": "rgba(255,255,255,0.72)",
"marginTop": 1,
"paddingLeft": 12,
},
Object {
"color": "#ffffff",
},
]
}
>
Highlighted!!!
</Text>
</View>
</View>
</View>
</View>
<View
onLayout={[Function]}
style={null}
>
<View
style={
Object {
"marginTop": 12,
"paddingVertical": 8,
}
}
>
<Text
style={
Object {
"color": "rgba(255,255,255,0.64)",
"fontFamily": "OpenSans-SemiBold",
"fontSize": 12,
"fontWeight": "600",
"lineHeight": 16,
}
}
>
ANOTHER CAT
</Text>
</View>
<View
data={
Array [
Object {
"id": "1",
"name": "Just a channel",
},
Object {
"highlight": true,
"id": "2",
"name": "Highlighted!!!",
},
]
}
getItem={[Function]}
getItemCount={[Function]}
keyExtractor={[Function]}
onContentSizeChange={[Function]}
onLayout={[Function]}
onMomentumScrollBegin={[Function]}
onMomentumScrollEnd={[Function]}
onScroll={[Function]}
onScrollBeginDrag={[Function]}
onScrollEndDrag={[Function]}
removeClippedSubviews={false}
renderItem={[Function]}
scrollEventThrottle={50}
stickyHeaderIndices={Array []}
viewabilityConfigCallbackPairs={Array []}
>
<View
onLayout={[Function]}
style={null}
>
<View
style={
Object {
"flexDirection": "row",
"paddingVertical": 4,
}
}
>
<Icon
name="globe"
style={
Object {
"color": "rgba(255,255,255,0.72)",
"fontSize": 24,
"lineHeight": 28,
}
}
/>
<Text
style={
Array [
Object {
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
},
Object {
"color": "rgba(255,255,255,0.72)",
"marginTop": 1,
"paddingLeft": 12,
},
undefined,
]
}
>
Just a channel
</Text>
</View>
</View>
<View
onLayout={[Function]}
style={null}
>
<View
style={
Object {
"flexDirection": "row",
"paddingVertical": 4,
}
}
>
<Icon
name="globe"
style={
Object {
"color": "rgba(255,255,255,0.72)",
"fontSize": 24,
"lineHeight": 28,
}
}
/>
<Text
style={
Array [
Object {
"fontFamily": "OpenSans-SemiBold",
"fontSize": 16,
"fontWeight": "600",
"lineHeight": 24,
},
Object {
"color": "rgba(255,255,255,0.72)",
"marginTop": 1,
"paddingLeft": 12,
},
Object {
"color": "#ffffff",
},
]
}
>
Highlighted!!!
</Text>
</View>
</View>
</View>
</View>
</View>
</RCTScrollView>
`;

View file

@ -0,0 +1,123 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/channel_list/categories/body should match snapshot 1`] = `
Object {
"children": Array [
<View>
<View
onLayout={[Function]}
style={null}
>
<RNGestureHandlerButton
collapsable={false}
exclusive={true}
onGestureEvent={[Function]}
onGestureHandlerEvent={[Function]}
onGestureHandlerStateChange={[Function]}
onHandlerStateChange={[Function]}
rippleColor={0}
>
<View
accessible={true}
collapsable={false}
style={
Object {
"opacity": 1,
}
}
>
<View
style={
Object {
"flexDirection": "row",
"marginBottom": 8,
"paddingLeft": 2,
"paddingVertical": 4,
}
}
>
<View
style={
Array [
Object {
"alignItems": "center",
"justifyContent": "center",
},
Object {
"height": 24,
"width": 24,
},
undefined,
]
}
>
<Icon
name="globe"
style={
Array [
Object {
"color": "rgba(255,255,255,0.4)",
},
undefined,
undefined,
Object {
"fontSize": 24,
"left": 1,
},
]
}
testID="undefined.public"
/>
</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,
]
}
>
Channel
</Text>
</View>
</View>
</RNGestureHandlerButton>
</View>
</View>,
],
"props": Object {
"data": Anything,
"getItem": [Function],
"getItemCount": [Function],
"invertStickyHeaders": undefined,
"keyExtractor": [Function],
"onContentSizeChange": [Function],
"onLayout": [Function],
"onMomentumScrollBegin": [Function],
"onMomentumScrollEnd": [Function],
"onScroll": [Function],
"onScrollBeginDrag": [Function],
"onScrollEndDrag": [Function],
"removeClippedSubviews": false,
"renderItem": [Function],
"scrollEventThrottle": 50,
"stickyHeaderIndices": Array [],
"style": undefined,
"viewabilityConfigCallbackPairs": Array [],
},
"type": "RCTScrollView",
}
`;

View file

@ -1,127 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Category Body Component should match snapshot 1`] = `
<RCTScrollView
data={
Array [
Object {
"id": "1",
"name": "Just a channel",
},
Object {
"highlight": true,
"id": "2",
"name": "Highlighted!!!",
},
]
}
getItem={[Function]}
getItemCount={[Function]}
keyExtractor={[Function]}
onContentSizeChange={[Function]}
onLayout={[Function]}
onMomentumScrollBegin={[Function]}
onMomentumScrollEnd={[Function]}
onScroll={[Function]}
onScrollBeginDrag={[Function]}
onScrollEndDrag={[Function]}
removeClippedSubviews={false}
renderItem={[Function]}
scrollEventThrottle={50}
stickyHeaderIndices={Array []}
viewabilityConfigCallbackPairs={Array []}
>
<View>
<View
onLayout={[Function]}
style={null}
>
<View
style={
Object {
"flexDirection": "row",
"paddingVertical": 4,
}
}
>
<Icon
name="globe"
style={
Object {
"color": "rgba(255,255,255,0.72)",
"fontSize": 24,
"lineHeight": 28,
}
}
/>
<Text
style={
Array [
Object {
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
},
Object {
"color": "rgba(255,255,255,0.72)",
"marginTop": 1,
"paddingLeft": 12,
},
undefined,
]
}
>
Just a channel
</Text>
</View>
</View>
<View
onLayout={[Function]}
style={null}
>
<View
style={
Object {
"flexDirection": "row",
"paddingVertical": 4,
}
}
>
<Icon
name="globe"
style={
Object {
"color": "rgba(255,255,255,0.72)",
"fontSize": 24,
"lineHeight": 28,
}
}
/>
<Text
style={
Array [
Object {
"fontFamily": "OpenSans-SemiBold",
"fontSize": 16,
"fontWeight": "600",
"lineHeight": 24,
},
Object {
"color": "rgba(255,255,255,0.72)",
"marginTop": 1,
"paddingLeft": 12,
},
Object {
"color": "#ffffff",
},
]
}
>
Highlighted!!!
</Text>
</View>
</View>
</View>
</RCTScrollView>
`;

View file

@ -0,0 +1,64 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Database, Q} from '@nozbe/watermelondb';
import React from 'react';
import {MM_TABLES} from '@constants/database';
import {renderWithEverything} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import CategoryBody from './category_body';
import type CategoryModel from '@typings/database/models/servers/category';
import type CategoryChannelModel from '@typings/database/models/servers/category_channel';
import type ChannelModel from '@typings/database/models/servers/channel';
import type MyChannelModel from '@typings/database/models/servers/my_channel';
const {SERVER: {CATEGORY, CATEGORY_CHANNEL, CHANNEL, MY_CHANNEL}} = MM_TABLES;
describe('components/channel_list/categories/body', () => {
let database: Database;
let category: CategoryModel;
let categoryChannels: CategoryChannelModel[];
let myChannels: MyChannelModel[];
let channels: ChannelModel[];
beforeAll(async () => {
const server = await TestHelper.setupServerDatabase();
database = server.database;
const categories = await database.get<CategoryModel>(CATEGORY).query(
Q.take(1),
).fetch();
category = categories[0];
categoryChannels = await database.get<CategoryChannelModel>(CATEGORY_CHANNEL).query(
Q.where('category_id', category.id),
).fetch();
const channelIds = await database.get<ChannelModel>(CHANNEL).query(
Q.on(CATEGORY_CHANNEL, 'category_id', category.id),
).fetchIds();
myChannels = await database.get<MyChannelModel>(MY_CHANNEL).query(
Q.where('id', Q.oneOf(channelIds)),
).fetch();
});
it('should match snapshot', () => {
const wrapper = renderWithEverything(
<CategoryBody
category={category}
myChannels={myChannels}
categoryChannels={categoryChannels}
channels={channels}
/>,
{database},
);
expect(wrapper.toJSON()).toMatchSnapshot({
props: {data: expect.anything()},
});
});
});

View file

@ -0,0 +1,47 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import {FlatList} from 'react-native';
import ChannelListItem from './channel';
import type CategoryModel from '@typings/database/models/servers/category';
import type CategoryChannelModel from '@typings/database/models/servers/category_channel';
import type ChannelModel from '@typings/database/models/servers/channel';
import type MyChannelModel from '@typings/database/models/servers/my_channel';
type Props = {
category: CategoryModel;
channels: ChannelModel[];
myChannels: MyChannelModel[];
categoryChannels: CategoryChannelModel[];
};
const ChannelItem = ({item}: {item: string}) => {
return (
<ChannelListItem channelId={item}/>
);
};
const CategoryBody = ({category, categoryChannels, channels, myChannels}: Props) => {
const data: string[] = useMemo(() => {
switch (category.sorting) {
case 'alpha':
return channels.map((c) => c.id);
case 'manual':
return categoryChannels.map((cc) => cc.channelId);
default:
return myChannels.map((m) => m.id);
}
}, [category.sorting, categoryChannels, channels, myChannels]);
return (
<FlatList
data={data}
renderItem={ChannelItem}
/>
);
};
export default CategoryBody;

View file

@ -0,0 +1,114 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/channel_list/categories/body/channel/item should match snapshot 1`] = `
<RNGestureHandlerButton
collapsable={false}
exclusive={true}
onGestureEvent={[Function]}
onGestureHandlerEvent={[Function]}
onGestureHandlerStateChange={[Function]}
onHandlerStateChange={[Function]}
rippleColor={0}
>
<View
accessible={true}
collapsable={false}
style={
Object {
"opacity": 1,
}
}
>
<View
style={
Object {
"flexDirection": "row",
"marginBottom": 8,
"paddingLeft": 2,
"paddingVertical": 4,
}
}
>
<View
style={
Array [
Object {
"alignItems": "center",
"justifyContent": "center",
},
Object {
"height": 24,
"width": 24,
},
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,
]
}
>
Hello!
</Text>
</View>
</View>
</RNGestureHandlerButton>
`;

View file

@ -1,43 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Category Channel List Item should match snapshot 1`] = `
<View
style={
Object {
"flexDirection": "row",
"paddingVertical": 4,
}
}
>
<Icon
name="globe"
style={
Object {
"color": "rgba(255,255,255,0.72)",
"fontSize": 24,
"lineHeight": 28,
}
}
/>
<Text
style={
Array [
Object {
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
},
Object {
"color": "rgba(255,255,255,0.72)",
"marginTop": 1,
"paddingLeft": 12,
},
undefined,
]
}
>
Channel Name
</Text>
</View>
`;

View file

@ -0,0 +1,41 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Database, Q} from '@nozbe/watermelondb';
import React from 'react';
import {MM_TABLES} from '@constants/database';
import {renderWithIntlAndTheme} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import ChannelListItem from './channel_list_item';
import type MyChannelModel from '@typings/database/models/servers/my_channel';
describe('components/channel_list/categories/body/channel/item', () => {
let database: Database;
let myChannel: MyChannelModel;
beforeAll(async () => {
const server = await TestHelper.setupServerDatabase();
database = server.database;
const myChannels = await database.get<MyChannelModel>(MM_TABLES.SERVER.MY_CHANNEL).query(
Q.take(1),
).fetch();
myChannel = myChannels[0];
});
it('should match snapshot', () => {
const wrapper = renderWithIntlAndTheme(
<ChannelListItem
channel={{displayName: 'Hello!', type: 'G', shared: false, name: 'hello'}}
isOwnDirectMessage={false}
myChannel={myChannel}
/>,
);
expect(wrapper.toJSON()).toMatchSnapshot();
});
});

View file

@ -0,0 +1,106 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import {useIntl} from 'react-intl';
import {StyleSheet, Text, View} from 'react-native';
import {TouchableOpacity} from 'react-native-gesture-handler';
import {switchToChannelById} from '@actions/remote/channel';
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 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',
marginBottom: 8,
paddingLeft: 2,
paddingVertical: 4,
},
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,
},
}));
const textStyle = StyleSheet.create({
bright: typography('Body', 200, 'SemiBold'),
regular: typography('Body', 200, 'Regular'),
});
type Props = {
channel: Pick<ChannelModel, 'displayName' | 'name' | 'shared' | 'type'>;
isOwnDirectMessage: boolean;
myChannel: MyChannelModel;
}
const ChannelListItem = ({channel, isOwnDirectMessage, myChannel}: Props) => {
const {formatMessage} = useIntl();
const theme = useTheme();
const styles = getStyleSheet(theme);
const serverUrl = useServerUrl();
// Make it brighter if it's highlighted, or has unreads
const bright = myChannel.isUnread || myChannel.mentionsCount > 0;
const switchChannels = () => switchToChannelById(serverUrl, myChannel.id);
const membersCount = useMemo(() => {
if (channel.type === General.GM_CHANNEL) {
return channel.displayName?.split(',').length;
}
return 0;
}, [channel.type, channel.displayName]);
const textStyles = useMemo(() => [
bright ? textStyle.bright : textStyle.regular,
styles.text,
bright && styles.highlight,
], [bright, styles]);
let displayName = channel.displayName;
if (isOwnDirectMessage) {
displayName = formatMessage({id: 'channel_header.directchannel.you', defaultMessage: '{displayName} (you)'}, {displayName});
}
return (
<TouchableOpacity onPress={switchChannels}>
<View style={styles.container}>
<ChannelIcon
membersCount={membersCount}
name={channel.name}
shared={channel.shared}
size={24}
type={channel.type}
/>
<Text
ellipsizeMode='tail'
numberOfLines={1}
style={textStyles}
>
{displayName}
</Text>
</View>
</TouchableOpacity>
);
};
export default ChannelListItem;

View file

@ -1,19 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {renderWithIntlAndTheme} from '@test/intl-test-helper';
import ChannelItem from './index';
test('Category Channel List Item should match snapshot', () => {
const {toJSON} = renderWithIntlAndTheme(
<ChannelItem
icon={'globe'}
name='Channel Name'
/>,
);
expect(toJSON()).toMatchSnapshot();
});

View file

@ -0,0 +1,54 @@
// 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 {General} from '@constants';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import {getUserIdFromChannelName} from '@utils/user';
import ChannelListItem from './channel_list_item';
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';
import type SystemModel from '@typings/database/models/servers/system';
const {SERVER: {MY_CHANNEL, SYSTEM}} = MM_TABLES;
const {CURRENT_USER_ID} = SYSTEM_IDENTIFIERS;
const enhance = withObservables(['channelId'], ({channelId, database}: {channelId: string} & WithDatabaseArgs) => {
const myChannel = database.get<MyChannelModel>(MY_CHANNEL).findAndObserve(channelId);
const currentUserId = database.get<SystemModel>(SYSTEM).findAndObserve(CURRENT_USER_ID).pipe(
switchMap(({value}) => of$(value)),
);
const channel = myChannel.pipe(switchMap((my) => my.channel.observe()));
const isOwnDirectMessage = combineLatest([currentUserId, channel]).pipe(
switchMap(([userId, ch]) => {
if (ch?.type === General.DM_CHANNEL) {
const teammateId = getUserIdFromChannelName(userId, ch.name);
return of$(userId === teammateId);
}
return of$(false);
}),
);
return {
isOwnDirectMessage,
myChannel,
channel: channel.pipe(
switchMap((c: ChannelModel) => of$({
displayName: c.displayName,
name: c.name,
shared: c.shared,
type: c.type,
})),
),
};
});
export default withDatabase(enhance(ChannelListItem));

View file

@ -1,75 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {StyleSheet, Text, View} 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';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
paddingVertical: 4,
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,
},
}));
const textStyle = StyleSheet.create({
bright: typography('Body', 200, 'SemiBold'),
regular: typography('Body', 200, 'Regular'),
});
type Props = {
unreadCount?: number;
highlight?: boolean;
name: string;
icon: string;
}
const ChannelListItem = (props: Props) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const {unreadCount, highlight, name, icon} = props;
// Make it brighter if it's highlighted, or has unreads
const bright = highlight || (unreadCount && unreadCount > 0);
return (
<View style={styles.container}>
{icon && (
<CompassIcon
name={icon}
style={styles.icon}
/>
)}
<Text
style={[
bright ? textStyle.bright : textStyle.regular,
styles.text,
bright && styles.highlight,
]}
>
{name}
</Text>
</View>
);
};
export default ChannelListItem;

View file

@ -1,21 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {renderWithIntlAndTheme} from '@test/intl-test-helper';
import CategoryBody from './index';
const channels: TempoChannel[] = [
{id: '1', name: 'Just a channel'},
{id: '2', name: 'Highlighted!!!', highlight: true},
];
test('Category Body Component should match snapshot', () => {
const {toJSON} = renderWithIntlAndTheme(
<CategoryBody channels={channels}/>,
);
expect(toJSON()).toMatchSnapshot();
});

View file

@ -0,0 +1,17 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import withObservables from '@nozbe/with-observables';
import CategoryBody from './category_body';
import type CategoryModel from '@typings/database/models/servers/category';
const withCategory = withObservables(['category'], ({category}: {category: CategoryModel}) => ({
category,
categoryChannels: category.categoryChannelsBySortOrder.observeWithColumns(['sort_order']),
myChannels: category.myChannels.observeWithColumns(['last_post_at']),
channels: category.channels.observeWithColumns(['display_name']),
}));
export default withCategory(CategoryBody);

View file

@ -1,32 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FlatList} from 'react-native';
import ChannelListItem from './channel';
const renderChannelItem = (data: { item: TempoChannel }) => {
return (
<ChannelListItem
icon={'globe'}
name={data.item.name}
highlight={data.item.highlight}
/>
);
};
type Props = {
channels: TempoChannel[];
};
const CategoryBody = (props: Props) => {
return (
<FlatList
data={props.channels}
renderItem={renderChannelItem}
/>
);
};
export default CategoryBody;

View file

@ -0,0 +1,27 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import Database from '@nozbe/watermelondb/Database';
import React from 'react';
import {renderWithEverything} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import Categories from './';
describe('components/channel_list/categories', () => {
let database: Database;
beforeAll(async () => {
const server = await TestHelper.setupServerDatabase();
database = server.database;
});
it('render without error', () => {
const wrapper = renderWithEverything(
<Categories currentTeamId={TestHelper.basicTeam!.id}/>,
{database},
);
expect(wrapper.toJSON()).toBeTruthy();
});
});

View file

@ -0,0 +1,51 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FlatList, StyleSheet} from 'react-native';
import CategoryBody from './body';
import LoadCategoriesError from './error';
import CategoryHeader from './header';
import type {CategoryModel} from '@database/models/server';
type Props = {
categories: CategoryModel[];
}
const styles = StyleSheet.create({
flex: {
flex: 1,
},
});
const renderCategory = (data: {item: CategoryModel}) => {
return (
<>
<CategoryHeader category={data.item}/>
<CategoryBody category={data.item}/>
</>
);
};
const Categories = (props: Props) => {
if (!props.categories.length) {
return <LoadCategoriesError/>;
}
// Sort Categories
props.categories.sort((a, b) => a.sortOrder - b.sortOrder);
return (
<FlatList
data={props.categories}
renderItem={renderCategory}
style={styles.flex}
showsHorizontalScrollIndicator={false}
showsVerticalScrollIndicator={false}
/>
);
};
export default Categories;

View file

@ -0,0 +1,36 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useState} from 'react';
import {useIntl} from 'react-intl';
import {retryInitialTeamAndChannel} from '@actions/remote/retry';
import LoadingError from '@components/channel_list/loading_error';
import {useServerDisplayName, useServerUrl} from '@context/server';
const LoadCategoriesError = () => {
const {formatMessage} = useIntl();
const serverUrl = useServerUrl();
const serverName = useServerDisplayName();
const [loading, setLoading] = useState(false);
const onRetryTeams = useCallback(async () => {
setLoading(true);
const {error} = await retryInitialTeamAndChannel(serverUrl);
if (error) {
setLoading(false);
}
}, []);
return (
<LoadingError
loading={loading}
message={formatMessage({id: 'load_categories_error.message', defaultMessage: 'There was a problem loading content for this server.'})}
onRetry={onRetryTeams}
title={formatMessage({id: 'load_categories_error.title', defaultMessage: "Couldn't load categories for {serverName}"}, {serverName})}
/>
);
};
export default LoadCategoriesError;

View file

@ -1,10 +1,11 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Category Header Component should match snapshot 1`] = `
exports[`components/channel_list/categories/header should match snapshot 1`] = `
<View
style={
Object {
"marginTop": 12,
"paddingLeft": 2,
"paddingVertical": 8,
}
}
@ -20,7 +21,7 @@ exports[`Category Header Component should match snapshot 1`] = `
}
}
>
CAT HEADING
TEST CATEGORY
</Text>
</View>
`;

View file

@ -0,0 +1,38 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Database, Q} from '@nozbe/watermelondb';
import React from 'react';
import {MM_TABLES} from '@constants/database';
import {renderWithIntlAndTheme} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import CategoryHeader from './header';
import type CategoryModel from '@typings/database/models/servers/category';
describe('components/channel_list/categories/header', () => {
let database: Database;
let category: CategoryModel;
beforeAll(async () => {
const server = await TestHelper.setupServerDatabase();
database = server.database;
const categories = await database.get<CategoryModel>(MM_TABLES.SERVER.CATEGORY).query(
Q.take(1),
).fetch();
category = categories[0];
});
it('should match snapshot', () => {
const wrapper = renderWithIntlAndTheme(
<CategoryHeader
category={category}
hasChannels={true}
/>,
);
expect(wrapper.toJSON()).toMatchSnapshot();
});
});

View file

@ -8,10 +8,13 @@ import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import type CategoryModel from '@typings/database/models/servers/category';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
paddingVertical: 8,
marginTop: 12,
paddingLeft: 2,
},
heading: {
color: changeOpacity(theme.sidebarText, 0.64),
@ -20,17 +23,23 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
}));
type Props = {
heading: string;
category: CategoryModel;
hasChannels: boolean;
}
const CategoryHeader = (props: Props) => {
const CategoryHeader = ({category, hasChannels}: Props) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
// Hide favs if empty
if (!hasChannels && category.type === 'favorites') {
return (null);
}
return (
<View style={styles.container}>
<Text style={styles.heading}>
{props.heading.toUpperCase()}
{category.displayName.toUpperCase()}
</Text>
</View>
);

View file

@ -1,16 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {renderWithIntlAndTheme} from '@test/intl-test-helper';
import Header from './index';
test('Category Header Component should match snapshot', () => {
const {toJSON} = renderWithIntlAndTheme(
<Header heading='Cat Heading'/>,
);
expect(toJSON()).toMatchSnapshot();
});

View file

@ -0,0 +1,15 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import withObservables from '@nozbe/with-observables';
import CategoryHeader from './header';
import type CategoryModel from '@typings/database/models/servers/category';
const enhanced = withObservables(['category'], ({category}: {category: CategoryModel}) => ({
category,
hasChannels: category.hasChannels,
}));
export default enhanced(CategoryHeader);

View file

@ -1,38 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {renderWithEverything} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import Category from './index';
import type Database from '@nozbe/watermelondb/Database';
const channels: TempoChannel[] = [
{id: '1', name: 'Just a channel'},
{id: '2', name: 'Highlighted!!!', highlight: true},
];
const categories: TempoCategory[] = [
{id: '1', title: 'My first Category', channels},
{id: '2', title: 'Another cat', channels},
];
describe('Category List Component ', () => {
let database: Database | undefined;
beforeAll(async () => {
const server = await TestHelper.setupServerDatabase();
database = server.database;
});
test('should match snapshot', () => {
const {toJSON} = renderWithEverything(
<Category categories={categories}/>,
{database},
);
expect(toJSON()).toMatchSnapshot();
});
});

View file

@ -0,0 +1,31 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Q} from '@nozbe/watermelondb';
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {MM_TABLES} from '@constants/database';
import Categories from './categories';
import type {WithDatabaseArgs} from '@typings/database/database';
import type CategoryModel from '@typings/database/models/servers/category';
const {SERVER: {CATEGORY}} = MM_TABLES;
type WithDatabaseProps = {currentTeamId: string } & WithDatabaseArgs
const enhanced = withObservables(
['currentTeamId'],
({currentTeamId, database}: WithDatabaseProps) => {
const categories = database.get<CategoryModel>(CATEGORY).query(
Q.where('team_id', currentTeamId),
).observeWithColumns(['sort_order']);
return {
categories,
};
});
export default withDatabase(enhanced(Categories));

View file

@ -1,40 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FlatList, StyleSheet} from 'react-native';
import ThreadsButton from '../threads';
import CategoryBody from './body';
import CategoryHeader from './header';
type Props = {
categories: TempoCategory[];
}
const styles = StyleSheet.create({
flex: {
flex: 1,
},
});
const renderCategory = (data: {item: TempoCategory}) => (
<>
<CategoryHeader heading={data.item.title}/>
<CategoryBody channels={data.item.channels}/>
</>
);
const Categories = (props: Props) => {
return (
<FlatList
data={props.categories}
renderItem={renderCategory}
ListHeaderComponent={ThreadsButton}
style={styles.flex}
/>
);
};
export default Categories;

View file

@ -10,7 +10,7 @@ import {TeamModel} from '@database/models/server';
import {renderWithEverything} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import ChannelsList from './index';
import ChannelsList from './';
describe('components/channel_list', () => {
let database: Database;
@ -26,12 +26,44 @@ describe('components/channel_list', () => {
});
});
it('should match snapshot', () => {
it('should render', () => {
const wrapper = renderWithEverything(
<SafeAreaProvider>
<ChannelsList
isTablet={false}
teamsCount={1}
currentTeamId={TestHelper.basicTeam!.id}
channelsCount={1}
/>
</SafeAreaProvider>,
{database},
);
expect(wrapper.toJSON()).toBeTruthy();
});
it('should render team error', () => {
const wrapper = renderWithEverything(
<SafeAreaProvider>
<ChannelsList
isTablet={false}
teamsCount={0}
currentTeamId='TestHelper.basicTeam!.id'
channelsCount={1}
/>
</SafeAreaProvider>,
{database},
);
expect(wrapper.toJSON()).toMatchSnapshot();
});
it('should render channels error', () => {
const wrapper = renderWithEverything(
<SafeAreaProvider>
<ChannelsList
isTablet={false}
teamsCount={1}
currentTeamId={TestHelper.basicTeam!.id}
channelsCount={0}
/>
</SafeAreaProvider>,
{database},

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect, useState} from 'react';
import React, {useEffect} from 'react';
import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
import {TABLET_SIDEBAR_WIDTH, TEAM_SIDEBAR_WIDTH} from '@constants/view';
@ -13,38 +13,28 @@ import ChannelListHeader from './header';
import LoadChannelsError from './load_channels_error';
import LoadTeamsError from './load_teams_error';
import SearchField from './search';
// import Loading from '@components/loading';
const channels: TempoChannel[] = [
{id: '1', name: 'Just a channel'},
{id: '2', name: 'A Highlighted Channel!!!', highlight: true},
{id: '3', name: 'And a longer channel name.'},
];
const categories: TempoCategory[] = [
{id: '1', title: 'My first Category', channels},
{id: '2', title: 'Another Cat', channels},
];
import Threads from './threads';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
flex: 1,
backgroundColor: theme.sidebarBg,
paddingHorizontal: 20,
paddingLeft: 18,
paddingRight: 20,
paddingVertical: 10,
},
}));
type ChannelListProps = {
channelsCount: number;
currentTeamId?: string;
iconPad?: boolean;
isTablet: boolean;
teamsCount: number;
}
const ChannelList = ({currentTeamId, iconPad, isTablet, teamsCount}: ChannelListProps) => {
const ChannelList = ({channelsCount, currentTeamId, iconPad, isTablet, teamsCount}: ChannelListProps) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const tabletWidth = useSharedValue(TABLET_SIDEBAR_WIDTH);
@ -64,26 +54,28 @@ const ChannelList = ({currentTeamId, iconPad, isTablet, teamsCount}: ChannelList
}
}, [isTablet, teamsCount]);
const [showCats, setShowCats] = useState<boolean>(true);
let content;
if (!currentTeamId) {
content = (<LoadTeamsError/>);
} else if (showCats) {
} else if (channelsCount < 1) {
content = (<LoadChannelsError teamId={currentTeamId}/>);
} else {
content = (
<>
<SearchField/>
<Categories categories={categories}/>
<Threads/>
<Categories
currentTeamId={currentTeamId}
/>
</>
);
} else {
content = (<LoadChannelsError teamId={currentTeamId}/>);
}
return (
<Animated.View style={[styles.container, tabletStyle]}>
<ChannelListHeader
iconPad={iconPad}
onHeaderPress={() => setShowCats(!showCats)}
/>
{content}
</Animated.View>

View file

@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import React from 'react';
import {StyleSheet, TextInput, View} from 'react-native';
import {TextInput, StyleSheet, View} from 'react-native';
import CompassIcon from '@components/compass_icon';
import {useTheme} from '@context/theme';

View file

@ -1,16 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// THIS IS TEMPORARY POC/MVP - WILL BE REMOVEd
// @to-do - Wire up actual models
type TempoChannel = {
id: string;
name: string;
highlight?: boolean;
};
type TempoCategory = {
id: string;
title: string;
channels: TempoChannel[];
}

View file

@ -3,26 +3,14 @@
import React from 'react';
import {renderWithEverything} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import {renderWithIntlAndTheme} from '@test/intl-test-helper';
import Threads from './index';
import type Database from '@nozbe/watermelondb/Database';
test('Threads Component should match snapshot', () => {
const {toJSON} = renderWithIntlAndTheme(
<Threads/>,
);
describe('Threads Component', () => {
let database: Database | undefined;
beforeAll(async () => {
const server = await TestHelper.setupServerDatabase();
database = server.database;
});
test('should match snapshot', () => {
const {toJSON} = renderWithEverything(
<Threads/>,
{database},
);
expect(toJSON()).toMatchSnapshot();
});
expect(toJSON()).toMatchSnapshot();
});

View file

@ -1,28 +1,17 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Q} from '@nozbe/watermelondb';
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import React from 'react';
import {StyleSheet, Text, View} from 'react-native';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {switchToChannelById} from '@actions/remote/channel';
import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {General} from '@constants';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import {useServerUrl} from '@context/server';
import {Screens} from '@constants';
import {useTheme} from '@context/theme';
import {goToScreen} from '@screens/navigation';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import type {WithDatabaseArgs} from '@typings/database/database';
import type ChannelModel from '@typings/database/models/servers/channel';
import type SystemModel from '@typings/database/models/servers/system';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
display: 'flex',
@ -41,19 +30,18 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
const textStyle = StyleSheet.create([typography('Body', 200, 'SemiBold')]);
const ThreadsButton = ({channelId}: {channelId?: string}) => {
const ThreadsButton = () => {
const theme = useTheme();
const serverUrl = useServerUrl();
const styles = getStyleSheet(theme);
/*
* @to-do:
* - Check if there are threads, else return null (think of doing this before mounting this component)
* - Change to button, navigate to threads view instead of the current team Town Square
* - Check if there are threads, else return null
* - Change to button, navigate to threads view
* - Add right-side number badge
*/
return (
<TouchableWithFeedback onPress={() => (channelId ? switchToChannelById(serverUrl, channelId) : true)} >
<TouchableWithFeedback onPress={() => goToScreen(Screens.CHANNEL, 'Channel', {}, {topBar: {visible: false}})} >
<View style={styles.container}>
<CompassIcon
name='message-text-outline'
@ -65,19 +53,4 @@ const ThreadsButton = ({channelId}: {channelId?: string}) => {
);
};
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
const currentTeamId = database.get<SystemModel>(MM_TABLES.SERVER.SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID);
const channelId = currentTeamId.pipe(
switchMap((model) => database.get<ChannelModel>(MM_TABLES.SERVER.CHANNEL).query(
Q.where('team_id', model.value),
Q.where('name', General.DEFAULT_CHANNEL),
).observe().pipe(
// eslint-disable-next-line max-nested-callbacks
switchMap((channels) => (channels.length ? of$(channels[0].id) : of$(undefined))),
)),
);
return {channelId};
});
export default withDatabase(enhanced(ThreadsButton));
export default ThreadsButton;

View file

@ -38,7 +38,8 @@ const Status = ({author, statusSize, statusStyle, theme}: Props) => {
styles.statusWrapper,
statusStyle,
{borderRadius: statusSize / 2},
]), []);
]), [statusStyle]);
if (author?.status && !author.isBot) {
return (
<View

View file

@ -17,6 +17,7 @@ import {makeStyleSheetFromTheme} from '@utils/theme';
import Servers from './servers';
type ChannelProps = {
channelsCount: number;
currentTeamId?: string;
teamsCount: number;
time?: number;
@ -91,10 +92,11 @@ const ChannelListScreen = (props: ChannelProps) => {
teamsCount={props.teamsCount}
/>
<ChannelList
currentTeamId={props.currentTeamId}
iconPad={canAddOtherServers && props.teamsCount <= 1}
isTablet={isTablet}
teamsCount={props.teamsCount}
channelsCount={props.channelsCount}
currentTeamId={props.currentTeamId}
/>
{isTablet && Boolean(props.currentTeamId) &&
<Channel/>

View file

@ -7,14 +7,15 @@ import {catchError, of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import MyTeamModel from '@typings/database/models/servers/my_team';
import ChannelsList from './channel_list';
import type {WithDatabaseArgs} from '@typings/database/database';
import type MyChannelModel from '@typings/database/models/servers/my_channel';
import type MyTeamModel from '@typings/database/models/servers/my_team';
import type SystemModel from '@typings/database/models/servers/system';
const {SERVER: {MY_TEAM, SYSTEM}} = MM_TABLES;
const {SERVER: {MY_CHANNEL, MY_TEAM, SYSTEM}} = MM_TABLES;
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({
currentTeamId: database.get<SystemModel>(SYSTEM).findAndObserve(SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID).pipe(
@ -22,6 +23,7 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({
catchError(() => of$(undefined)),
),
teamsCount: database.get<MyTeamModel>(MY_TEAM).query().observeCount(),
channelsCount: database.get<MyChannelModel>(MY_CHANNEL).query().observeCount(),
}));
export default withDatabase(enhanced(ChannelsList));

View file

@ -39,7 +39,7 @@
"camera_type.video.option": "Record Video",
"center_panel.archived.closeChannel": "Close Channel",
"channel": "{count, plural, one {# member} other {# members}}",
"channel_header.directchannel.you": "{displayname} (you)",
"channel_header.directchannel.you": "{displayName} (you)",
"channel_loader.someone": "Someone",
"channel_modal.optional": "(optional)",
"combined_system_message.added_to_channel.many_expanded": "{users} and {lastUser} were **added to the channel** by {actor}.",
@ -150,6 +150,8 @@
"last_users_message.others": "{numOthers} others ",
"last_users_message.removed_from_channel.type": "were **removed from the channel**.",
"last_users_message.removed_from_team.type": "were **removed from the team**.",
"load_categories_error.message": "There was a problem loading content for this server.",
"load_categories_error.title": "Couldn't load categories for {serverName}",
"load_channels_error.message": "There was a problem loading content for this team.",
"load_channels_error.title": "Couldn't load {teamDisplayName}",
"load_teams_error.message": "There was a problem loading content for this server.",

View file

@ -162,6 +162,7 @@
"build:ios-sim": "./scripts/build.sh ipa simulator",
"build:ios-unsigned": "./scripts/build.sh ipa unsigned",
"check": "npm run lint && npm run tsc",
"check-test": "npm run lint && npm run tsc && npm run test",
"clean": "./scripts/clean.sh",
"e2e:android": "cd detox && npm run e2e:android-build && npm run e2e:android-test && cd ..",
"e2e:ios": "cd detox && npm run e2e:ios-test && cd ..",

View file

@ -3,6 +3,7 @@
import assert from 'assert';
import {random} from 'lodash';
import nock from 'nock';
import Config from '@assets/config.json';
@ -22,8 +23,11 @@ class TestHelper {
this.basicUser = null;
this.basicTeam = null;
this.basicTeamMember = null;
this.basicCategory = null;
this.basicCategoryChannel = null;
this.basicChannel = null;
this.basicChannelMember = null;
this.basicMyChannel = null;
this.basicPost = null;
this.basicRoles = null;
this.basicScheme = null;
@ -52,6 +56,25 @@ class TestHelper {
prepareRecordsOnly: false,
});
// Add a category and associated channel entities
await operator.handleCategories({
categories: [this.basicCategory],
prepareRecordsOnly: false,
});
await operator.handleCategoryChannels({
categoryChannels: [this.basicCategoryChannel],
prepareRecordsOnly: false,
});
await operator.handleChannel({
channels: [this.basicChannel],
prepareRecordsOnly: false,
});
await operator.handleMyChannel({
prepareRecordsOnly: false,
channels: [this.basicChannel],
myChannels: [this.basicMyChannel],
});
const systems = await prepareCommonSystemValues(operator, {
config: {},
license: {},
@ -93,13 +116,53 @@ class TestHelper {
return new Client(mockApiClient, mockApiClient.baseUrl);
};
fakeCategory = (teamId) => {
return {
display_name: 'Test Category',
type: 'custom',
sort_order: 0,
sorting: 'manual',
muted: false,
collapsed: false,
team_id: teamId,
};
};
fakeCategoryWithId = (teamId) => {
return {
...this.fakeCategory(teamId),
id: this.generateId(),
};
};
fakeCategoryChannel = (categoryId, channelId) => {
return {
category_id: categoryId,
channel_id: channelId,
sort_order: random(0, 10, false),
};
};
fakeCategoryChannelWithId = (teamId, categoryId, channelId) => {
return {
id: teamId + channelId,
category_id: categoryId,
channel_id: channelId,
sort_order: random(0, 10, false),
};
};
fakeChannel = (teamId) => {
const name = this.generateId();
return {
name,
team_id: teamId,
display_name: `Unit Test ${name}`,
// @to-do: Make tests more detriministic;
// https://jestjs.io/docs/snapshot-testing#2-tests-should-be-deterministic
// display_name: `Unit Test ${name}`,
display_name: 'Channel',
type: 'O',
delete_at: 0,
total_msg_count: 0,
@ -144,6 +207,21 @@ class TestHelper {
};
};
fakeMyChannel = (channelId) => {
return {
id: channelId,
channel_id: channelId,
last_post_at: 0,
last_viewed_at: 0,
manually_unread: false,
mentions_count: 0,
message_count: 0,
is_unread: false,
roles: '',
viewed_at: 0,
};
};
fakeEmail = () => {
return 'success' + this.generateId() + '@simulator.amazonses.com';
};
@ -318,8 +396,11 @@ class TestHelper {
this.basicUser.roles = 'system_user system_admin';
this.basicTeam = this.fakeTeamWithId();
this.basicTeamMember = this.fakeTeamMember(this.basicUser.id, this.basicTeam.id);
this.basicCategory = this.fakeCategoryWithId(this.basicTeam.id);
this.basicChannel = this.fakeChannelWithId(this.basicTeam.id);
this.basicCategoryChannel = this.fakeCategoryChannelWithId(this.basicTeam.id, this.basicCategory.id, this.basicChannel.id);
this.basicChannelMember = this.fakeChannelMember(this.basicUser.id, this.basicChannel.id);
this.basicMyChannel = this.fakeMyChannel(this.basicChannel.id);
this.basicPost = {...this.fakePostWithId(this.basicChannel.id), create_at: 1507841118796};
this.basicRoles = {
system_admin: {