diff --git a/NOTICE.txt b/NOTICE.txt index b9cc921e6..ff4adacd4 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -1,10 +1,10 @@ -Mattermost Mobile +Mattermost Mobile © 2016 Mattermost, Inc. All Rights Reserved. See LICENSE.txt for license information. -NOTICES: +NOTICES: -------- -This document includes a list of open source components used in Mattermost Mobile, including those that have been modified. +This document includes a list of open source components used in Mattermost Mobile, including those that have been modified. -------- @@ -12,10 +12,10 @@ This document includes a list of open source components used in Mattermost Mobil This product contains a modified portion of 'fbjs', a collection of JavaScript utilities by Facebook, Inc. -* HOMEPAGE: +* HOMEPAGE: * https://github.com/facebook/fbjs - -* LICENSE: + +* LICENSE: BSD License @@ -55,9 +55,9 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. This product contains a modified portion of 'react-native-search-bar', a high-quality iOS native search bar for react native by Zhao Han. -* HOMEPAGE: +* HOMEPAGE: * https://github.com/umhan35/react-native-search-bar - + * LICENSE: The MIT License (MIT) @@ -164,10 +164,10 @@ License. This product contains 'Intl.js', to bring long overdue localization methods to ECMAScript implementations by Andy Earnshaw. -* HOMEPAGE: +* HOMEPAGE: * https://github.com/andyearnshaw/Intl.js -* LICENSE: +* LICENSE: The MIT License (MIT) @@ -241,10 +241,10 @@ the property of their respective owners. This product contains 'isomorphic-fetch', to provide the fetch function for node by Matt Andrews. -* HOMEPAGE: +* HOMEPAGE: * https://github.com/matthew-andrews/isomorphic-fetch - -* LICENSE: + +* LICENSE: The MIT License (MIT) @@ -272,13 +272,13 @@ SOFTWARE. ## react -This product contains 'react', a declarative, efficient, and -flexible JavaScript library for building user interfaces by Facebook, Inc. +This product contains 'react', a declarative, efficient, and +flexible JavaScript library for building user interfaces by Facebook, Inc. -* HOMEPAGE: +* HOMEPAGE: * https://github.com/facebook/react - -* LICENSE: + +* LICENSE: BSD License @@ -313,7 +313,7 @@ ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. The licensor of the React software also provides the following: - + Additional Grant of Patent Rights Version 2 "Software" means the React software distributed by Facebook, Inc. @@ -344,12 +344,12 @@ under section (i) of this paragraph due to such counterclaim. ## react-intl -This product contains 'react-intl', a library that provides React components and an API to format dates, numbers, and string messages including pluralization by Yahoo Inc. +This product contains 'react-intl', a library that provides React components and an API to format dates, numbers, and string messages including pluralization by Yahoo Inc. -* HOMEPAGE: +* HOMEPAGE: * https://github.com/yahoo/react-intl - -* LICENSE: + +* LICENSE: Copyright 2014 Yahoo Inc. All rights reserved. @@ -385,10 +385,10 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. This product contains 'react-native', a library for building native applications using Javascript and React by Facebook, Inc. -* HOMEPAGE: +* HOMEPAGE: * https://github.com/facebook/react-native - -* LICENSE: + +* LICENSE: BSD License @@ -425,7 +425,7 @@ The licensor of the React Native software also provides the following: Additional Grant of Patent Rights Version 2 -"Software" means the React Native software distributed by Facebook, Inc. +"Software" means the React Native software distributed by Facebook, Inc. Facebook, Inc. (“Facebook”) hereby grants to each recipient of the Software (“you”) a perpetual, worldwide, royalty-free, non-exclusive, irrevocable (subject to the termination provision below) license under any Necessary @@ -455,10 +455,10 @@ under section (i) of this paragraph due to such counterclaim. This product contains 'react-native-button', a button for React apps by James Ide. -* HOMEPAGE: +* HOMEPAGE: * https://github.com/ide/react-native-button - -* LICENSE: + +* LICENSE: The MIT License (MIT) @@ -488,10 +488,10 @@ SOFTWARE. This product contains 'react-native-drawer', a drawer component for React Native by Zack Story. -* HOMEPAGE: +* HOMEPAGE: * https://github.com/root-two/react-native-drawer - -* LICENSE: + +* LICENSE: The MIT License (MIT) @@ -509,10 +509,10 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI This product contains 'react-native-keyboard-spacer', a keyboard spacer view for React Native by Andrew Hurst. -* HOMEPAGE: +* HOMEPAGE: * https://github.com/Andr3wHur5t/react-native-keyboard-spacer - -* LICENSE: + +* LICENSE: The MIT License (MIT) @@ -542,10 +542,10 @@ SOFTWARE. This product contains 'react-native-vector-icons', a set of vector icons for use in React Native apps by Joel Arvidsson. -* HOMEPAGE: +* HOMEPAGE: * https://github.com/oblador/react-native-vector-icons - -* LICENSE: + +* LICENSE: The MIT License (MIT) @@ -575,10 +575,10 @@ SOFTWARE. This product contains 'react-redux', a set of React bindings for Redux by Dan Abramov. -* HOMEPAGE: +* HOMEPAGE: * https://github.com/reactjs/react-redux - -* LICENSE: + +* LICENSE: The MIT License (MIT) @@ -608,10 +608,10 @@ SOFTWARE. This product contains 'redux', a predictable state container for JavaScript apps by Dan Abramov. -* HOMEPAGE: +* HOMEPAGE: * https://github.com/reactjs/redux - -* LICENSE: + +* LICENSE: The MIT License (MIT) @@ -641,10 +641,10 @@ SOFTWARE. This product contains 'redux-batched-actions', a batching action creator and higher order reducer for Redux by Tim Shelburne. -* HOMEPAGE: +* HOMEPAGE: * https://github.com/tshelburne/redux-batched-actions - -* LICENSE: + +* LICENSE: MIT License @@ -674,10 +674,10 @@ SOFTWARE. This product contains 'redux-thunk', middleware to allow asynchronous actions in Redux by Dan Abramov. -* HOMEPAGE: +* HOMEPAGE: * https://github.com/gaearon/redux-thunk - -* LICENSE: + +* LICENSE: The MIT License (MIT) @@ -832,3 +832,38 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--- + +## react-native-linear-gradient + +This product contains 'react-native-linear-gradient', a library to provide gradient components to react native on both iOS and Android. + +* HOMEPAGE + * https://github.com/react-native-community/react-native-linear-gradient + +* LICENSE + +MIT License + +Copyright (c) 2016 React Native Community + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- diff --git a/android/app/build.gradle b/android/app/build.gradle index 67befafe7..c570b6bc3 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -149,6 +149,7 @@ dependencies { } compile project(':react-native-device-info') compile project(':react-native-cookies') + compile project(':react-native-linear-gradient') compile project(':react-native-vector-icons') compile project(':react-native-svg') compile fileTree(dir: "libs", include: ["*.jar"]) diff --git a/android/app/src/main/java/com/mattermost/MainActivity.java b/android/app/src/main/java/com/mattermost/MainActivity.java index fd98c9b2c..ba75fdc35 100644 --- a/android/app/src/main/java/com/mattermost/MainActivity.java +++ b/android/app/src/main/java/com/mattermost/MainActivity.java @@ -1,6 +1,8 @@ package com.mattermost; import com.facebook.react.ReactActivity; +import com.psykar.cookiemanager.CookieManagerPackage; +import com.BV.LinearGradient.LinearGradientPackage; public class MainActivity extends ReactActivity { diff --git a/android/app/src/main/java/com/mattermost/MainApplication.java b/android/app/src/main/java/com/mattermost/MainApplication.java index 774ac5ea6..2e43278fc 100644 --- a/android/app/src/main/java/com/mattermost/MainApplication.java +++ b/android/app/src/main/java/com/mattermost/MainApplication.java @@ -14,6 +14,7 @@ import com.facebook.react.ReactNativeHost; import com.facebook.react.ReactPackage; import com.facebook.react.shell.MainReactPackage; import com.facebook.soloader.SoLoader; +import com.BV.LinearGradient.LinearGradientPackage; import java.util.Arrays; import java.util.List; @@ -34,7 +35,8 @@ public class MainApplication extends Application implements ReactApplication { new CookieManagerPackage(), new ReactNativePushNotificationPackage(), new VectorIconsPackage(), - new RNSvgPackage() + new RNSvgPackage(), + new LinearGradientPackage() ); } }; diff --git a/android/settings.gradle b/android/settings.gradle index da3881f72..c5d66fcb7 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -9,5 +9,7 @@ include ':react-native-vector-icons' project(':react-native-vector-icons').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-vector-icons/android') include ':app' +include ':react-native-linear-gradient' +project(':react-native-linear-gradient').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-linear-gradient/android') include ':react-native-svg' project(':react-native-svg').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-svg/android') diff --git a/app/actions/navigation/index.js b/app/actions/navigation/index.js index 608785fb7..c0c9f1d64 100644 --- a/app/actions/navigation/index.js +++ b/app/actions/navigation/index.js @@ -246,3 +246,18 @@ export function requestCloseModal() { }, getState); }; } + +export function goToImagePreviewModal(post, fileId) { + return async (dispatch, getState) => { + dispatch({ + type: NavigationTypes.NAVIGATION_MODAL, + route: { + ...Routes.ImagePreview, + props: { + post, + fileId + } + } + }, getState); + }; +} diff --git a/app/components/file_attachment_list/file_attachment.js b/app/components/file_attachment_list/file_attachment.js index 53bd146ac..5deb82e94 100644 --- a/app/components/file_attachment_list/file_attachment.js +++ b/app/components/file_attachment_list/file_attachment.js @@ -2,8 +2,8 @@ // See License.txt for license information. import React, { - Component, - PropTypes + PropTypes, + PureComponent } from 'react'; import { @@ -18,6 +18,69 @@ import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; import * as Utils from 'mattermost-redux/utils/file_utils.js'; import FileAttachmentIcon from './file_attachment_icon'; +import FileAttachmentPreview from './file_attachment_preview'; + +export default class FileAttachment extends PureComponent { + static propTypes = { + file: PropTypes.object.isRequired, + theme: PropTypes.object.isRequired + }; + + renderFileInfo() { + const {file, theme} = this.props; + const style = getStyleSheet(theme); + + return ( + + + {file.name.trim()} + + + + + {`${file.extension.toUpperCase()} ${Utils.getFormattedFileSize(file)}`} + + + + ); + } + + render() { + const {file, theme} = this.props; + const style = getStyleSheet(theme); + + let fileAttachmentComponent; + if (file.has_preview_image) { + fileAttachmentComponent = ( + + ); + } else { + fileAttachmentComponent = ( + + ); + } + + return ( + + {fileAttachmentComponent} + {this.renderFileInfo()} + + ); + } +} const getStyleSheet = makeStyleSheetFromTheme((theme) => { return StyleSheet.create({ @@ -57,51 +120,3 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => { } }); }); - -export default class FileAttachment extends Component { - static propTypes = { - file: PropTypes.object.isRequired, - theme: PropTypes.object.isRequired - }; - - renderFileInfo() { - const {file, theme} = this.props; - const style = getStyleSheet(theme); - - return ( - - - {file.name.trim()} - - - - - {`${file.extension.toUpperCase()} ${Utils.getFormattedFileSize(file)}`} - - - - ); - } - - render() { - const {file, theme} = this.props; - const style = getStyleSheet(theme); - - return ( - - - {this.renderFileInfo()} - - ); - } -} diff --git a/app/components/file_attachment_list/file_attachment_icon.js b/app/components/file_attachment_list/file_attachment_icon.js index 314dcb547..b3817369b 100644 --- a/app/components/file_attachment_list/file_attachment_icon.js +++ b/app/components/file_attachment_list/file_attachment_icon.js @@ -25,20 +25,6 @@ import pptIcon from 'assets/images/icons/ppt.png'; import videoIcon from 'assets/images/icons/video.png'; import wordIcon from 'assets/images/icons/word.png'; -const styles = StyleSheet.create({ - fileIcon: { - width: 60, - height: 60 - }, - fileIconWrapper: { - alignItems: 'center', - justifyContent: 'center', - width: 100, - height: 100, - backgroundColor: '#fff' - } -}); - const ICON_PATH_FROM_FILE_TYPE = { audio: audioIcon, code: codeIcon, @@ -54,23 +40,45 @@ const ICON_PATH_FROM_FILE_TYPE = { export default class FileAttachmentIcon extends Component { static propTypes = { - file: PropTypes.object.isRequired + file: PropTypes.object.isRequired, + iconHeight: PropTypes.number, + iconWidth: PropTypes.number, + wrapperHeight: PropTypes.number, + wrapperWidth: PropTypes.number }; + static defaultProps = { + iconHeight: 60, + iconWidth: 60, + wrapperHeight: 100, + wrapperWidth: 100 + } + getFileIconPath(file) { const fileType = Utils.getFileType(file); return ICON_PATH_FROM_FILE_TYPE[fileType] || ICON_PATH_FROM_FILE_TYPE.other; } render() { - const {file} = this.props; + const {file, iconHeight, iconWidth, wrapperHeight, wrapperWidth} = this.props; + const source = this.getFileIconPath(file); + return ( - + ); } } + +const styles = StyleSheet.create({ + fileIconWrapper: { + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#fff' + } +}); diff --git a/app/components/file_attachment_list/file_attachment_list.js b/app/components/file_attachment_list/file_attachment_list.js index 085ba6f9c..eee22b410 100644 --- a/app/components/file_attachment_list/file_attachment_list.js +++ b/app/components/file_attachment_list/file_attachment_list.js @@ -7,7 +7,8 @@ import React, { } from 'react'; import { - View + View, + TouchableOpacity } from 'react-native'; import FileAttachment from './file_attachment'; @@ -27,11 +28,15 @@ export default class FileAttachmentList extends Component { render() { const fileAttachments = this.props.files.map((file) => ( - + onPress={() => this.props.actions.goToImagePreviewModal(this.props.post, file.id)} + > + + )); return ( diff --git a/app/components/file_attachment_list/file_attachment_list_container.js b/app/components/file_attachment_list/file_attachment_list_container.js index 6848156a4..146e8b676 100644 --- a/app/components/file_attachment_list/file_attachment_list_container.js +++ b/app/components/file_attachment_list/file_attachment_list_container.js @@ -7,6 +7,7 @@ import {connect} from 'react-redux'; import {makeGetFilesForPost} from 'mattermost-redux/selectors/entities/files'; import {loadFilesForPostIfNecessary} from 'app/actions/views/channel'; import {getTheme} from 'app/selectors/preferences'; +import {goToImagePreviewModal} from 'app/actions/navigation'; import FileAttachmentList from './file_attachment_list'; @@ -24,6 +25,7 @@ function makeMapStateToProps() { function mapDispatchToProps(dispatch) { return { actions: bindActionCreators({ + goToImagePreviewModal, loadFilesForPostIfNecessary }, dispatch) }; diff --git a/app/components/file_attachment_list/file_attachment_preview.js b/app/components/file_attachment_list/file_attachment_preview.js new file mode 100644 index 000000000..7c2f80115 --- /dev/null +++ b/app/components/file_attachment_list/file_attachment_preview.js @@ -0,0 +1,70 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React, { + PropTypes, + PureComponent +} from 'react'; +import { + View, + Image, + StyleSheet +} from 'react-native'; + +import Client from 'mattermost-redux/client'; + +import genericIcon from 'assets/images/icons/generic.png'; + +export default class FileAttachmentPreview extends PureComponent { + static propTypes = { + file: PropTypes.object.isRequired, + imageHeight: PropTypes.number, + imageWidth: PropTypes.number, + resizeMode: PropTypes.string, + resizeMethod: PropTypes.string, + wrapperHeight: PropTypes.number, + wrapperWidth: PropTypes.number + }; + + static defaultProps = { + imageHeight: 100, + imageWidth: 100, + resizeMode: 'cover', + resizeMethod: 'resize', + wrapperHeigh: 100, + wrapperWidth: 100 + }; + + render() { + const { + file, + imageHeight, + imageWidth, + resizeMethod, + resizeMode, + wrapperHeight, + wrapperWidth + } = this.props; + + const source = {uri: Client.getFilePreviewUrl(file.id)}; + + return ( + + + + ); + } +} + +const style = StyleSheet.create({ + fileImageWrapper: { + alignItems: 'center', + justifyContent: 'center' + } +}); diff --git a/app/navigation/navigation_modal.js b/app/navigation/navigation_modal.js index 22429679c..b00955725 100644 --- a/app/navigation/navigation_modal.js +++ b/app/navigation/navigation_modal.js @@ -3,24 +3,39 @@ import React, {PropTypes, PureComponent} from 'react'; import { - Animated, - Dimensions + Animated } from 'react-native'; const {View: AnimatedView} = Animated; -const {width: deviceWidth, height: deviceHeight} = Dimensions.get('window'); + +const ANIMATION_TYPES = { + SlideFromBottom: 'slideFromBottom', + Fade: 'fade' +}; export default class NavigationModal extends PureComponent { static propTypes = { + animationType: PropTypes.oneOf([ + ANIMATION_TYPES.SlideFromBottom, + ANIMATION_TYPES.Fade + ]), + deviceHeight: PropTypes.number.isRequired, + deviceWidth: PropTypes.number.isRequired, show: PropTypes.bool } static defaultProps = { + animationType: ANIMATION_TYPES.SlideFromBottom, show: false } - state = { - top: new Animated.Value(deviceHeight) + constructor(props) { + super(props); + + this.state = { + top: new Animated.Value(props.deviceHeight), + opacity: new Animated.Value(100) + }; } componentWillReceiveProps(nextProps) { @@ -44,12 +59,61 @@ export default class NavigationModal extends PureComponent { } if (nextProps.show !== this.props.show) { - const animateValue = nextProps.show ? 0 : deviceHeight; + const animationType = nextProps.show ? nextProps.animationType : this.props.animationType; - Animated.timing(this.state.top, { - toValue: animateValue, + if (animationType === ANIMATION_TYPES.SlideFromBottom) { + this.slideFromBottomAnimationRunner(nextProps); + } else if (animationType === ANIMATION_TYPES.Fade) { + this.fadeAnimationRunner(nextProps); + } + } + } + + fadeAnimationRunner = (nextProps) => { + if (nextProps.show) { + const setOpacityToZero = Animated.timing(this.state.opacity, { + toValue: 0, + duration: 0 + }); + const setTopToZero = Animated.timing(this.state.top, { + toValue: 0, + duration: 0 + }); + const setOpacityToFull = Animated.timing(this.state.opacity, { + toValue: 1, duration: 400 - }).start(() => { + }); + Animated.sequence([ + setOpacityToZero, + setTopToZero, + setOpacityToFull + ]).start(() => { + // Once the scene has finished sliding down we can release the child scene + // which will unmount the scene correctly. + if (!this.props.show && !nextProps.show) { + this.setState({ + children: null + }); + } + }); + } else { + const setOpacityToZero = Animated.timing(this.state.opacity, { + toValue: 0, + duration: 400 + }); + const setTopToDeviceHeight = Animated.timing(this.state.top, { + toValue: this.props.deviceHeight, + duration: 0 + }); + const setOpacityToFull = Animated.timing(this.state.opacity, { + toValue: 1, + duration: 0 + }); + Animated.sequence([ + setOpacityToZero, + setTopToDeviceHeight, + setOpacityToFull + ]).start(() => { // Once the scene has finished sliding down we can release the child scene // which will unmount the scene correctly. if (!this.props.show && !nextProps.show) { @@ -61,9 +125,37 @@ export default class NavigationModal extends PureComponent { } } + slideFromBottomAnimationRunner = (nextProps) => { + const animateValue = nextProps.show ? 0 : this.props.deviceHeight; + + Animated.timing(this.state.top, { + toValue: animateValue, + duration: 400 + }).start(() => { + // Once the scene has finished sliding down we can release the child scene + // which will unmount the scene correctly. + if (!this.props.show && !nextProps.show) { + this.setState({ + children: null + }); + } + }); + } + render() { return ( - + {this.state.children} ); diff --git a/app/navigation/router.js b/app/navigation/router.js index 98412a85e..d4ba2de31 100644 --- a/app/navigation/router.js +++ b/app/navigation/router.js @@ -22,6 +22,8 @@ import NavigationModal from './navigation_modal'; const navigationPanResponder = NavigationExperimental.Card.CardStackPanResponder; +const {width: deviceWidth, height: deviceHeight} = Dimensions.get('window'); + class Router extends React.Component { static propTypes = { navigation: React.PropTypes.object, @@ -34,6 +36,11 @@ class Router extends React.Component { headerEventSubscriptions = {}; + state = { + deviceHeight, + deviceWidth + } + emitHeaderEvent = (key) => (event) => { const subscription = this.headerEventSubscriptions[`${key}-${event}`]; if (subscription) { @@ -60,6 +67,10 @@ class Router extends React.Component { }; extractNavigationProps = (route) => { + if (!route) { + return {}; + } + return Object.assign({}, route.navigationProps, route.component.navigationProps); }; @@ -110,7 +121,7 @@ class Router extends React.Component { if (navigationProps.allowSceneSwipe) { panHandlers = navigationPanResponder.forHorizontal({ ...cardProps, - gestureResponseDistance: (Dimensions.get('window').width / 2), // sets the distance from the edge for swiping + gestureResponseDistance: (this.state.deviceWidth / 2), // sets the distance from the edge for swiping onNavigateBack: this.props.actions.goBack }); } @@ -181,17 +192,29 @@ class Router extends React.Component { }; }; + onLayout = (event) => { + if (event.nativeEvent.layout.width !== this.state.deviceWidth) { + this.setState({ + deviceHeight: event.nativeEvent.layout.height, + deviceWidth: event.nativeEvent.layout.width + }); + } + } + render = () => { const { index, + isModal: modalVisible, leftDrawerOpen, leftDrawerRoute, + modal, rightDrawerOpen, rightDrawerRoute, - routes, - isModal: modalVisible + routes } = this.props.navigation; + const navigationProps = this.extractNavigationProps(routes[index]); + const modalNavigationProps = this.extractNavigationProps(modal.routes[0]); let leftDrawerContent; if (leftDrawerRoute) { @@ -204,7 +227,10 @@ class Router extends React.Component { } return ( - + - + diff --git a/app/navigation/routes.js b/app/navigation/routes.js index c9360e51d..13be361db 100644 --- a/app/navigation/routes.js +++ b/app/navigation/routes.js @@ -10,6 +10,7 @@ import { ChannelMembers, ChannelAddMembers, CreateChannel, + ImagePreview, LoadTeam, Login, LoginOptions, @@ -98,6 +99,10 @@ export const Routes = { title: {id: 'mobile.create_channel.private', defaultMessage: 'New Private Group'} } }, + ImagePreview: { + key: 'ImagePreview', + component: ImagePreview + }, LoadTeam: { key: 'LoadTeam', component: LoadTeam diff --git a/app/reducers/navigation/index.js b/app/reducers/navigation/index.js index 7fb8a810e..bfc573cc0 100644 --- a/app/reducers/navigation/index.js +++ b/app/reducers/navigation/index.js @@ -178,8 +178,7 @@ export default function(state = initialState, action) { index: 0, routes: [ { - ...action.route, - props: action.props + ...action.route } ] }; diff --git a/app/scenes/image_preview/image_preview.js b/app/scenes/image_preview/image_preview.js new file mode 100644 index 000000000..afe52862c --- /dev/null +++ b/app/scenes/image_preview/image_preview.js @@ -0,0 +1,320 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React, {PropTypes, PureComponent} from 'react'; +import { + Animated, + Dimensions, + InteractionManager, + PanResponder, + Platform, + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + TouchableWithoutFeedback, + View +} from 'react-native'; +import Icon from 'react-native-vector-icons/FontAwesome'; +import LinearGradient from 'react-native-linear-gradient'; + +import FileAttachmentIcon from 'app/components/file_attachment_list/file_attachment_icon'; +import FileAttachmentPreview from 'app/components/file_attachment_list/file_attachment_preview'; + +const {View: AnimatedView} = Animated; +const {height: deviceHeight, width: deviceWidth} = Dimensions.get('window'); +const DRAG_VERTICAL_THRESHOLD_START = 25; // When do we want to start capturing the drag +const DRAG_VERTICAL_THRESHOLD_END = 100; // When do we want to navigate back +const DRAG_HORIZONTAL_THRESHOLD = 50; // Make sure that it's not a sloppy horizontal swipe +const HEADER_HEIGHT = 64; +const STATUS_BAR_HEIGHT = Platform.OS === 'android' ? 25 : 0; // Used to account for the Android status bar + +export default class ImagePreview extends PureComponent { + static propTypes = { + actions: PropTypes.shape({ + goBack: PropTypes.func.isRequired + }), + fileId: PropTypes.string.isRequired, + files: PropTypes.array.isRequired, + theme: PropTypes.object.isRequired + }; + + static navigationProps = { + allowSceneSwipe: false, + hideNavBar: true, + modalAnimationType: 'fade' + }; + + constructor(props) { + super(props); + + const currentFile = props.files.findIndex((file) => file.id === props.fileId); + this.state = { + currentFile, + deviceHeight, + deviceWidth, + drag: new Animated.ValueXY(), + footerOpacity: new Animated.Value(1), + pagingEnabled: true, + showFileInfo: true, + wrapperViewOpacity: new Animated.Value(Platform.OS === 'android' ? 0 : 1) + }; + } + + componentWillMount() { + this.panResponder = PanResponder.create({ + onMoveShouldSetPanResponderCapture: this.onMoveShouldSetPanResponderCapture, + onPanResponderMove: Animated.event([null, { + dx: 0, + dy: this.state.drag.y + }]), + onPanResponderRelease: this.onPanResponderRelease, + onPanResponderTerminate: this.onPanResponderRelease + }); + } + + componentDidMount() { + // TODO: Use contentOffset on Android once PR is merged + // This is a hack until this PR gets merged: https://github.com/facebook/react-native/pull/12502 + // On Android there is a render animation for scrollViews. In order for scrollTo to work + // on scrollViews we have to wait for the animation to finish. This will cause a bad flicker when we + // want to set the offset of the scrollView to show say the second file of a post with 3 files. + // Using this delayed opacity animation allows us to wait for the scrollView animation to finish, + // scollTo the correct offset for the chosen file, and then fade in like the component does on iOS. + if (Platform.OS === 'android') { + InteractionManager.runAfterInteractions(() => { + this.scrollView.scrollTo({x: (this.state.currentFile) * this.state.deviceWidth, animated: false}); + Animated.timing(this.state.wrapperViewOpacity, { + toValue: 1, + duration: 200, + delay: 75 + }).start(); + }); + } + } + + onMoveShouldSetPanResponderCapture = (evt, gestureState) => { + const {dx, dy} = gestureState; + return (Math.abs(dy) > DRAG_VERTICAL_THRESHOLD_START && dx < DRAG_HORIZONTAL_THRESHOLD); + } + + onPanResponderRelease = (evt, gestureState) => { + if (Math.abs(gestureState.dy) > DRAG_VERTICAL_THRESHOLD_END) { + this.props.actions.goBack(); + } else { + Animated.spring(this.state.drag, { + toValue: {x: 0, y: 0} + }).start(); + } + } + + toggleFileInfo = () => { + const showFileInfo = !this.state.showFileInfo; + this.setState({ + showFileInfo + }); + + const opacity = showFileInfo ? 1 : 0; + + Animated.timing(this.state.footerOpacity, { + toValue: opacity, + duration: 300 + }).start(); + } + + handleScroll = (event) => { + if (event.nativeEvent.contentOffset.x % this.state.deviceWidth === 0) { + this.setState({ + currentFile: (event.nativeEvent.contentOffset.x / this.state.deviceWidth), + pagingEnabled: true + }); + } + } + + attachScrollView = (c) => { + this.scrollView = c; + } + + onLayout = (event) => { + if (event.nativeEvent.layout.width !== this.state.deviceWidth) { + this.setState({ + deviceHeight: event.nativeEvent.layout.height, + deviceWidth: event.nativeEvent.layout.width + }); + } + } + + render() { + const maxImageHeight = this.state.deviceHeight - HEADER_HEIGHT - STATUS_BAR_HEIGHT; + + return ( + + + + + + + + + {`${this.state.currentFile + 1}/${this.props.files.length}`} + + true} + style={style.headerIcon} + > + {/**/} + + + + + {this.props.files.map((file) => { + let component; + if (file.has_preview_image) { + component = ( + + ); + } else { + component = ( + + ); + } + + return ( + + + {component} + + + ); + })} + + + + + {this.props.files[this.state.currentFile].name} + + + + + + ); + } +} + +const style = StyleSheet.create({ + filename: { + color: 'white', + fontSize: 15 + }, + footer: { + flex: 1, + justifyContent: 'flex-end', + paddingHorizontal: 24, + paddingBottom: 16 + }, + footerWrapper: { + position: 'absolute', + bottom: 0, + left: 0, + height: 100 + }, + header: { + backgroundColor: '#000', + height: HEADER_HEIGHT + }, + headerControls: { + alignItems: 'center', + justifyContent: 'space-around', + flexDirection: 'row', + ...Platform.select({ + ios: { + marginTop: 30 + }, + android: { + marginTop: 10 + } + }) + }, + headerIcon: { + height: 44, + width: 48, + alignItems: 'center', + justifyContent: 'center' + }, + pageWrapper: { + alignItems: 'center', + justifyContent: 'center' + }, + scrollView: { + flex: 1 + }, + scrollViewContent: { + backgroundColor: '#000', + alignItems: 'center', + justifyContent: 'center' + }, + title: { + flex: 1, + marginHorizontal: 10, + color: 'white', + fontSize: 15, + textAlign: 'center' + }, + wrapper: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.8)' + } +}); diff --git a/app/scenes/image_preview/index.js b/app/scenes/image_preview/index.js new file mode 100644 index 000000000..31ca5560c --- /dev/null +++ b/app/scenes/image_preview/index.js @@ -0,0 +1,33 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {bindActionCreators} from 'redux'; + +import {goBack} from 'app/actions/navigation'; +import {getTheme} from 'app/selectors/preferences'; +import {makeGetFilesForPost} from 'mattermost-redux/selectors/entities/files'; + +import ImagePreview from './image_preview'; + +import navigationSceneConnect from '../navigationSceneConnect'; + +function makeMapStateToProps() { + const getFilesForPost = makeGetFilesForPost(); + return function mapStateToProps(state, ownProps) { + return { + ...ownProps, + files: getFilesForPost(state, ownProps), + theme: getTheme(state) + }; + }; +} + +function mapDispatchToProps(dispatch) { + return { + actions: bindActionCreators({ + goBack + }, dispatch) + }; +} + +export default navigationSceneConnect(makeMapStateToProps, mapDispatchToProps)(ImagePreview); diff --git a/app/scenes/index.js b/app/scenes/index.js index 74c8cca95..6675e4d68 100644 --- a/app/scenes/index.js +++ b/app/scenes/index.js @@ -9,6 +9,7 @@ import ChannelInfo from './channel_info'; import ChannelMembers from './channel_members'; import ChannelAddMembers from './channel_add_members'; import CreateChannel from './create_channel'; +import ImagePreview from './image_preview'; import LoadTeam from './load_team'; import Login from './login/login_container.js'; import LoginOptions from './login_options'; @@ -34,6 +35,7 @@ module.exports = { ChannelMembers, ChannelAddMembers, CreateChannel, + ImagePreview, LoadTeam, Login, LoginOptions, diff --git a/ios/Mattermost.xcodeproj/project.pbxproj b/ios/Mattermost.xcodeproj/project.pbxproj index 0556c0f0f..07a2efe29 100644 --- a/ios/Mattermost.xcodeproj/project.pbxproj +++ b/ios/Mattermost.xcodeproj/project.pbxproj @@ -39,6 +39,7 @@ C337E8708D2845C6A8DBB47E /* Ionicons.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 2DCFD31D3F4A4154822AB532 /* Ionicons.ttf */; }; F006936FC2884C24A1321FC0 /* MaterialIcons.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 356C9186FA374641A00EB2EA /* MaterialIcons.ttf */; }; 10CD747CE4304BD6AB38B4CD /* libRNDeviceInfo.a in Frameworks */ = {isa = PBXBuildFile; fileRef = EE671DF7637347CD8C069819 /* libRNDeviceInfo.a */; }; + 584837D6B55F405F908A2053 /* libBVLinearGradient.a in Frameworks */ = {isa = PBXBuildFile; fileRef = ACC6B9FDC0AD45A6BFA4FBCD /* libBVLinearGradient.a */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -310,6 +311,8 @@ F81F6DC42D394831B4549928 /* libRNSearchBar.a */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = archive.ar; path = libRNSearchBar.a; sourceTree = ""; }; B97AA6F961BB47D3A3297E8E /* RNDeviceInfo.xcodeproj */ = {isa = PBXFileReference; name = "RNDeviceInfo.xcodeproj"; path = "../node_modules/react-native-device-info/RNDeviceInfo.xcodeproj"; sourceTree = ""; fileEncoding = undefined; lastKnownFileType = wrapper.pb-project; explicitFileType = undefined; includeInIndex = 0; }; EE671DF7637347CD8C069819 /* libRNDeviceInfo.a */ = {isa = PBXFileReference; name = "libRNDeviceInfo.a"; path = "libRNDeviceInfo.a"; sourceTree = ""; fileEncoding = undefined; lastKnownFileType = archive.ar; explicitFileType = undefined; includeInIndex = 0; }; + 7DCC3D826CE640AF8F491692 /* BVLinearGradient.xcodeproj */ = {isa = PBXFileReference; name = "BVLinearGradient.xcodeproj"; path = "../node_modules/react-native-linear-gradient/BVLinearGradient.xcodeproj"; sourceTree = ""; fileEncoding = undefined; lastKnownFileType = wrapper.pb-project; explicitFileType = undefined; includeInIndex = 0; }; + ACC6B9FDC0AD45A6BFA4FBCD /* libBVLinearGradient.a */ = {isa = PBXFileReference; name = "libBVLinearGradient.a"; path = "libBVLinearGradient.a"; sourceTree = ""; fileEncoding = undefined; lastKnownFileType = archive.ar; explicitFileType = undefined; includeInIndex = 0; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -342,6 +345,7 @@ B50561A109B4442299F82327 /* libRNSearchBar.a in Frameworks */, 9906AC672C594EEB9D5EE503 /* libRNCookieManagerIOS.a in Frameworks */, 10CD747CE4304BD6AB38B4CD /* libRNDeviceInfo.a in Frameworks */, + 584837D6B55F405F908A2053 /* libBVLinearGradient.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -550,6 +554,7 @@ 59954D479A89488091EB588F /* RNSearchBar.xcodeproj */, 31A0780E2F224AC8AF8E6930 /* RNCookieManagerIOS.xcodeproj */, B97AA6F961BB47D3A3297E8E /* RNDeviceInfo.xcodeproj */, + 7DCC3D826CE640AF8F491692 /* BVLinearGradient.xcodeproj */, ); name = Libraries; sourceTree = ""; diff --git a/package.json b/package.json index 92ea61562..a086868fb 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "react-native-keyboard-spacer": "0.3.1", "react-native-message-bar": "1.6.0", "react-native-push-notification": "2.2.1", + "react-native-linear-gradient": "2.0.0", "react-native-search-bar": "enahum/react-native-search-bar.git", "react-native-svg": "4.5.0", "react-native-vector-icons": "4.0.0", @@ -46,6 +47,7 @@ "fetch-mock": "5.8.1", "form-data": "2.1.2", "mocha": "3.2.0", + "mocha-react-native": "0.5.0", "react-addons-test-utils": "15.4.2", "react-dom": "15.4.2", "react-native-mock": "0.2.9", diff --git a/test/mocha.opts b/test/mocha.opts index 1c51a4428..a611bc998 100644 --- a/test/mocha.opts +++ b/test/mocha.opts @@ -1,5 +1,7 @@ --require test/setup.js +--require test/mocks.js --compilers js:babel-core/register +--compilers js:mocha-react-native/init --require react-native-mock/mock.js --require react-native-svg-mock/mock --require babel-polyfill diff --git a/test/mocks.js b/test/mocks.js new file mode 100644 index 000000000..646bac37e --- /dev/null +++ b/test/mocks.js @@ -0,0 +1,4 @@ +import {addMock} from 'mocha-react-native'; + +addMock('react-native-linear-gradient', {}); +addMock('UIManager', {}); diff --git a/test/setup.js b/test/setup.js index a956314b0..3cbde6b2a 100644 --- a/test/setup.js +++ b/test/setup.js @@ -17,23 +17,18 @@ const m = require('module'); const originalLoader = m._load; const NativeModules = ReactNative.NativeModules; +const Platform = ReactNative.Platform; NativeModules.RNCookieManagerIOS = {}; NativeModules.RNCookieManagerAndroid = {}; +Platform.__setOS('ios'); + // Image file ignore setup from: // http://valuemotive.com/2016/08/01/unit-testing-react-native-components-with-mocha-and-enzyme/ m._load = function hookedLoader(request, parent, isMain) { if (request.match(/.jpeg|.jpg|.png$/)) { - return {uri: request}; - } - - if (request === './search_bar') { - request = './search_bar.ios'; - } - - if (request === './options_modal_list') { - request = './options_modal_list.ios'; + return {uri: request} } return originalLoader(request, parent, isMain);