From 583d6b74ee2ae48e0bafd182235024de53ef5a11 Mon Sep 17 00:00:00 2001 From: Stan Chan Date: Wed, 27 Jun 2018 03:55:03 -0700 Subject: [PATCH] Timezone feature (#1456) * Add automatic and manual timezone support Update users timezone and scrollTo selected timezone in SelectTimezone coponent Clean styles for SelectTimezone screen Add auto-timezone update when login or enter app Hide timezone feature behind config Fix requested changes Parse SupportedTimezone from config Add localTime and localized post stamps Add trailing commas Include all timezone util methods from redux Remove EnableTimezoneSelection flag WIP get timezones from server Pull supportedTimezones from api Minor fixes Remove wrapWithPreventDoubleTap Revert back to react-intl formatDate Include timeZone prop in FormattedTime Refactor Timezone row into component Minor cosmetic changes Add minimum server support for the timezone feature Move getSupportedTimezones to componentWillMount Move autoUpdateTimezone function to handleSuccessfulLogin Specify user timezone in profile_header Remove format props from FormattedTime Add ExperimentalTimezone flag Replace Client().getServerVersion() with entities.general.serverVersion Add isTimezoneEnabled helper function Move isMinimumServerVersion to utils/timezone.js * Fix style errors * Remove date-time-format-timezone polyfill * Feedback changes * Use timezone selector from redux * Explicitly pass hour12 props to intl.formatDate * Update package-lock * Revert iOS project file changes * Fix license header * Include timezone related paths in modulePaths * Fix license header * Fix minor issue with rebasing * Fix issue with getconfig in GeneralSettings * Update package-lock --- app/actions/views/login.js | 9 +- app/components/formatted_time.js | 28 ++- app/components/post_header/index.js | 8 +- app/components/post_header/post_header.js | 5 + .../search_bar/search_bar.android.js | 5 +- app/screens/entry/entry.js | 13 + app/screens/entry/index.js | 8 + app/screens/index.js | 2 + .../display_settings/display_settings.js | 42 +++- .../settings/display_settings/index.js | 5 + app/screens/timezone/index.js | 39 +++ app/screens/timezone/select_timezone/index.js | 29 +++ .../select_timezone/select_timezone.js | 159 ++++++++++++ .../select_timezone/select_timezone_row.js | 78 ++++++ app/screens/timezone/timezone.js | 232 ++++++++++++++++++ app/screens/user_profile/index.js | 9 +- app/screens/user_profile/user_profile.js | 36 ++- app/utils/timezone.js | 16 ++ assets/base/i18n/en.json | 5 + package-lock.json | 6 +- packager/modulePaths.js | 4 + 21 files changed, 715 insertions(+), 23 deletions(-) create mode 100644 app/screens/timezone/index.js create mode 100644 app/screens/timezone/select_timezone/index.js create mode 100644 app/screens/timezone/select_timezone/select_timezone.js create mode 100644 app/screens/timezone/select_timezone/select_timezone_row.js create mode 100644 app/screens/timezone/timezone.js create mode 100644 app/utils/timezone.js diff --git a/app/actions/views/login.js b/app/actions/views/login.js index dbe1f076a..4d6c06132 100644 --- a/app/actions/views/login.js +++ b/app/actions/views/login.js @@ -3,12 +3,14 @@ import {getDataRetentionPolicy} from 'mattermost-redux/actions/general'; import {GeneralTypes} from 'mattermost-redux/action_types'; +import {autoUpdateTimezone} from 'mattermost-redux/actions/timezone'; import {Client4} from 'mattermost-redux/client'; +import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general'; import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; import {ViewTypes} from 'app/constants'; import {app} from 'app/mattermost'; -import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general'; +import {getDeviceTimezone, isTimezoneEnabled} from 'app/utils/timezone'; export function handleLoginIdChanged(loginId) { return async (dispatch, getState) => { @@ -40,6 +42,11 @@ export function handleSuccessfulLogin() { app.setAppCredentials(deviceToken, currentUserId, token, url); + const enableTimezone = isTimezoneEnabled(state); + if (enableTimezone) { + dispatch(autoUpdateTimezone(getDeviceTimezone())); + } + dispatch({ type: GeneralTypes.RECEIVED_APP_CREDENTIALS, data: { diff --git a/app/components/formatted_time.js b/app/components/formatted_time.js index f5bf373c1..fb528e828 100644 --- a/app/components/formatted_time.js +++ b/app/components/formatted_time.js @@ -3,35 +3,37 @@ import React from 'react'; import PropTypes from 'prop-types'; +import {intlShape} from 'react-intl'; import {Text} from 'react-native'; export default class FormattedTime extends React.PureComponent { static propTypes = { value: PropTypes.any.isRequired, + timeZone: PropTypes.string, children: PropTypes.func, hour12: PropTypes.bool, }; + static contextTypes = { + intl: intlShape.isRequired, + }; + render() { const { value, children, + timeZone, hour12, } = this.props; + const {intl} = this.context; - const date = new Date(value); - const militaryTime = !hour12; - - const hour = militaryTime ? date.getHours() : (date.getHours() % 12 || 12); - let minute = date.getMinutes(); - minute = minute >= 10 ? minute : ('0' + minute); - let time = ''; - - if (!militaryTime) { - time = (date.getHours() >= 12 ? ' PM' : ' AM'); - } - - const formattedTime = hour + ':' + minute + time; + const timezoneProps = timeZone ? {timeZone} : {}; + const formattedTime = intl.formatDate(value, { + ...timezoneProps, + hour: 'numeric', + minute: 'numeric', + hour12, + }); if (typeof children === 'function') { return children(formattedTime); diff --git a/app/components/post_header/index.js b/app/components/post_header/index.js index 641d0c60a..316355606 100644 --- a/app/components/post_header/index.js +++ b/app/components/post_header/index.js @@ -6,12 +6,14 @@ import {connect} from 'react-redux'; import {Preferences} from 'mattermost-redux/constants'; import {getPost, makeGetCommentCountForPost} from 'mattermost-redux/selectors/entities/posts'; import {getBool, getTeammateNameDisplaySetting, getTheme} from 'mattermost-redux/selectors/entities/preferences'; -import {getUser} from 'mattermost-redux/selectors/entities/users'; +import {getUser, getCurrentUser} from 'mattermost-redux/selectors/entities/users'; import {isPostPendingOrFailed, isSystemMessage} from 'mattermost-redux/utils/post_utils'; +import {getUserCurrentTimezone} from 'mattermost-redux/utils/timezone_utils'; import {displayUsername} from 'mattermost-redux/utils/user_utils'; import {getConfig} from 'mattermost-redux/selectors/entities/general'; import {fromAutoResponder} from 'app/utils/general'; +import {isTimezoneEnabled} from 'app/utils/timezone'; import PostHeader from './post_header'; @@ -22,8 +24,11 @@ function makeMapStateToProps() { const post = getPost(state, ownProps.postId); const commentedOnUser = getUser(state, ownProps.commentedOnUserId); const user = getUser(state, post.user_id) || {}; + const currentUser = getCurrentUser(state); const teammateNameDisplay = getTeammateNameDisplaySetting(state); const militaryTime = getBool(state, Preferences.CATEGORY_DISPLAY_SETTINGS, 'use_military_time'); + const enableTimezone = isTimezoneEnabled(state); + const userTimezone = enableTimezone && getUserCurrentTimezone(currentUser.timezone); return { commentedOnDisplayName: ownProps.commentedOnUserId ? displayUsername(commentedOnUser, teammateNameDisplay) : '', @@ -39,6 +44,7 @@ function makeMapStateToProps() { overrideUsername: post.props && post.props.override_username, theme: getTheme(state), username: user.username, + userTimezone, }; }; } diff --git a/app/components/post_header/post_header.js b/app/components/post_header/post_header.js index 900a35343..145895be9 100644 --- a/app/components/post_header/post_header.js +++ b/app/components/post_header/post_header.js @@ -39,7 +39,9 @@ export default class PostHeader extends PureComponent { showFullDate: PropTypes.bool, theme: PropTypes.object.isRequired, username: PropTypes.string, + userTimezone: PropTypes.string, isFlagged: PropTypes.bool, + enableTimezone: PropTypes.bool, }; static defaultProps = { @@ -172,6 +174,7 @@ export default class PostHeader extends PureComponent { createAt, isPendingOrFailedPost, isSearchResult, + userTimezone, militaryTime, onPress, renderReplies, @@ -192,6 +195,7 @@ export default class PostHeader extends PureComponent { @@ -202,6 +206,7 @@ export default class PostHeader extends PureComponent { dateComponent = ( diff --git a/app/components/search_bar/search_bar.android.js b/app/components/search_bar/search_bar.android.js index 784e05847..b94f34eea 100644 --- a/app/components/search_bar/search_bar.android.js +++ b/app/components/search_bar/search_bar.android.js @@ -41,6 +41,7 @@ export default class SearchBarAndroid extends PureComponent { inputHeight: PropTypes.number, inputBorderRadius: PropTypes.number, blurOnSubmit: PropTypes.bool, + showArrow: PropTypes.bool, value: PropTypes.string, containerStyle: CustomPropTypes.Style, }; @@ -52,6 +53,7 @@ export default class SearchBarAndroid extends PureComponent { blurOnSubmit: true, placeholder: 'Search', showCancelButton: true, + showArrow: true, placeholderTextColor: changeOpacity('#000', 0.5), containerStyle: {}, onSearchButtonPress: () => true, @@ -156,6 +158,7 @@ export default class SearchBarAndroid extends PureComponent { tintColorSearch, containerStyle, value, + showArrow, } = this.props; const {isFocused} = this.state; @@ -192,7 +195,7 @@ export default class SearchBarAndroid extends PureComponent { }, ]} > - {isFocused ? + {isFocused && showArrow ? { + const { + actions: { + autoUpdateTimezone, + }, + enableTimezone, + deviceTimezone, + } = this.props; const {getState} = store; const state = getState(); @@ -116,6 +125,10 @@ export default class Entry extends PureComponent { if (state.views.root.hydrationComplete) { this.unsubscribeFromStore(); + if (enableTimezone) { + autoUpdateTimezone(deviceTimezone); + } + this.setAppCredentials(); this.setStartupThemes(); this.handleNotification(); diff --git a/app/screens/entry/index.js b/app/screens/entry/index.js index cb7b95ace..5048a5537 100644 --- a/app/screens/entry/index.js +++ b/app/screens/entry/index.js @@ -4,10 +4,12 @@ import {bindActionCreators} from 'redux'; import {connect} from 'react-redux'; import {setDeviceToken} from 'mattermost-redux/actions/general'; +import {autoUpdateTimezone} from 'mattermost-redux/actions/timezone'; import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; import {getConfig} from 'mattermost-redux/selectors/entities/general'; import {isLandscape} from 'app/selectors/device'; +import {getDeviceTimezone, isTimezoneEnabled} from 'app/utils/timezone'; const lazyLoadEntry = () => { return require('./entry').default; @@ -16,11 +18,16 @@ const lazyLoadEntry = () => { function mapStateToProps(state) { const config = getConfig(state); + const enableTimezone = isTimezoneEnabled(state); + const deviceTimezone = getDeviceTimezone(); + return { config, theme: getTheme(state), isLandscape: isLandscape(state), hydrationComplete: state.views.root.hydrationComplete, + enableTimezone, + deviceTimezone, }; } @@ -28,6 +35,7 @@ function mapDispatchToProps(dispatch) { return { actions: bindActionCreators({ setDeviceToken, + autoUpdateTimezone, }, dispatch), }; } diff --git a/app/screens/index.js b/app/screens/index.js index 20a1b0376..37e1e6a89 100644 --- a/app/screens/index.js +++ b/app/screens/index.js @@ -48,11 +48,13 @@ export function registerScreens(store, Provider) { Navigation.registerComponent('Search', () => wrapWithContextProvider(require('app/screens/search').default), store, Provider); Navigation.registerComponent('SelectServer', () => wrapWithContextProvider(SelectServer), store, Provider); Navigation.registerComponent('SelectTeam', () => wrapWithContextProvider(require('app/screens/select_team').default), store, Provider); + Navigation.registerComponent('SelectTimezone', () => wrapWithContextProvider(require('app/screens/timezone/select_timezone').default), store, Provider); Navigation.registerComponent('Settings', () => wrapWithContextProvider(require('app/screens/settings/general').default), store, Provider); Navigation.registerComponent('SSO', () => wrapWithContextProvider(require('app/screens/sso').default), store, Provider); Navigation.registerComponent('Table', () => wrapWithContextProvider(require('app/screens/table').default), store, Provider); Navigation.registerComponent('TableImage', () => wrapWithContextProvider(require('app/screens/table_image').default), store, Provider); Navigation.registerComponent('TextPreview', () => wrapWithContextProvider(require('app/screens/text_preview').default), store, Provider); Navigation.registerComponent('Thread', () => wrapWithContextProvider(require('app/screens/thread').default), store, Provider); + Navigation.registerComponent('TimezoneSettings', () => wrapWithContextProvider(require('app/screens/timezone').default), store, Provider); Navigation.registerComponent('UserProfile', () => wrapWithContextProvider(require('app/screens/user_profile').default), store, Provider); } diff --git a/app/screens/settings/display_settings/display_settings.js b/app/screens/settings/display_settings/display_settings.js index 6ca8e943f..8ca20cbe9 100644 --- a/app/screens/settings/display_settings/display_settings.js +++ b/app/screens/settings/display_settings/display_settings.js @@ -20,6 +20,7 @@ export default class DisplaySettings extends PureComponent { static propTypes = { navigator: PropTypes.object.isRequired, theme: PropTypes.object.isRequired, + enableTimezone: PropTypes.bool.isRequired, }; static contextTypes = { @@ -53,12 +54,30 @@ export default class DisplaySettings extends PureComponent { this.setState({showClockDisplaySettings: true}); }); + goToTimezoneSettings = preventDoubleTap(() => { + const {navigator, theme} = this.props; + const {intl} = this.context; + + navigator.push({ + screen: 'TimezoneSettings', + title: intl.formatMessage({id: 'mobile.advanced_settings.timezone', defaultMessage: 'Timezone'}), + animated: true, + backButtonTitle: '', + navigatorStyle: { + navBarTextColor: theme.sidebarHeaderTextColor, + navBarBackgroundColor: theme.sidebarHeaderBg, + navBarButtonColor: theme.sidebarHeaderTextColor, + screenBackgroundColor: theme.centerChannelBg, + }, + }); + }); + closeClockDisplaySettings = () => { this.setState({showClockDisplaySettings: false}); }; render() { - const {theme} = this.props; + const {theme, enableTimezone} = this.props; const {showClockDisplaySettings} = this.state; const style = getStyleSheet(theme); @@ -72,6 +91,24 @@ export default class DisplaySettings extends PureComponent { ); } + let timezoneOption; + + const disableClockDisplaySeparator = enableTimezone; + if (enableTimezone) { + timezoneOption = ( + + ); + } + return ( @@ -83,10 +120,11 @@ export default class DisplaySettings extends PureComponent { iconName='ios-time' iconType='ion' onPress={this.goToClockDisplaySettings} - separator={false} + separator={disableClockDisplaySeparator} showArrow={false} theme={theme} /> + {timezoneOption} {clockDisplayModal} diff --git a/app/screens/settings/display_settings/index.js b/app/screens/settings/display_settings/index.js index 02a50856b..2eb8b6b4b 100644 --- a/app/screens/settings/display_settings/index.js +++ b/app/screens/settings/display_settings/index.js @@ -5,10 +5,15 @@ import {connect} from 'react-redux'; import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; +import {isTimezoneEnabled} from 'app/utils/timezone'; + import DisplaySettings from './display_settings'; function mapStateToProps(state) { + const enableTimezone = isTimezoneEnabled(state); + return { + enableTimezone, theme: getTheme(state), }; } diff --git a/app/screens/timezone/index.js b/app/screens/timezone/index.js new file mode 100644 index 000000000..19e7280b4 --- /dev/null +++ b/app/screens/timezone/index.js @@ -0,0 +1,39 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {connect} from 'react-redux'; +import {bindActionCreators} from 'redux'; + +import {getSupportedTimezones} from 'mattermost-redux/actions/general'; +import {getSupportedTimezones as getTimezones} from 'mattermost-redux/selectors/entities/general'; +import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; +import {getUserTimezone} from 'mattermost-redux/selectors/entities/timezone'; +import {getCurrentUser} from 'mattermost-redux/selectors/entities/users'; + +import {updateUser} from 'app/actions/views/edit_profile'; + +import Timezone from './timezone'; + +function mapStateToProps(state) { + const timezones = getTimezones(state); + const currentUser = getCurrentUser(state); + const userTimezone = getUserTimezone(state, currentUser.id); + + return { + user: currentUser, + theme: getTheme(state), + userTimezone, + timezones, + }; +} + +function mapDispatchToProps(dispatch) { + return { + actions: bindActionCreators({ + getSupportedTimezones, + updateUser, + }, dispatch), + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(Timezone); diff --git a/app/screens/timezone/select_timezone/index.js b/app/screens/timezone/select_timezone/index.js new file mode 100644 index 000000000..5fb95760f --- /dev/null +++ b/app/screens/timezone/select_timezone/index.js @@ -0,0 +1,29 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {connect} from 'react-redux'; + +import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; +import {getSupportedTimezones} from 'mattermost-redux/selectors/entities/general'; + +import SelectTimezone from './select_timezone'; + +function mapStateToProps(state, props) { + const {selectedTimezone} = props; + const supportedTimezones = getSupportedTimezones(state); + + let index = 0; + + const timezoneIndex = supportedTimezones.findIndex((timezone) => timezone === selectedTimezone); + if (timezoneIndex > 0) { + index = timezoneIndex; + } + + return { + theme: getTheme(state), + timezones: supportedTimezones, + initialScrollIndex: index, + }; +} + +export default connect(mapStateToProps)(SelectTimezone); diff --git a/app/screens/timezone/select_timezone/select_timezone.js b/app/screens/timezone/select_timezone/select_timezone.js new file mode 100644 index 000000000..f52559010 --- /dev/null +++ b/app/screens/timezone/select_timezone/select_timezone.js @@ -0,0 +1,159 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {PureComponent} from 'react'; +import PropTypes from 'prop-types'; +import { + View, + FlatList, + Platform, +} from 'react-native'; +import {getTimezoneRegion} from 'mattermost-redux/utils/timezone_utils'; +import {intlShape} from 'react-intl'; + +import SearchBar from 'app/components/search_bar'; +import StatusBar from 'app/components/status_bar'; +import SelectTimezoneRow from './select_timezone_row'; + +import {ListTypes} from 'app/constants'; +import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; + +const ITEM_HEIGHT = 45; +const VIEWABILITY_CONFIG = ListTypes.VISIBILITY_CONFIG_DEFAULTS; + +export default class Timezone extends PureComponent { + static propTypes = { + selectedTimezone: PropTypes.string.isRequired, + initialScrollIndex: PropTypes.number.isRequired, + timezones: PropTypes.array.isRequired, + navigator: PropTypes.object, + onBack: PropTypes.func.isRequired, + theme: PropTypes.object.isRequired, + }; + + static contextTypes = { + intl: intlShape, + }; + + constructor(props) { + super(props); + + this.state = { + value: '', + timezones: props.timezones, + }; + } + + filteredTimezones = (timezonePrefix) => { + if (timezonePrefix.length === 0) { + return this.state.timezones; + } + + const lowerCasePrefix = timezonePrefix.toLowerCase(); + + return this.state.timezones.filter((t) => ( + getTimezoneRegion(t).toLowerCase().indexOf(lowerCasePrefix) >= 0 || + t.toLowerCase().indexOf(lowerCasePrefix) >= 0 + )); + }; + + timezoneSelected = (timezone) => { + this.props.onBack(timezone); + this.props.navigator.pop(); + }; + + handleTextChanged = (value) => { + this.setState({value}); + }; + + keyExtractor = (item) => item; + + getItemLayout = (data, index) => ({ + length: ITEM_HEIGHT, + offset: ITEM_HEIGHT * index, + index, + }); + + renderItem = ({item: timezone}) => { + return ( + + ); + }; + + render() { + const {theme, initialScrollIndex} = this.props; + const {value} = this.state; + const {intl} = this.context; + const style = getStyleSheet(theme); + + const searchBarInput = { + backgroundColor: changeOpacity(theme.sidebarHeaderTextColor, 0.2), + color: theme.sidebarHeaderTextColor, + fontSize: 15, + }; + + return ( + + + + + + + + ); + } +} + +const getStyleSheet = makeStyleSheetFromTheme((theme) => { + return { + container: { + flex: 1, + backgroundColor: theme.centerChannelBg, + }, + header: { + backgroundColor: theme.sidebarHeaderBg, + width: '100%', + ...Platform.select({ + android: { + height: 46, + justifyContent: 'center', + }, + ios: { + height: 44, + }, + }), + }, + }; +}); diff --git a/app/screens/timezone/select_timezone/select_timezone_row.js b/app/screens/timezone/select_timezone/select_timezone_row.js new file mode 100644 index 000000000..11f62df94 --- /dev/null +++ b/app/screens/timezone/select_timezone/select_timezone_row.js @@ -0,0 +1,78 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {PureComponent} from 'react'; +import PropTypes from 'prop-types'; +import { + TouchableOpacity, + Text, + View, +} from 'react-native'; + +import CheckMark from 'app/components/checkmark'; +import {makeStyleSheetFromTheme} from 'app/utils/theme'; + +const ITEM_HEIGHT = 45; + +export default class SelectTimezoneRow extends PureComponent { + static propTypes = { + theme: PropTypes.object.isRequired, + timezone: PropTypes.string.isRequired, + selectedTimezone: PropTypes.string, + onPress: PropTypes.func.isRequired, + }; + + timezoneSelected = () => { + const {timezone, onPress} = this.props; + onPress(timezone); + }; + + render() { + const {theme, timezone, selectedTimezone} = this.props; + const styles = getStyleSheet(theme); + + const selected = timezone === selectedTimezone && ( + + ); + + return ( + + + + {timezone} + + + {selected} + + ); + } +} + +const getStyleSheet = makeStyleSheetFromTheme((theme) => { + return { + itemContainer: { + flexDirection: 'row', + alignItems: 'center', + width: '100%', + paddingHorizontal: 15, + height: ITEM_HEIGHT, + }, + item: { + alignItems: 'center', + flex: 1, + flexDirection: 'row', + }, + itemText: { + fontSize: 15, + color: theme.centerChannelColor, + }, + }; +}); diff --git a/app/screens/timezone/timezone.js b/app/screens/timezone/timezone.js new file mode 100644 index 000000000..9c83756af --- /dev/null +++ b/app/screens/timezone/timezone.js @@ -0,0 +1,232 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {PureComponent} from 'react'; +import PropTypes from 'prop-types'; +import { + View, + Text, + Platform, +} from 'react-native'; +import {intlShape} from 'react-intl'; + +import {getTimezoneRegion} from 'mattermost-redux/utils/timezone_utils'; + +import FormattedText from 'app/components/formatted_text'; +import StatusBar from 'app/components/status_bar'; +import Section from 'app/screens/settings/section'; +import SectionItem from 'app/screens/settings/section_item'; +import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; +import {getDeviceTimezone} from 'app/utils/timezone'; + +export default class Timezone extends PureComponent { + static propTypes = { + navigator: PropTypes.object.isRequired, + theme: PropTypes.object.isRequired, + timezones: PropTypes.array.isRequired, + user: PropTypes.object.isRequired, + userTimezone: PropTypes.shape({ + useAutomaticTimezone: PropTypes.bool.isRequired, + automaticTimezone: PropTypes.string.isRequired, + manualTimezone: PropTypes.string.isRequired, + }).isRequired, + actions: PropTypes.shape({ + getSupportedTimezones: PropTypes.func.isRequired, + updateUser: PropTypes.func.isRequired, + }).isRequired, + }; + + static contextTypes = { + intl: intlShape, + }; + + constructor(props) { + super(props); + + this.state = { + useAutomaticTimezone: props.userTimezone.useAutomaticTimezone, + }; + } + + componentWillMount() { + const {actions: {getSupportedTimezones}, timezones} = this.props; + + if (timezones.length === 0) { + getSupportedTimezones(); + } + } + + updateAutomaticTimezone = (useAutomaticTimezone) => { + const {userTimezone: {manualTimezone}} = this.props; + let automaticTimezone = ''; + + this.setState({useAutomaticTimezone}); + if (useAutomaticTimezone) { + automaticTimezone = getDeviceTimezone(); + this.submitUser({ + useAutomaticTimezone, + automaticTimezone, + manualTimezone, + }); + return; + } + + if (manualTimezone.length > 0) { + // Preserve state change in server if manualTimezone exists + this.submitUser({ + useAutomaticTimezone, + automaticTimezone, + manualTimezone, + }); + } + }; + + updateManualTimezone = (manualTimezone) => { + this.submitUser({ + useAutomaticTimezone: false, + automaticTimezone: '', + manualTimezone, + }); + }; + + submitUser = ({ + useAutomaticTimezone, + automaticTimezone, + manualTimezone, + }) => { + const {user} = this.props; + + const timezone = { + useAutomaticTimezone: useAutomaticTimezone.toString(), + automaticTimezone, + manualTimezone, + }; + + const updatedUser = { + ...user, + timezone, + }; + + this.props.actions.updateUser(updatedUser); + }; + + goToSelectTimezone = () => { + const { + userTimezone: {manualTimezone}, + navigator, + theme, + } = this.props; + const {intl} = this.context; + this.goingBack = false; + + navigator.push({ + backButtonTitle: '', + screen: 'SelectTimezone', + title: intl.formatMessage({id: 'mobile.timezone_settings.select', defaultMessage: 'Select Timezone'}), + animated: true, + navigatorStyle: { + navBarTextColor: theme.sidebarHeaderTextColor, + navBarBackgroundColor: theme.sidebarHeaderBg, + navBarButtonColor: theme.sidebarHeaderTextColor, + screenBackgroundColor: theme.centerChannelBg, + }, + passProps: { + selectedTimezone: manualTimezone, + onBack: this.updateManualTimezone, + }, + }); + }; + + render() { + const { + theme, + userTimezone: { + automaticTimezone, + manualTimezone, + }, + } = this.props; + const {useAutomaticTimezone} = this.state; + const style = getStyleSheet(theme); + + return ( + + + +
+ + + )} + description={( + + {useAutomaticTimezone && getTimezoneRegion(automaticTimezone)} + + )} + action={this.updateAutomaticTimezone} + actionType='toggle' + selected={useAutomaticTimezone} + theme={theme} + /> + + {!useAutomaticTimezone && ( + + + + )} + description={( + + {getTimezoneRegion(manualTimezone)} + + )} + action={this.goToSelectTimezone} + actionType='arrow' + theme={theme} + /> + + )} + +
+
+
+ ); + } +} + +const getStyleSheet = makeStyleSheetFromTheme((theme) => { + return { + container: { + flex: 1, + backgroundColor: theme.centerChannelBg, + }, + wrapper: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.06), + flex: 1, + ...Platform.select({ + ios: { + paddingTop: 35, + }, + }), + }, + divider: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.1), + height: 1, + }, + separator: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.1), + height: 1, + marginLeft: 15, + }, + }; +}); diff --git a/app/screens/user_profile/index.js b/app/screens/user_profile/index.js index 4738327d8..94db56556 100644 --- a/app/screens/user_profile/index.js +++ b/app/screens/user_profile/index.js @@ -7,14 +7,19 @@ import {connect} from 'react-redux'; import {setChannelDisplayName} from 'app/actions/views/channel'; import {makeDirectChannel} from 'app/actions/views/more_dms'; -import {getTeammateNameDisplaySetting, getTheme} from 'mattermost-redux/selectors/entities/preferences'; +import {getTeammateNameDisplaySetting, getTheme, getBool} from 'mattermost-redux/selectors/entities/preferences'; import {getConfig} from 'mattermost-redux/selectors/entities/general'; +import Preferences from 'mattermost-redux/constants/preferences'; + +import {isTimezoneEnabled} from 'app/utils/timezone'; import UserProfile from './user_profile'; function mapStateToProps(state, ownProps) { const config = getConfig(state); const {createChannel: createChannelRequest} = state.requests.channels; + const militaryTime = getBool(state, Preferences.CATEGORY_DISPLAY_SETTINGS, 'use_military_time'); + const enableTimezone = isTimezoneEnabled(state); return { config, @@ -22,6 +27,8 @@ function mapStateToProps(state, ownProps) { currentDisplayName: state.views.channel.displayName, user: state.entities.users.profiles[ownProps.userId], teammateNameDisplay: getTeammateNameDisplaySetting(state), + enableTimezone, + militaryTime, theme: getTheme(state), }; } diff --git a/app/screens/user_profile/user_profile.js b/app/screens/user_profile/user_profile.js index c141e4c34..e0147865e 100644 --- a/app/screens/user_profile/user_profile.js +++ b/app/screens/user_profile/user_profile.js @@ -12,8 +12,11 @@ import { import {intlShape} from 'react-intl'; import {displayUsername} from 'mattermost-redux/utils/user_utils'; +import {getUserCurrentTimezone} from 'mattermost-redux/utils/timezone_utils'; import ProfilePicture from 'app/components/profile_picture'; +import FormattedText from 'app/components/formatted_text'; +import FormattedTime from 'app/components/formatted_time'; import StatusBar from 'app/components/status_bar'; import {alertErrorWithFallback} from 'app/utils/general'; import {changeOpacity, makeStyleSheetFromTheme, setNavigatorStyles} from 'app/utils/theme'; @@ -33,6 +36,8 @@ export default class UserProfile extends PureComponent { teammateNameDisplay: PropTypes.string, theme: PropTypes.object.isRequired, user: PropTypes.object.isRequired, + militaryTime: PropTypes.bool.isRequired, + enableTimezone: PropTypes.bool.isRequired, }; static contextTypes = { @@ -91,6 +96,34 @@ export default class UserProfile extends PureComponent { return null; }; + buildTimezoneBlock = () => { + const {theme, user, militaryTime} = this.props; + const style = createStyleSheet(theme); + + const currentTimezone = getUserCurrentTimezone(user.timezone); + if (!currentTimezone) { + return null; + } + const nowDate = new Date(); + + return ( + + + + + + + ); + }; + sendMessage = async () => { const {intl} = this.context; const {actions, currentDisplayName, teammateNameDisplay, user} = this.props; @@ -162,7 +195,7 @@ export default class UserProfile extends PureComponent { }; render() { - const {config, theme, user} = this.props; + const {config, theme, user, enableTimezone} = this.props; const style = createStyleSheet(theme); if (!user) { @@ -186,6 +219,7 @@ export default class UserProfile extends PureComponent { {`@${user.username}`}
+ {enableTimezone && this.buildTimezoneBlock()} {this.buildDisplayBlock('username')} {config.ShowEmailAddress === 'true' && this.buildDisplayBlock('email')} {this.buildDisplayBlock('position')} diff --git a/app/utils/timezone.js b/app/utils/timezone.js new file mode 100644 index 000000000..df8da9520 --- /dev/null +++ b/app/utils/timezone.js @@ -0,0 +1,16 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import DeviceInfo from 'react-native-device-info'; + +import {isMinimumServerVersion} from 'mattermost-redux/utils/helpers'; + +export function getDeviceTimezone() { + return DeviceInfo.getTimezone(); +} + +export function isTimezoneEnabled(state) { + const {config} = state.entities.general; + const serverVersion = state.entities.general.serverVersion; + return config.ExperimentalTimezone === 'true' && isMinimumServerVersion(serverVersion, 4, 9); +} diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index c37d253f0..1c84834ba 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -2229,6 +2229,7 @@ "mobile.advanced_settings.reset_message": "\nThis will reset all offline data and restart the app. You will be automatically logged back in once the app restarts.\n", "mobile.advanced_settings.reset_title": "Reset Cache", "mobile.advanced_settings.title": "Advanced Settings", + "mobile.advanced_settings.timezone": "Timezone", "mobile.android.camera_permission_denied_description": "To take photos and videos with your camera, please change your permission settings.", "mobile.android.camera_permission_denied_title": "Camera access is required", "mobile.android.permission_denied_dismiss": "Dismiss", @@ -2500,6 +2501,7 @@ "mobile.routes.thread_dm": "Direct Message Thread", "mobile.routes.user_profile": "Profile", "mobile.routes.user_profile.send_message": "Send Message", + "mobile.routes.user_profile.local_time": "LOCAL TIME", "mobile.search.from_modifier_description": "to find posts from specific users", "mobile.search.from_modifier_title": "username", "mobile.search.in_modifier_description": "to find posts in specific channels", @@ -2533,6 +2535,9 @@ "mobile.share_extension.send": "Send", "mobile.share_extension.team": "Team", "mobile.suggestion.members": "Members", + "mobile.timezone_settings.automatically": "Set automatically", + "mobile.timezone_settings.manual": "Change timezone", + "mobile.timezone_settings.select": "Select Timezone", "mobile.video.save_error_message": "To save the video file you need to download it first.", "mobile.video.save_error_title": "Save Video Error", "mobile.video_playback.failed_description": "An error occurred while trying to play the video.\n", diff --git a/package-lock.json b/package-lock.json index 0c3a652be..e6bdf260a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5155,7 +5155,7 @@ }, "commonmark": { "version": "github:mattermost/commonmark.js#e5c34706cafa2924370772ff47937b833648a6de", - "from": "github:mattermost/commonmark.js#e5c34706cafa2924370772ff47937b833648a6de", + "from": "commonmark@github:mattermost/commonmark.js#e5c34706cafa2924370772ff47937b833648a6de", "requires": { "entities": "~ 1.1.1", "mdurl": "~ 1.0.1", @@ -15141,7 +15141,7 @@ }, "redux-offline": { "version": "git+https://github.com/enahum/redux-offline.git#4bd85e7e3b279a2b11fb4d587808d583d2b5e7b5", - "from": "git+https://github.com/enahum/redux-offline.git#4bd85e7e3b279a2b11fb4d587808d583d2b5e7b5", + "from": "redux-offline@git+https://github.com/enahum/redux-offline.git#4bd85e7e3b279a2b11fb4d587808d583d2b5e7b5", "requires": { "redux-persist": "^4.5.0" } @@ -15714,7 +15714,7 @@ }, "rn-placeholder": { "version": "github:enahum/rn-placeholder#4b065f892d7a7f9d921a969f2e72e609ebc0a212", - "from": "github:enahum/rn-placeholder#4b065f892d7a7f9d921a969f2e72e609ebc0a212", + "from": "rn-placeholder@github:enahum/rn-placeholder#4b065f892d7a7f9d921a969f2e72e609ebc0a212", "requires": { "prop-types": "^15.5.10" } diff --git a/packager/modulePaths.js b/packager/modulePaths.js index b8d55d28d..60a3ffbdc 100644 --- a/packager/modulePaths.js +++ b/packager/modulePaths.js @@ -338,6 +338,7 @@ module.exports = ['./node_modules/react-native/Libraries/ART/ARTSerializablePath './node_modules/app/utils/tap.js', './node_modules/app/utils/theme.js', './node_modules/app/utils/time_tracker.js', + './node_modules/app/utils/timezone.js', './node_modules/app/utils/tooltip.js', './node_modules/app/utils/url.js', './node_modules/app/utils/wrap_context_provider.js', @@ -690,6 +691,7 @@ module.exports = ['./node_modules/react-native/Libraries/ART/ARTSerializablePath './node_modules/node_modules/mattermost-redux/actions/roles.js', './node_modules/node_modules/mattermost-redux/actions/search.js', './node_modules/node_modules/mattermost-redux/actions/teams.js', + './node_modules/node_modules/mattermost-redux/actions/timezone.js', './node_modules/node_modules/mattermost-redux/actions/users.js', './node_modules/node_modules/mattermost-redux/actions/websocket.js', './node_modules/node_modules/mattermost-redux/client/client.js', @@ -762,6 +764,7 @@ module.exports = ['./node_modules/react-native/Libraries/ART/ARTSerializablePath './node_modules/node_modules/mattermost-redux/selectors/entities/preferences.js', './node_modules/node_modules/mattermost-redux/selectors/entities/roles.js', './node_modules/node_modules/mattermost-redux/selectors/entities/teams.js', + './node_modules/node_modules/mattermost-redux/selectors/entities/timezone.js', './node_modules/node_modules/mattermost-redux/selectors/entities/typing.js', './node_modules/node_modules/mattermost-redux/selectors/entities/users.js', './node_modules/node_modules/mattermost-redux/store/configureStore.prod.js', @@ -781,6 +784,7 @@ module.exports = ['./node_modules/react-native/Libraries/ART/ARTSerializablePath './node_modules/node_modules/mattermost-redux/utils/preference_utils.js', './node_modules/node_modules/mattermost-redux/utils/team_utils.js', './node_modules/node_modules/mattermost-redux/utils/theme_utils.js', + './node_modules/node_modules/mattermost-redux/utils/timezone_utils.js', './node_modules/node_modules/mattermost-redux/utils/user_utils.js', './node_modules/node_modules/mdurl/decode.js', './node_modules/node_modules/mdurl/encode.js',