MM-29104: Add avatars component (#5001)

Summary
Adds an avatar list component. It is required for the new collapsed reply threads feature. The feature design is at https://www.figma.com/file/t2KIwZnxb7h94249k6XdOp/MM-18107-Mobile-Threads-Overhaul and https://mattermost.atlassian.net/wiki/spaces/Threads/pages/1003945985/Mobile+User+Experience

Ticket Link
https://mattermost.atlassian.net/browse/MM-29104
This commit is contained in:
Ashish Bhate 2021-01-11 16:09:13 +05:30 committed by GitHub
parent 2fad44fae0
commit 1a75eed6bc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 365 additions and 3 deletions

View file

@ -0,0 +1,155 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Avatars should match snapshot for overflow 1`] = `
<TouchableWithFeedbackIOS
type="native"
>
<View
style={
Object {
"flexDirection": "row",
}
}
>
<View
style={
Object {
"alignItems": "center",
"backgroundColor": "#ffffff",
"borderColor": "#ffffff",
"borderRadius": 12,
"borderWidth": 13,
"height": 24,
"justifyContent": "center",
"width": 24,
}
}
>
<Connect(ProfilePicture)
showStatus={false}
size={24}
userId="user1"
/>
</View>
<View
style={
Object {
"alignItems": "center",
"backgroundColor": "#ffffff",
"borderColor": "#ffffff",
"borderRadius": 12,
"borderWidth": 13,
"height": 24,
"justifyContent": "center",
"marginLeft": -6,
"width": 24,
}
}
>
<Connect(ProfilePicture)
showStatus={false}
size={24}
userId="user2"
/>
</View>
<View
style={
Object {
"alignItems": "center",
"backgroundColor": "#ffffff",
"borderColor": "#ffffff",
"borderRadius": 12,
"borderWidth": 13,
"height": 24,
"justifyContent": "center",
"marginLeft": -6,
"width": 24,
}
}
>
<Connect(ProfilePicture)
showStatus={false}
size={24}
userId="user3"
/>
</View>
<View
style={
Object {
"alignItems": "center",
"backgroundColor": "#ffffff",
"borderColor": "#ffffff",
"borderRadius": 13.5,
"borderWidth": 1,
"height": 27,
"justifyContent": "center",
"marginLeft": -6,
"width": 27,
}
}
>
<View
style={
Object {
"alignItems": "center",
"backgroundColor": "rgba(61,60,64,0.08)",
"borderColor": "#ffffff",
"borderRadius": 13.5,
"borderWidth": 1,
"height": 27,
"justifyContent": "center",
"width": 27,
}
}
>
<Text
style={
Object {
"color": "rgba(61,60,64,0.64)",
"fontSize": 10,
"textAlign": "center",
}
}
>
+3
</Text>
</View>
</View>
</View>
</TouchableWithFeedbackIOS>
`;
exports[`Avatars should match snapshot for single avatar 1`] = `
<TouchableWithFeedbackIOS
type="native"
>
<View
style={
Object {
"flexDirection": "row",
}
}
>
<View
style={
Object {
"alignItems": "center",
"backgroundColor": "#ffffff",
"borderColor": "#ffffff",
"borderRadius": 12,
"borderWidth": 13,
"height": 24,
"justifyContent": "center",
"width": 24,
}
}
>
<Connect(ProfilePicture)
showStatus={false}
size={24}
userId="user1"
/>
</View>
</View>
</TouchableWithFeedbackIOS>
`;

View file

@ -0,0 +1,25 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {storiesOf} from '@storybook/react-native';
import {withKnobs, number, select} from '@storybook/addon-knobs';
import {Preferences} from '@mm-redux/constants';
import {UserProfile} from '@mm-redux/types/users';
import {getProfiles} from '@mm-redux/selectors/entities/users';
import Store from '@store/store';
import Avatars from './avatars';
const state = Store.redux?.getState();
const users = getProfiles(state, {});
const userIds = users.map((user:UserProfile) => user.id);
storiesOf('Avatars', module).
addDecorator(withKnobs).
add('Avatars', () => (
<Avatars
userIds={userIds.slice(0, number(`number of named participants (max ${userIds.length})`, userIds.length))}
theme={select('theme', Preferences.THEMES, Preferences.THEMES.default)}
/>
));

View file

@ -0,0 +1,31 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import Preferences from '@mm-redux/constants/preferences';
import Avatars from './avatars';
describe('Avatars', () => {
test('should match snapshot for single avatar', () => {
const baseProps = {
theme: Preferences.THEMES.default,
userIds: ['user1'],
};
const wrapper = shallow(<Avatars {...baseProps}/>);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should match snapshot for overflow', () => {
const baseProps = {
theme: Preferences.THEMES.default,
userIds: ['user1', 'user2', 'user3', 'user4', 'user5', 'user6'],
};
const wrapper = shallow(<Avatars {...baseProps}/>);
expect(wrapper.getElement()).toMatchSnapshot();
});
});

View file

@ -0,0 +1,129 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {PureComponent} from 'react';
import {Platform, Text, View} from 'react-native';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import ProfilePicture from '@components/profile_picture';
import {ViewTypes} from '@constants';
import type {Theme} from '@mm-redux/types/preferences';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
const size = ViewTypes.AVATAR_LIST_PICTURE_SIZE;
// compensate for the status buffer that is not rendered (but still padded)
// by the ProfilePicture Component
let STATUS_BUFFER = Platform.select({
ios: 3,
android: 2,
});
STATUS_BUFFER = STATUS_BUFFER || 0;
const overflowSize = size + STATUS_BUFFER;
const imgOverlap = -6;
return {
container: {
flexDirection: 'row',
},
firstAvatar: {
justifyContent: 'center',
alignItems: 'center',
width: size,
height: size,
borderWidth: (size / 2) + 1,
borderColor: theme.centerChannelBg,
backgroundColor: theme.centerChannelBg,
borderRadius: size / 2,
},
notFirstAvatars: {
justifyContent: 'center',
alignItems: 'center',
width: size,
height: size,
borderWidth: (size / 2) + 1,
borderColor: theme.centerChannelBg,
backgroundColor: theme.centerChannelBg,
borderRadius: size / 2,
marginLeft: imgOverlap,
},
overflowContainer: {
justifyContent: 'center',
alignItems: 'center',
width: overflowSize,
height: overflowSize,
borderRadius: overflowSize / 2,
borderWidth: 1,
borderColor: theme.centerChannelBg,
backgroundColor: theme.centerChannelBg,
marginLeft: imgOverlap,
},
overflowItem: {
justifyContent: 'center',
alignItems: 'center',
width: overflowSize,
height: overflowSize,
borderRadius: overflowSize / 2,
borderWidth: 1,
borderColor: theme.centerChannelBg,
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
},
overflowText: {
fontSize: 10,
color: changeOpacity(theme.centerChannelColor, 0.64),
textAlign: 'center',
},
};
});
const OVERFLOW_DISPLAY_LIMIT = 99;
interface AvatarsProps {
userIds: string[];
breakAt?: number;
theme: Theme;
}
export default class Avatars extends PureComponent<AvatarsProps> {
static defaultProps = {
breakAt: 3,
};
render() {
const {userIds, breakAt, theme} = this.props;
const displayUserIds = userIds.slice(0, breakAt);
const overflowUsersCount = Math.min(userIds.length - displayUserIds.length, OVERFLOW_DISPLAY_LIMIT);
const style = getStyleSheet(theme);
return (
<TouchableWithFeedback>
<View style={style.container}>
{displayUserIds.map((userId: string, i: number) => (
<View
key={'participants' + userId}
style={i === 0 ? style.firstAvatar : style.notFirstAvatars}
>
<ProfilePicture
userId={userId}
size={ViewTypes.AVATAR_LIST_PICTURE_SIZE}
showStatus={false}
/>
</View>
))}
{Boolean(overflowUsersCount) && (
<View style={style.overflowContainer}>
<View style={style.overflowItem}>
<Text style={style.overflowText} >
{'+' + overflowUsersCount.toString()}
</Text>
</View>
</View>
)}
</View>
</TouchableWithFeedback>
);
}
}

View file

@ -0,0 +1,15 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {getTheme} from '@mm-redux/selectors/entities/preferences';
import Avatars from './avatars';
import type {GlobalState} from '@mm-redux/types/store';
function mapStateToProps(state: GlobalState) {
return {
theme: getTheme(state),
};
}
export default connect(mapStateToProps)(Avatars);

View file

@ -139,4 +139,5 @@ export default {
SidebarSectionTypes,
IOS_HORIZONTAL_LANDSCAPE: 44,
INDICATOR_BAR_HEIGHT,
AVATAR_LIST_PICTURE_SIZE: 24,
};

View file

@ -21,7 +21,7 @@ const withGestures = (screen, styles) => {
};
// eslint-disable-next-line react/display-name
const withReduxProvider = (Screen, excludeEvents = true) => (props) => (
export const withReduxProvider = (Screen, excludeEvents = true) => (props) => (
<Provider store={store}>
<ThemeProvider>
<RootWrapper excludeEvents={excludeEvents}>

View file

@ -2,7 +2,9 @@
// See LICENSE.txt for license information.
import {Navigation} from 'react-native-navigation';
import {goToScreen} from '@actions/navigation';
import {withReduxProvider} from '@screens';
import DevMenu from 'react-native-dev-menu';
DevMenu.addItem('StoryBook', () => goToScreen('StoryBook', 'StoryBook'));
Navigation.registerComponent('StoryBook', () => require('../storybook').default, () => require('../storybook').default);
Navigation.registerComponent('StoryBook', () => withReduxProvider(require('../storybook').default));

View file

@ -2,10 +2,14 @@
// See LICENSE.txt for license information.
function loadStories() {
require('../app/components/avatars/avatars.stories');
require('../app/components/loading.stories');
}
const stories = ['../app/components/loading.stories'];
const stories = [
'../app/components/avatars/avatars.stories',
'../app/components/loading.stories',
];
module.exports = {
loadStories,