[MM-21155] Fix typing animations (#4373)

* Fix typing animations

* Remove animations on unmount

* Remove obsolete rn-placeholder mocks

* Apply thread animation for Android

* No need to call scrollToInitialIndexIfNeeded

* Fix snapshot test

* Add context
This commit is contained in:
Miguel Alatzar 2020-07-15 14:28:02 -07:00 committed by GitHub
parent 9c9aee69f4
commit 81dd01ba2c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 426 additions and 169 deletions

View file

@ -33,7 +33,7 @@ exports[`ChannelLoader should match snapshot 1`] = `
]
}
>
<UNDEFINED
<Unknown
Animation={[Function]}
Left={[Function]}
styles={
@ -44,18 +44,18 @@ exports[`ChannelLoader should match snapshot 1`] = `
}
}
>
<UNDEFINED
<Unknown
color="rgba(61,60,64,0.15)"
/>
<UNDEFINED
<Unknown
color="rgba(61,60,64,0.15)"
width={80}
/>
<UNDEFINED
<Unknown
color="rgba(61,60,64,0.15)"
width={60}
/>
</UNDEFINED>
</Unknown>
</View>
<View
style={
@ -73,7 +73,7 @@ exports[`ChannelLoader should match snapshot 1`] = `
]
}
>
<UNDEFINED
<Unknown
Animation={[Function]}
Left={[Function]}
styles={
@ -84,18 +84,18 @@ exports[`ChannelLoader should match snapshot 1`] = `
}
}
>
<UNDEFINED
<Unknown
color="rgba(61,60,64,0.15)"
/>
<UNDEFINED
<Unknown
color="rgba(61,60,64,0.15)"
width={80}
/>
<UNDEFINED
<Unknown
color="rgba(61,60,64,0.15)"
width={60}
/>
</UNDEFINED>
</Unknown>
</View>
<View
style={
@ -113,7 +113,7 @@ exports[`ChannelLoader should match snapshot 1`] = `
]
}
>
<UNDEFINED
<Unknown
Animation={[Function]}
Left={[Function]}
styles={
@ -124,18 +124,18 @@ exports[`ChannelLoader should match snapshot 1`] = `
}
}
>
<UNDEFINED
<Unknown
color="rgba(61,60,64,0.15)"
/>
<UNDEFINED
<Unknown
color="rgba(61,60,64,0.15)"
width={80}
/>
<UNDEFINED
<Unknown
color="rgba(61,60,64,0.15)"
width={60}
/>
</UNDEFINED>
</Unknown>
</View>
<View
style={
@ -153,7 +153,7 @@ exports[`ChannelLoader should match snapshot 1`] = `
]
}
>
<UNDEFINED
<Unknown
Animation={[Function]}
Left={[Function]}
styles={
@ -164,18 +164,18 @@ exports[`ChannelLoader should match snapshot 1`] = `
}
}
>
<UNDEFINED
<Unknown
color="rgba(61,60,64,0.15)"
/>
<UNDEFINED
<Unknown
color="rgba(61,60,64,0.15)"
width={80}
/>
<UNDEFINED
<Unknown
color="rgba(61,60,64,0.15)"
width={60}
/>
</UNDEFINED>
</Unknown>
</View>
<View
style={
@ -193,7 +193,7 @@ exports[`ChannelLoader should match snapshot 1`] = `
]
}
>
<UNDEFINED
<Unknown
Animation={[Function]}
Left={[Function]}
styles={
@ -204,18 +204,18 @@ exports[`ChannelLoader should match snapshot 1`] = `
}
}
>
<UNDEFINED
<Unknown
color="rgba(61,60,64,0.15)"
/>
<UNDEFINED
<Unknown
color="rgba(61,60,64,0.15)"
width={80}
/>
<UNDEFINED
<Unknown
color="rgba(61,60,64,0.15)"
width={60}
/>
</UNDEFINED>
</Unknown>
</View>
<View
style={
@ -233,7 +233,7 @@ exports[`ChannelLoader should match snapshot 1`] = `
]
}
>
<UNDEFINED
<Unknown
Animation={[Function]}
Left={[Function]}
styles={
@ -244,18 +244,18 @@ exports[`ChannelLoader should match snapshot 1`] = `
}
}
>
<UNDEFINED
<Unknown
color="rgba(61,60,64,0.15)"
/>
<UNDEFINED
<Unknown
color="rgba(61,60,64,0.15)"
width={80}
/>
<UNDEFINED
<Unknown
color="rgba(61,60,64,0.15)"
width={60}
/>
</UNDEFINED>
</Unknown>
</View>
<View
style={
@ -273,7 +273,7 @@ exports[`ChannelLoader should match snapshot 1`] = `
]
}
>
<UNDEFINED
<Unknown
Animation={[Function]}
Left={[Function]}
styles={
@ -284,18 +284,18 @@ exports[`ChannelLoader should match snapshot 1`] = `
}
}
>
<UNDEFINED
<Unknown
color="rgba(61,60,64,0.15)"
/>
<UNDEFINED
<Unknown
color="rgba(61,60,64,0.15)"
width={80}
/>
<UNDEFINED
<Unknown
color="rgba(61,60,64,0.15)"
width={60}
/>
</UNDEFINED>
</Unknown>
</View>
<View
style={
@ -313,7 +313,7 @@ exports[`ChannelLoader should match snapshot 1`] = `
]
}
>
<UNDEFINED
<Unknown
Animation={[Function]}
Left={[Function]}
styles={
@ -324,18 +324,18 @@ exports[`ChannelLoader should match snapshot 1`] = `
}
}
>
<UNDEFINED
<Unknown
color="rgba(61,60,64,0.15)"
/>
<UNDEFINED
<Unknown
color="rgba(61,60,64,0.15)"
width={80}
/>
<UNDEFINED
<Unknown
color="rgba(61,60,64,0.15)"
width={60}
/>
</UNDEFINED>
</Unknown>
</View>
<View
style={
@ -353,7 +353,7 @@ exports[`ChannelLoader should match snapshot 1`] = `
]
}
>
<UNDEFINED
<Unknown
Animation={[Function]}
Left={[Function]}
styles={
@ -364,18 +364,18 @@ exports[`ChannelLoader should match snapshot 1`] = `
}
}
>
<UNDEFINED
<Unknown
color="rgba(61,60,64,0.15)"
/>
<UNDEFINED
<Unknown
color="rgba(61,60,64,0.15)"
width={80}
/>
<UNDEFINED
<Unknown
color="rgba(61,60,64,0.15)"
width={60}
/>
</UNDEFINED>
</Unknown>
</View>
<View
style={
@ -393,7 +393,7 @@ exports[`ChannelLoader should match snapshot 1`] = `
]
}
>
<UNDEFINED
<Unknown
Animation={[Function]}
Left={[Function]}
styles={
@ -404,18 +404,18 @@ exports[`ChannelLoader should match snapshot 1`] = `
}
}
>
<UNDEFINED
<Unknown
color="rgba(61,60,64,0.15)"
/>
<UNDEFINED
<Unknown
color="rgba(61,60,64,0.15)"
width={80}
/>
<UNDEFINED
<Unknown
color="rgba(61,60,64,0.15)"
width={60}
/>
</UNDEFINED>
</Unknown>
</View>
<View
style={
@ -433,7 +433,7 @@ exports[`ChannelLoader should match snapshot 1`] = `
]
}
>
<UNDEFINED
<Unknown
Animation={[Function]}
Left={[Function]}
styles={
@ -444,18 +444,18 @@ exports[`ChannelLoader should match snapshot 1`] = `
}
}
>
<UNDEFINED
<Unknown
color="rgba(61,60,64,0.15)"
/>
<UNDEFINED
<Unknown
color="rgba(61,60,64,0.15)"
width={80}
/>
<UNDEFINED
<Unknown
color="rgba(61,60,64,0.15)"
width={60}
/>
</UNDEFINED>
</Unknown>
</View>
<View
style={
@ -473,7 +473,7 @@ exports[`ChannelLoader should match snapshot 1`] = `
]
}
>
<UNDEFINED
<Unknown
Animation={[Function]}
Left={[Function]}
styles={
@ -484,18 +484,18 @@ exports[`ChannelLoader should match snapshot 1`] = `
}
}
>
<UNDEFINED
<Unknown
color="rgba(61,60,64,0.15)"
/>
<UNDEFINED
<Unknown
color="rgba(61,60,64,0.15)"
width={80}
/>
<UNDEFINED
<Unknown
color="rgba(61,60,64,0.15)"
width={60}
/>
</UNDEFINED>
</Unknown>
</View>
<View
style={
@ -513,7 +513,7 @@ exports[`ChannelLoader should match snapshot 1`] = `
]
}
>
<UNDEFINED
<Unknown
Animation={[Function]}
Left={[Function]}
styles={
@ -524,18 +524,18 @@ exports[`ChannelLoader should match snapshot 1`] = `
}
}
>
<UNDEFINED
<Unknown
color="rgba(61,60,64,0.15)"
/>
<UNDEFINED
<Unknown
color="rgba(61,60,64,0.15)"
width={80}
/>
<UNDEFINED
<Unknown
color="rgba(61,60,64,0.15)"
width={60}
/>
</UNDEFINED>
</Unknown>
</View>
</View>
`;

View file

@ -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,

View file

@ -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 (
<>
<Typing theme={theme}/>
<Typing
theme={theme}
registerTypingAnimation={registerTypingAnimation}
/>
{Platform.OS === 'android' &&
<Autocomplete
cursorPositionEvent={cursorPositionEvent}

View file

@ -8,43 +8,52 @@ import {
Text,
} from 'react-native';
import FormattedText from 'app/components/formatted_text';
import {makeStyleSheetFromTheme} from 'app/utils/theme';
import EventEmitter from '@mm-redux/utils/event_emitter';
const {View: AnimatedView} = Animated;
import FormattedText from '@components/formatted_text';
import SafeAreaView from '@components/safe_area_view';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {TYPING_VISIBLE, TYPING_HEIGHT} from '@constants/post_draft';
export default class Typing extends PureComponent {
static propTypes = {
theme: PropTypes.object.isRequired,
typing: PropTypes.array.isRequired,
registerTypingAnimation: PropTypes.func.isRequired,
};
static defaultProps = {
typing: [],
};
state = {
typingHeight: new Animated.Value(0),
typingBottom = new Animated.Value(0);
componentDidMount() {
this.removeTypingAnimation = this.props.registerTypingAnimation(this.typingAnimation);
}
componentDidUpdate(prevProps) {
if (this.props.typing.length && !prevProps.typing.length) {
this.animateTyping(true);
EventEmitter.emit(TYPING_VISIBLE, true);
} else if (!this.props.typing.length) {
this.animateTyping();
EventEmitter.emit(TYPING_VISIBLE, false);
}
}
animateTyping = (show = false) => {
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 (
<AnimatedView style={{height: this.state.typingHeight}}>
<Text
style={style.typing}
ellipsizeMode='tail'
numberOfLines={1}
<Animated.View style={{bottom: this.typingBottom}}>
<SafeAreaView
excludeHeader={true}
excludeFooter={true}
useLandscapeMargin={true}
>
{this.renderTyping()}
</Text>
</AnimatedView>
<Text
style={style.typing}
ellipsizeMode='tail'
numberOfLines={1}
>
{this.renderTyping()}
</Text>
</SafeAreaView>
</Animated.View>
);
}
}
@ -101,6 +116,7 @@ export default class Typing extends PureComponent {
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
typing: {
position: 'absolute',
paddingLeft: 10,
paddingTop: 3,
fontSize: 11,

View file

@ -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(
<Typing {...baseProps}/>,
);
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(
<Typing {...props}/>,
);
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(
<Typing {...props}/>,
);
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(
<Typing {...props}/>,
<Typing {...baseProps}/>,
);
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(
<Typing {...baseProps}/>,
);
const instance = wrapper.instance();
expect(baseProps.registerTypingAnimation).toHaveBeenCalledTimes(1);
expect(instance.removeTypingAnimation).not.toHaveBeenCalled();
wrapper.unmount();
expect(instance.removeTypingAnimation).toHaveBeenCalledTimes(1);
});
});

View file

@ -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;

View file

@ -67,11 +67,12 @@ export default class ChannelAndroid extends ChannelBase {
component = (
<KeyboardLayout>
<View style={style.flex}>
<ChannelPostList/>
<ChannelPostList registerTypingAnimation={this.registerTypingAnimation}/>
</View>
<PostDraft
ref={this.postDraft}
screenId={this.props.componentId}
registerTypingAnimation={this.registerTypingAnimation}
/>
</KeyboardLayout>
);

View file

@ -63,6 +63,7 @@ export default class ChannelIOS extends ChannelBase {
<>
<ChannelPostList
updateNativeScrollView={this.updateNativeScrollView}
registerTypingAnimation={this.registerTypingAnimation}
/>
<View nativeID={ACCESSORIES_CONTAINER_NATIVE_ID}>
<Autocomplete
@ -101,6 +102,7 @@ export default class ChannelIOS extends ChannelBase {
valueEvent={CHANNEL_POST_TEXTBOX_VALUE_CHANGE}
ref={this.postDraft}
screenId={this.props.componentId}
registerTypingAnimation={this.registerTypingAnimation}
/>
</KeyboardTrackingView>
}

View file

@ -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 = () => {

View file

@ -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(
<ChannelBase {...baseProps}/>,
{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([]);
});
});

View file

@ -0,0 +1,42 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ChannelPostList should match snapshot 1`] = `
<ForwardRef(AnimatedComponentWrapper)
style={
Array [
Object {
"flex": 1,
},
Object {
"paddingBottom": 0,
},
]
}
>
<View
style={
Object {
"backgroundColor": "rgba(61,60,64,0.2)",
"height": 1,
}
}
/>
<Connect(PostList)
channelId="channel-id"
extraData={false}
indicateNewMessages={true}
lastPostIndex={-1}
loadMorePostsVisible={false}
onLoadMoreUp={[Function]}
onPostPress={[Function]}
postIds={Array []}
refreshing={false}
renderFooter={[Function]}
renderReplies={true}
scrollViewNativeID="channel-id"
showMoreMessagesButton={true}
/>
<Connect(AnnouncementBanner) />
<Connect(RetryBarIndicator) />
</ForwardRef(AnimatedComponentWrapper)>
`;

