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