diff --git a/app/components/avatars/__snapshots__/avatars.test.tsx.snap b/app/components/avatars/__snapshots__/avatars.test.tsx.snap
new file mode 100644
index 000000000..102a39e6a
--- /dev/null
+++ b/app/components/avatars/__snapshots__/avatars.test.tsx.snap
@@ -0,0 +1,155 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Avatars should match snapshot for overflow 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +3
+
+
+
+
+
+`;
+
+exports[`Avatars should match snapshot for single avatar 1`] = `
+
+
+
+
+
+
+
+`;
diff --git a/app/components/avatars/avatars.stories.tsx b/app/components/avatars/avatars.stories.tsx
new file mode 100644
index 000000000..0c3f3c2ec
--- /dev/null
+++ b/app/components/avatars/avatars.stories.tsx
@@ -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', () => (
+
+ ));
diff --git a/app/components/avatars/avatars.test.tsx b/app/components/avatars/avatars.test.tsx
new file mode 100644
index 000000000..cb274443a
--- /dev/null
+++ b/app/components/avatars/avatars.test.tsx
@@ -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();
+ 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();
+ expect(wrapper.getElement()).toMatchSnapshot();
+ });
+});
diff --git a/app/components/avatars/avatars.tsx b/app/components/avatars/avatars.tsx
new file mode 100644
index 000000000..b2af1c0d8
--- /dev/null
+++ b/app/components/avatars/avatars.tsx
@@ -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 {
+ 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 (
+
+
+ {displayUserIds.map((userId: string, i: number) => (
+
+
+
+ ))}
+ {Boolean(overflowUsersCount) && (
+
+
+
+ {'+' + overflowUsersCount.toString()}
+
+
+
+
+ )}
+
+
+ );
+ }
+}
+
diff --git a/app/components/avatars/index.ts b/app/components/avatars/index.ts
new file mode 100644
index 000000000..9409827f4
--- /dev/null
+++ b/app/components/avatars/index.ts
@@ -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);
diff --git a/app/constants/view.js b/app/constants/view.js
index 7282276b2..77a802b23 100644
--- a/app/constants/view.js
+++ b/app/constants/view.js
@@ -139,4 +139,5 @@ export default {
SidebarSectionTypes,
IOS_HORIZONTAL_LANDSCAPE: 44,
INDICATOR_BAR_HEIGHT,
+ AVATAR_LIST_PICTURE_SIZE: 24,
};
diff --git a/app/screens/index.js b/app/screens/index.js
index 28699605f..0444e7124 100644
--- a/app/screens/index.js
+++ b/app/screens/index.js
@@ -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) => (
diff --git a/storybook/mattermost_storybook.ts b/storybook/mattermost_storybook.ts
index 26e1cc7b0..deb6223a5 100644
--- a/storybook/mattermost_storybook.ts
+++ b/storybook/mattermost_storybook.ts
@@ -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));
diff --git a/storybook/storyLoader.js b/storybook/storyLoader.js
index 91dc83211..23e4bc779 100644
--- a/storybook/storyLoader.js
+++ b/storybook/storyLoader.js
@@ -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,