From 1a75eed6bc94b3296c9bf5b0ea64c419504247e9 Mon Sep 17 00:00:00 2001 From: Ashish Bhate Date: Mon, 11 Jan 2021 16:09:13 +0530 Subject: [PATCH] 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 --- .../__snapshots__/avatars.test.tsx.snap | 155 ++++++++++++++++++ app/components/avatars/avatars.stories.tsx | 25 +++ app/components/avatars/avatars.test.tsx | 31 ++++ app/components/avatars/avatars.tsx | 129 +++++++++++++++ app/components/avatars/index.ts | 15 ++ app/constants/view.js | 1 + app/screens/index.js | 2 +- storybook/mattermost_storybook.ts | 4 +- storybook/storyLoader.js | 6 +- 9 files changed, 365 insertions(+), 3 deletions(-) create mode 100644 app/components/avatars/__snapshots__/avatars.test.tsx.snap create mode 100644 app/components/avatars/avatars.stories.tsx create mode 100644 app/components/avatars/avatars.test.tsx create mode 100644 app/components/avatars/avatars.tsx create mode 100644 app/components/avatars/index.ts 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,