diff --git a/app/components/channel_loader/__snapshots__/channel_loader.test.js.snap b/app/components/channel_loader/__snapshots__/channel_loader.test.js.snap index 37bf39c57..8f1d0b9f8 100644 --- a/app/components/channel_loader/__snapshots__/channel_loader.test.js.snap +++ b/app/components/channel_loader/__snapshots__/channel_loader.test.js.snap @@ -33,7 +33,7 @@ exports[`ChannelLoader should match snapshot 1`] = ` ] } > - - - - - + - - - - - + - - - - - + - - - - - + - - - - - + - - - - - + - - - - - + - - - - - + - - - - - + - - - - - + - - - - - + - - - - - + - - - - - + `; diff --git a/app/components/channel_loader/channel_loader.test.js b/app/components/channel_loader/channel_loader.test.js index df58d8773..327243c33 100644 --- a/app/components/channel_loader/channel_loader.test.js +++ b/app/components/channel_loader/channel_loader.test.js @@ -8,10 +8,6 @@ import Preferences from '@mm-redux/constants/preferences'; import ChannelLoader from './channel_loader'; -jest.mock('rn-placeholder', () => ({ - ImageContent: () => null, -})); - describe('ChannelLoader', () => { const baseProps = { channelIsLoading: true, diff --git a/app/components/post_draft/post_draft.js b/app/components/post_draft/post_draft.js index 66d43cfe5..b00e7791e 100644 --- a/app/components/post_draft/post_draft.js +++ b/app/components/post_draft/post_draft.js @@ -29,6 +29,7 @@ const AUTOCOMPLETE_MAX_HEIGHT = 200; export default class PostDraft extends PureComponent { static propTypes = { + registerTypingAnimation: PropTypes.func.isRequired, addReactionToLatestPost: PropTypes.func.isRequired, canPost: PropTypes.bool.isRequired, channelDisplayName: PropTypes.string, @@ -513,13 +514,17 @@ export default class PostDraft extends PureComponent { maxMessageLength, screenId, valueEvent, + registerTypingAnimation, } = this.props; const style = getStyleSheet(theme); const readonly = channelIsReadOnly || !canPost; return ( <> - + {Platform.OS === 'android' && { - const [height, duration] = show ? - [20, 200] : + componentWillUnmount() { + this.removeTypingAnimation(); + } + + typingAnimation = (visible = false) => { + const [bottom, duration] = visible ? + [TYPING_HEIGHT, 200] : [0, 400]; - Animated.timing(this.state.typingHeight, { - toValue: height, + return Animated.timing(this.typingBottom, { + toValue: bottom, duration, useNativeDriver: false, - }).start(); + }); } renderTyping = () => { @@ -85,15 +94,21 @@ export default class Typing extends PureComponent { const style = getStyleSheet(this.props.theme); return ( - - + - {this.renderTyping()} - - + + {this.renderTyping()} + + + ); } } @@ -101,6 +116,7 @@ export default class Typing extends PureComponent { const getStyleSheet = makeStyleSheetFromTheme((theme) => { return { typing: { + position: 'absolute', paddingLeft: 10, paddingTop: 3, fontSize: 11, diff --git a/app/components/post_draft/typing/typing.test.js b/app/components/post_draft/typing/typing.test.js index 169b89e23..4c6a0bcfb 100644 --- a/app/components/post_draft/typing/typing.test.js +++ b/app/components/post_draft/typing/typing.test.js @@ -2,10 +2,11 @@ // See LICENSE.txt for license information. import React from 'react'; import {shallow} from 'enzyme'; -import { - Animated, -} from 'react-native'; -const {View: AnimatedView} = Animated; +import {Animated} from 'react-native'; + +import EventEmitter from '@mm-redux/utils/event_emitter'; + +import {TYPING_VISIBLE} from '@constants/post_draft'; import Typing from './typing'; @@ -15,17 +16,35 @@ describe('Typing', () => { theme: { centerChannelColor: 'blue', }, + registerTypingAnimation: jest.fn(() => { + return jest.fn(); + }), }; + EventEmitter.emit = jest.fn(); + test('should render component without error', () => { const wrapper = shallow( , ); - expect(wrapper.find(AnimatedView).exists()).toBe(true); + expect(wrapper.find(Animated.View).exists()).toBe(true); }); - test('should call animateTyping when next typing props is not empty and current is empty', () => { + test('should not emit TYPING_VISIBLE when typing props is not empty and previous is not empty', () => { + const props = { + ...baseProps, + typing: ['user2'], + }; + const wrapper = shallow( + , + ); + + wrapper.setProps({typing: ['user2 and user3']}); + expect(EventEmitter.emit).not.toHaveBeenCalled(); + }); + + test('should emit TYPING_VISIBLE with true when typing props is not empty and previous is empty', () => { const props = { ...baseProps, typing: [], @@ -33,22 +52,30 @@ describe('Typing', () => { const wrapper = shallow( , ); - wrapper.instance().animateTyping = jest.fn(); wrapper.setProps({typing: ['user2']}); - expect(wrapper.instance().animateTyping).toHaveBeenCalledWith(true); + expect(EventEmitter.emit).toHaveBeenCalledWith(TYPING_VISIBLE, true); }); - test('should call animateTyping when next typing props is not empty', () => { - const props = { - ...baseProps, - }; + test('should emit TYPING_VISIBLE with false when typing props is empty', () => { const wrapper = shallow( - , + , ); - wrapper.instance().animateTyping = jest.fn(); wrapper.setProps({typing: []}); - expect(wrapper.instance().animateTyping).toHaveBeenCalledWith(); + expect(EventEmitter.emit).toHaveBeenCalledWith(TYPING_VISIBLE, false); + }); + + test('should add/remove typing animation on mount/unmount', () => { + const wrapper = shallow( + , + ); + const instance = wrapper.instance(); + + expect(baseProps.registerTypingAnimation).toHaveBeenCalledTimes(1); + expect(instance.removeTypingAnimation).not.toHaveBeenCalled(); + + wrapper.unmount(); + expect(instance.removeTypingAnimation).toHaveBeenCalledTimes(1); }); }); diff --git a/app/constants/post_draft.js b/app/constants/post_draft.js index b1e8cfb9b..e8ecfc948 100644 --- a/app/constants/post_draft.js +++ b/app/constants/post_draft.js @@ -10,3 +10,5 @@ export const INSERT_TO_DRAFT = 'insert_to_draft'; export const IS_REACTION_REGEX = /(^\+:([^:\s]*):)$/i; export const MAX_FILE_COUNT = 5; export const MAX_MESSAGE_LENGTH_FALLBACK = 4000; +export const TYPING_VISIBLE = 'typingVisible'; +export const TYPING_HEIGHT = 18; diff --git a/app/screens/channel/channel.android.js b/app/screens/channel/channel.android.js index 7eccd0eb8..9f27850f5 100644 --- a/app/screens/channel/channel.android.js +++ b/app/screens/channel/channel.android.js @@ -67,11 +67,12 @@ export default class ChannelAndroid extends ChannelBase { component = ( - + ); diff --git a/app/screens/channel/channel.ios.js b/app/screens/channel/channel.ios.js index 04f1c280a..81d325206 100644 --- a/app/screens/channel/channel.ios.js +++ b/app/screens/channel/channel.ios.js @@ -63,6 +63,7 @@ export default class ChannelIOS extends ChannelBase { <> } diff --git a/app/screens/channel/channel_base.js b/app/screens/channel/channel_base.js index 4593f0684..62b814a67 100644 --- a/app/screens/channel/channel_base.js +++ b/app/screens/channel/channel_base.js @@ -4,12 +4,13 @@ import React, {PureComponent} from 'react'; import PropTypes from 'prop-types'; import {intlShape} from 'react-intl'; -import {Keyboard, StyleSheet} from 'react-native'; +import {Animated, Keyboard, StyleSheet} from 'react-native'; import MaterialIcon from 'react-native-vector-icons/MaterialIcons'; import {showModal, showModalOverCurrentContext} from '@actions/navigation'; import LocalConfig from '@assets/config'; import {NavigationTypes} from '@constants'; +import {TYPING_VISIBLE} from '@constants/post_draft'; import EventEmitter from '@mm-redux/utils/event_emitter'; import EphemeralStore from '@store/ephemeral_store'; import {unsupportedServer} from '@utils/supported_server'; @@ -65,6 +66,8 @@ export default class ChannelBase extends PureComponent { if (LocalConfig.EnableMobileClientUpgrade && !ClientUpgradeListener) { ClientUpgradeListener = require('app/components/client_upgrade_listener').default; } + + this.typingAnimations = []; } componentDidMount() { @@ -80,6 +83,7 @@ export default class ChannelBase extends PureComponent { } = this.props; EventEmitter.on(NavigationTypes.BLUR_POST_DRAFT, this.blurPostDraft); EventEmitter.on('leave_team', this.handleLeaveTeam); + EventEmitter.on(TYPING_VISIBLE, this.runTypingAnimations); if (currentTeamId) { this.loadChannels(currentTeamId); @@ -147,6 +151,23 @@ export default class ChannelBase extends PureComponent { componentWillUnmount() { EventEmitter.off(NavigationTypes.BLUR_POST_DRAFT, this.blurPostDraft); EventEmitter.off('leave_team', this.handleLeaveTeam); + EventEmitter.off(TYPING_VISIBLE, this.runTypingAnimations); + } + + registerTypingAnimation = (animation) => { + const length = this.typingAnimations.push(animation); + const removeAnimation = () => { + const animationIndex = length - 1; + this.typingAnimations = this.typingAnimations.filter((a, index) => index !== animationIndex); + }; + + return removeAnimation; + } + + runTypingAnimations = (typingVisible) => { + Animated.parallel( + this.typingAnimations.map((animation) => animation(typingVisible)), + ).start(); } blurPostDraft = () => { diff --git a/app/screens/channel/channel_base.test.js b/app/screens/channel/channel_base.test.js index a1787068f..f6feb8e7e 100644 --- a/app/screens/channel/channel_base.test.js +++ b/app/screens/channel/channel_base.test.js @@ -8,6 +8,7 @@ import Preferences from '@mm-redux/constants/preferences'; import EphemeralStore from 'app/store/ephemeral_store'; import * as NavigationActions from 'app/actions/navigation'; +import {emptyFunction} from '@utils/general'; import ChannelBase from './channel_base'; @@ -79,4 +80,20 @@ describe('ChannelBase', () => { [componentIds[0], newThemeOptions], ]); }); + + test('registerTypingAnimation should return a callback that removes the typing animation', () => { + const wrapper = shallow( + , + {context: {intl: {formatMessage: jest.fn()}}}, + ); + + const instance = wrapper.instance(); + expect(instance.typingAnimations).toStrictEqual([]); + + const removeAnimation = instance.registerTypingAnimation(emptyFunction); + expect(instance.typingAnimations).toStrictEqual([emptyFunction]); + + removeAnimation(); + expect(instance.typingAnimations).toStrictEqual([]); + }); }); diff --git a/app/screens/channel/channel_post_list/__snapshots__/channel_post_list.test.js.snap b/app/screens/channel/channel_post_list/__snapshots__/channel_post_list.test.js.snap new file mode 100644 index 000000000..db33616c3 --- /dev/null +++ b/app/screens/channel/channel_post_list/__snapshots__/channel_post_list.test.js.snap @@ -0,0 +1,42 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ChannelPostList should match snapshot 1`] = ` + + + + + + +`; diff --git a/app/screens/channel/channel_post_list/channel_post_list.js b/app/screens/channel/channel_post_list/channel_post_list.js index 813f41bfa..2e6782ee0 100644 --- a/app/screens/channel/channel_post_list/channel_post_list.js +++ b/app/screens/channel/channel_post_list/channel_post_list.js @@ -7,6 +7,7 @@ import { Keyboard, Platform, View, + Animated, } from 'react-native'; import {getLastPostIndex} from '@mm-redux/utils/post_list'; @@ -20,6 +21,8 @@ import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; import telemetry from 'app/telemetry'; import {goToScreen} from 'app/actions/navigation'; +import {TYPING_HEIGHT} from '@constants/post_draft'; + let ChannelIntro = null; let LoadMorePosts = null; @@ -43,6 +46,7 @@ export default class ChannelPostList extends PureComponent { refreshing: PropTypes.bool.isRequired, theme: PropTypes.object.isRequired, updateNativeScrollView: PropTypes.func, + registerTypingAnimation: PropTypes.func.isRequired, }; static defaultProps = { @@ -56,10 +60,13 @@ export default class ChannelPostList extends PureComponent { this.isLoadingMoreBottom = false; this.isLoadingMoreTop = false; + + this.bottomPadding = new Animated.Value(0); } componentDidMount() { EventEmitter.on('goToThread', this.goToThread); + this.removeTypingAnimation = this.props.registerTypingAnimation(this.bottomPaddingAnimation); } componentDidUpdate(prevProps) { @@ -78,11 +85,24 @@ export default class ChannelPostList extends PureComponent { componentWillUnmount() { EventEmitter.off('goToThread', this.goToThread); + this.removeTypingAnimation(); + } + + bottomPaddingAnimation = (visible) => { + const [padding, duration] = visible ? + [TYPING_HEIGHT, 200] : + [0, 400]; + + return Animated.timing(this.bottomPadding, { + toValue: padding, + duration, + useNativeDriver: false, + }); } goToThread = (post) => { telemetry.start(['post_list:thread']); - const {actions, channelId} = this.props; + const {actions, channelId, registerTypingAnimation} = this.props; const rootId = (post.root_id || post.id); Keyboard.dismiss(); @@ -94,6 +114,7 @@ export default class ChannelPostList extends PureComponent { const passProps = { channelId, rootId, + registerTypingAnimation, }; requestAnimationFrame(() => { @@ -198,12 +219,12 @@ export default class ChannelPostList extends PureComponent { const style = getStyleSheet(theme); return ( - + {component} - + ); } } diff --git a/app/screens/channel/channel_post_list/channel_post_list.test.js b/app/screens/channel/channel_post_list/channel_post_list.test.js new file mode 100644 index 000000000..44d488824 --- /dev/null +++ b/app/screens/channel/channel_post_list/channel_post_list.test.js @@ -0,0 +1,49 @@ +// 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 ChannelPostList from './channel_post_list'; + +describe('ChannelPostList', () => { + const baseProps = { + actions: { + loadPostsIfNecessaryWithRetry: jest.fn(), + loadThreadIfNecessary: jest.fn(), + increasePostVisibility: jest.fn(), + selectPost: jest.fn(), + recordLoadTime: jest.fn(), + refreshChannelWithRetry: jest.fn(), + }, + channelId: 'channel-id', + loadMorePostsVisible: false, + refreshing: false, + theme: Preferences.THEMES.default, + registerTypingAnimation: jest.fn(() => { + return jest.fn(); + }), + }; + + test('should match snapshot', () => { + const wrapper = shallow( + , + ); + expect(wrapper.getElement()).toMatchSnapshot(); + }); + + test('should add/remove typing animation on mount/unmount', () => { + const wrapper = shallow( + , + ); + const instance = wrapper.instance(); + + expect(baseProps.registerTypingAnimation).toHaveBeenCalledTimes(1); + expect(instance.removeTypingAnimation).not.toHaveBeenCalled(); + + wrapper.unmount(); + expect(instance.removeTypingAnimation).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/screens/flagged_posts/flagged_posts.test.js b/app/screens/flagged_posts/flagged_posts.test.js index 1457816eb..39d7bc0aa 100644 --- a/app/screens/flagged_posts/flagged_posts.test.js +++ b/app/screens/flagged_posts/flagged_posts.test.js @@ -10,10 +10,6 @@ import {shallowWithIntl} from 'test/intl-test-helper'; import FlaggedPosts from './flagged_posts'; -jest.mock('rn-placeholder', () => ({ - ImageContent: () => null, -})); - describe('FlaggedPosts', () => { const baseProps = { actions: { diff --git a/app/screens/pinned_posts/pinned_posts.test.js b/app/screens/pinned_posts/pinned_posts.test.js index b2038f4e9..d61ca24ce 100644 --- a/app/screens/pinned_posts/pinned_posts.test.js +++ b/app/screens/pinned_posts/pinned_posts.test.js @@ -9,10 +9,6 @@ import {shallowWithIntl} from 'test/intl-test-helper'; import PinnedPosts from './pinned_posts'; -jest.mock('rn-placeholder', () => ({ - ImageContent: () => null, -})); - describe('PinnedPosts', () => { const baseProps = { actions: { diff --git a/app/screens/recent_mentions/recent_mentions.test.js b/app/screens/recent_mentions/recent_mentions.test.js index 1724a9610..cf82df5b7 100644 --- a/app/screens/recent_mentions/recent_mentions.test.js +++ b/app/screens/recent_mentions/recent_mentions.test.js @@ -10,10 +10,6 @@ import {shallowWithIntl} from 'test/intl-test-helper'; import RecentMentions from './recent_mentions'; -jest.mock('rn-placeholder', () => ({ - ImageContent: () => null, -})); - describe('RecentMentions', () => { const baseProps = { actions: { diff --git a/app/screens/thread/__snapshots__/thread.ios.test.js.snap b/app/screens/thread/__snapshots__/thread.ios.test.js.snap index d0f70c3f1..54bc86d92 100644 --- a/app/screens/thread/__snapshots__/thread.ios.test.js.snap +++ b/app/screens/thread/__snapshots__/thread.ios.test.js.snap @@ -16,29 +16,38 @@ exports[`thread should match snapshot, has root post 1`] = ` /> - - } - scrollViewNativeID="threadPostList" - /> + > + + } + scrollViewNativeID="threadPostList" + /> + @@ -60,6 +69,21 @@ exports[`thread should match snapshot, has root post 1`] = ` channelId="channel_id" channelIsArchived={false} cursorPositionEvent="onThreadTextBoxCursorChange" + registerTypingAnimation={ + [MockFunction] { + "calls": Array [ + Array [ + [Function], + ], + ], + "results": Array [ + Object { + "type": "return", + "value": [MockFunction], + }, + ], + } + } rootId="root_id" valueEvent="onThreadTextBoxValueChange" /> diff --git a/app/screens/thread/thread.android.js b/app/screens/thread/thread.android.js index bb0611af2..fbea95765 100644 --- a/app/screens/thread/thread.android.js +++ b/app/screens/thread/thread.android.js @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import React from 'react'; -import {View} from 'react-native'; +import {Animated, View} from 'react-native'; import KeyboardLayout from '@components/layout/keyboard_layout'; import Loading from '@components/loading'; @@ -24,28 +24,32 @@ export default class ThreadAndroid extends ThreadBase { rootId, channelIsArchived, theme, + registerTypingAnimation, } = this.props; let content; if (this.hasRootPost()) { content = ( <> - + + + ); diff --git a/app/screens/thread/thread.ios.js b/app/screens/thread/thread.ios.js index 6d7ad2ec1..61124d741 100644 --- a/app/screens/thread/thread.ios.js +++ b/app/screens/thread/thread.ios.js @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import React from 'react'; -import {View} from 'react-native'; +import {Animated, View} from 'react-native'; import {KeyboardTrackingView} from 'react-native-keyboard-tracking-view'; import Autocomplete, {AUTOCOMPLETE_MAX_HEIGHT} from '@components/autocomplete'; @@ -37,6 +37,7 @@ export default class ThreadIOS extends ThreadBase { rootId, channelIsArchived, theme, + registerTypingAnimation, } = this.props; let content; @@ -44,17 +45,19 @@ export default class ThreadIOS extends ThreadBase { if (this.hasRootPost()) { content = ( <> - + + + ); diff --git a/app/screens/thread/thread.ios.test.js b/app/screens/thread/thread.ios.test.js index d2503ee78..5ca2440bd 100644 --- a/app/screens/thread/thread.ios.test.js +++ b/app/screens/thread/thread.ios.test.js @@ -30,6 +30,9 @@ describe('thread', () => { postIds: ['root_id', 'post_id_1', 'post_id_2'], channelIsArchived: false, threadLoadingStatus: {status: RequestStatus.STARTED}, + registerTypingAnimation: jest.fn(() => { + return jest.fn(); + }), }; test('should match snapshot, has root post', () => { @@ -70,4 +73,18 @@ describe('thread', () => { wrapper.setProps({postIds: newPostIds}); expect(wrapper.getElement()).toMatchSnapshot(); }); + + test('should add/remove typing animation on mount/unmount', () => { + const wrapper = shallow( + , + {context: {intl: {formatMessage: jest.fn()}}}, + ); + const instance = wrapper.instance(); + + expect(baseProps.registerTypingAnimation).toHaveBeenCalledTimes(1); + expect(instance.removeTypingAnimation).not.toHaveBeenCalled(); + + wrapper.unmount(); + expect(instance.removeTypingAnimation).toHaveBeenCalledTimes(1); + }); }); diff --git a/app/screens/thread/thread_base.js b/app/screens/thread/thread_base.js index 5d2b67bb9..c79b48c17 100644 --- a/app/screens/thread/thread_base.js +++ b/app/screens/thread/thread_base.js @@ -3,7 +3,7 @@ import React, {PureComponent} from 'react'; import PropTypes from 'prop-types'; -import {Keyboard} from 'react-native'; +import {Animated, Keyboard} from 'react-native'; import {intlShape} from 'react-intl'; import {General, RequestStatus} from '@mm-redux/constants'; @@ -11,6 +11,7 @@ import {General, RequestStatus} from '@mm-redux/constants'; import Loading from 'app/components/loading'; import DeletedPost from 'app/components/deleted_post'; import {popTopScreen, mergeNavigationOptions} from 'app/actions/navigation'; +import {TYPING_HEIGHT} from '@constants/post_draft'; export default class ThreadBase extends PureComponent { static propTypes = { @@ -25,6 +26,7 @@ export default class ThreadBase extends PureComponent { rootId: PropTypes.string.isRequired, theme: PropTypes.object.isRequired, postIds: PropTypes.array.isRequired, + registerTypingAnimation: PropTypes.func.isRequired, channelIsArchived: PropTypes.bool, threadLoadingStatus: PropTypes.object, }; @@ -64,6 +66,12 @@ export default class ThreadBase extends PureComponent { this.state = { lastViewedAt: props.myMember && props.myMember.last_viewed_at, }; + + this.bottomPadding = new Animated.Value(0); + } + + componentDidMount() { + this.removeTypingAnimation = this.props.registerTypingAnimation(this.bottomPaddingAnimation); } componentWillReceiveProps(nextProps) { @@ -79,6 +87,7 @@ export default class ThreadBase extends PureComponent { componentWillUnmount() { this.props.actions.selectPost(''); + this.removeTypingAnimation(); } close = () => { @@ -109,4 +118,16 @@ export default class ThreadBase extends PureComponent { return null; }; + + bottomPaddingAnimation = (visible) => { + const [padding, duration] = visible ? + [TYPING_HEIGHT, 200] : + [0, 400]; + + return Animated.timing(this.bottomPadding, { + toValue: padding, + duration, + useNativeDriver: false, + }); + } }