View file

@ -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 (
<View style={style.container}>
<Animated.View style={[style.container, {paddingBottom: this.bottomPadding}]}>
<View style={style.separator}/>
{component}
<AnnouncementBanner/>
<RetryBarIndicator/>
</View>
</Animated.View>
);
}
}

View file

@ -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(
<ChannelPostList {...baseProps}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should add/remove typing animation on mount/unmount', () => {
const wrapper = shallow(
<ChannelPostList {...baseProps}/>,
);
const instance = wrapper.instance();
expect(baseProps.registerTypingAnimation).toHaveBeenCalledTimes(1);
expect(instance.removeTypingAnimation).not.toHaveBeenCalled();
wrapper.unmount();
expect(instance.removeTypingAnimation).toHaveBeenCalledTimes(1);
});
});

View file

@ -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: {

View file

@ -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: {

View file

@ -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: {

View file

@ -16,29 +16,38 @@ exports[`thread should match snapshot, has root post 1`] = `
/>
<Connect(StatusBar) />
<React.Fragment>
<Connect(PostList)
currentUserId="member_user_id"
indicateNewMessages={false}
lastPostIndex={2}
lastViewedAt={0}
location="thread"
onPostPress={[Function]}
postIds={
Array [
"root_id",
"post_id_1",
"post_id_2",
]
<ForwardRef(AnimatedComponentWrapper)
style={
Object {
"flex": 1,
"paddingBottom": 0,
}
}
renderFooter={
<Loading
color="#3d3c40"
size="large"
style={Object {}}
/>
}
scrollViewNativeID="threadPostList"
/>
>
<Connect(PostList)
currentUserId="member_user_id"
indicateNewMessages={false}
lastPostIndex={2}
lastViewedAt={0}
location="thread"
onPostPress={[Function]}
postIds={
Array [
"root_id",
"post_id_1",
"post_id_2",
]
}
renderFooter={
<Loading
color="#3d3c40"
size="large"
style={Object {}}
/>
}
scrollViewNativeID="threadPostList"
/>
</ForwardRef(AnimatedComponentWrapper)>
<View
nativeID="threadAccessoriesContainer"
>
@ -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"
/>

View file

@ -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 = (
<>
<PostList
renderFooter={this.renderFooter()}
indicateNewMessages={false}
postIds={postIds}
currentUserId={myMember && myMember.user_id}
lastViewedAt={this.state.lastViewedAt}
lastPostIndex={-1}
onPostPress={this.hideKeyboard}
location={THREAD}
/>
<Animated.View style={{flex: 1, paddingBottom: this.bottomPadding}}>
<PostList
renderFooter={this.renderFooter()}
indicateNewMessages={false}
postIds={postIds}
currentUserId={myMember && myMember.user_id}
lastViewedAt={this.state.lastViewedAt}
lastPostIndex={-1}
onPostPress={this.hideKeyboard}
location={THREAD}
/>
</Animated.View>
<PostDraft
ref={this.postDraft}
channelId={channelId}
channelIsArchived={channelIsArchived}
rootId={rootId}
screenId={this.props.componentId}
registerTypingAnimation={registerTypingAnimation}
/>
</>
);

View file

@ -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 = (
<>
<PostList
renderFooter={this.renderFooter()}
indicateNewMessages={false}
postIds={postIds}
lastPostIndex={getLastPostIndex(postIds)}
currentUserId={myMember && myMember.user_id}
lastViewedAt={this.state.lastViewedAt}
onPostPress={this.hideKeyboard}
location={THREAD}
scrollViewNativeID={SCROLLVIEW_NATIVE_ID}
/>
<Animated.View style={{flex: 1, paddingBottom: this.bottomPadding}}>
<PostList
renderFooter={this.renderFooter()}
indicateNewMessages={false}
postIds={postIds}
lastPostIndex={getLastPostIndex(postIds)}
currentUserId={myMember && myMember.user_id}
lastViewedAt={this.state.lastViewedAt}
onPostPress={this.hideKeyboard}
location={THREAD}
scrollViewNativeID={SCROLLVIEW_NATIVE_ID}
/>
</Animated.View>
<View nativeID={ACCESSORIES_CONTAINER_NATIVE_ID}>
<Autocomplete
maxHeight={AUTOCOMPLETE_MAX_HEIGHT}
@ -80,6 +83,7 @@ export default class ThreadIOS extends ThreadBase {
rootId={rootId}
screenId={this.props.componentId}
valueEvent={THREAD_POST_TEXTBOX_VALUE_CHANGE}
registerTypingAnimation={registerTypingAnimation}
/>
</KeyboardTrackingView>
);

View file

@ -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(
<ThreadIOS {...baseProps}/>,
{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);
});
});

View file

@ -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,
});
}
}