Migrate to moment to fix timestamps based on timezone or local time (#3126)

This commit is contained in:
Elias Nahum 2019-08-15 12:32:16 -04:00
parent 8416525487
commit b8b7d54185
No known key found for this signature in database
GPG key ID: E038DB71E0B61702
10 changed files with 150 additions and 129 deletions

View file

@ -3,35 +3,33 @@
import React from 'react';
import PropTypes from 'prop-types';
import {injectIntl, intlShape} from 'react-intl';
import moment from 'moment-timezone';
import {Text} from 'react-native';
class FormattedDate extends React.PureComponent {
export default class FormattedDate extends React.PureComponent {
static propTypes = {
intl: intlShape.isRequired,
value: PropTypes.any.isRequired,
format: PropTypes.string,
children: PropTypes.func,
timeZone: PropTypes.string,
value: PropTypes.any.isRequired,
};
static defaultProps = {
format: 'ddd, MMM DD, YYYY',
};
render() {
const {
intl,
format,
timeZone,
value,
children,
...props
} = this.props;
Reflect.deleteProperty(props, 'format');
const formattedDate = intl.formatDate(value, this.props);
if (typeof children === 'function') {
return children(formattedDate);
let formattedDate = moment(value).format(format);
if (timeZone) {
formattedDate = moment.tz(value, timeZone).format(format);
}
return <Text {...props}>{formattedDate}</Text>;
}
}
export default injectIntl(FormattedDate);

View file

@ -3,7 +3,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import {intlShape} from 'react-intl';
import {Text} from 'react-native';
import moment from 'moment-timezone';
@ -15,30 +14,19 @@ export default class FormattedTime extends React.PureComponent {
hour12: PropTypes.bool,
};
static contextTypes = {
intl: intlShape.isRequired,
};
getFormattedTime = () => {
const {intl} = this.context;
const {
value,
timeZone,
hour12,
} = this.props;
const format = hour12 ? 'hh:mm A' : 'HH:mm';
if (timeZone) {
const format = hour12 ? 'hh:mm A' : 'HH:mm';
return moment.tz(value, timeZone).format(format);
}
// If no timezone is defined fallback to the previous implementation
return intl.formatDate(new Date(value), {
hour: 'numeric',
minute: 'numeric',
hour12,
});
return moment(value).format(format);
};
render() {

View file

@ -202,7 +202,10 @@ export default class PostHeader extends PureComponent {
dateComponent = (
<View style={style.datetime}>
<Text style={style.time}>
<FormattedDate value={createAt}/>
<FormattedDate
timeZone={userTimezone}
value={createAt}
/>
</Text>
<Text style={style.time}>
<FormattedTime

View file

@ -31,9 +31,8 @@ exports[`DateHeader component should match snapshot when timezone is set 1`] = `
}
}
>
<InjectIntl(FormattedDate)
day="2-digit"
month="short"
<FormattedDate
format="ddd, MMM DD, YYYY"
style={
Object {
"color": "#3d3c40",
@ -41,9 +40,8 @@ exports[`DateHeader component should match snapshot when timezone is set 1`] = `
"fontWeight": "600",
}
}
value={1970-01-18T17:19:12.392Z}
weekday="short"
year="numeric"
timeZone="America/New_York"
value={1531152392}
/>
</View>
<View
@ -90,9 +88,8 @@ exports[`DateHeader component should match snapshot with suffix 1`] = `
}
}
>
<InjectIntl(FormattedDate)
day="2-digit"
month="short"
<FormattedDate
format="ddd, MMM DD, YYYY"
style={
Object {
"color": "#3d3c40",
@ -100,9 +97,8 @@ exports[`DateHeader component should match snapshot with suffix 1`] = `
"fontWeight": "600",
}
}
value={1970-01-18T17:19:12.392Z}
weekday="short"
year="numeric"
timeZone={null}
value={1531152392}
/>
</View>
<View
@ -149,9 +145,8 @@ exports[`DateHeader component should match snapshot without suffix 1`] = `
}
}
>
<InjectIntl(FormattedDate)
day="2-digit"
month="short"
<FormattedDate
format="ddd, MMM DD, YYYY"
style={
Object {
"color": "#3d3c40",
@ -159,9 +154,8 @@ exports[`DateHeader component should match snapshot without suffix 1`] = `
"fontWeight": "600",
}
}
value={1970-01-18T17:19:12.392Z}
weekday="short"
year="numeric"
timeZone={null}
value={1531152392}
/>
</View>
<View

View file

@ -7,7 +7,6 @@ import {
View,
ViewPropTypes,
} from 'react-native';
import moment from 'moment-timezone';
import FormattedDate from 'app/components/formatted_date';
import {makeStyleSheetFromTheme} from 'app/utils/theme';
@ -25,25 +24,14 @@ export default class DateHeader extends PureComponent {
const {theme, timeZone, date} = this.props;
const style = getStyleSheet(theme);
const dateFormatProps = {
weekday: 'short',
day: '2-digit',
month: 'short',
year: 'numeric',
value: new Date(date),
};
if (timeZone) {
dateFormatProps.value = moment.tz(date, timeZone).toDate();
}
return (
<View style={[style.container, this.props.style]}>
<View style={style.line}/>
<View style={style.dateContainer}>
<FormattedDate
style={style.date}
{...dateFormatProps}
timeZone={timeZone}
value={date}
/>
</View>
<View style={style.line}/>

View file

@ -4,6 +4,7 @@
import 'intl';
import {addLocaleData} from 'react-intl';
import enLocaleData from 'react-intl/locale-data/en';
import moment from 'moment';
import en from 'assets/i18n/en.json';
@ -16,72 +17,92 @@ addLocaleData(enLocaleData);
function loadTranslation(locale) {
try {
let localeData;
let momentData;
switch (locale) {
case 'de':
TRANSLATIONS.de = require('assets/i18n/de.json');
localeData = require('react-intl/locale-data/de');
momentData = require('moment/locale/de');
break;
case 'es':
TRANSLATIONS.es = require('assets/i18n/es.json');
localeData = require('react-intl/locale-data/es');
momentData = require('moment/locale/es');
break;
case 'fr':
TRANSLATIONS.fr = require('assets/i18n/fr.json');
localeData = require('react-intl/locale-data/fr');
momentData = require('moment/locale/fr');
break;
case 'it':
TRANSLATIONS.it = require('assets/i18n/it.json');
localeData = require('react-intl/locale-data/it');
momentData = require('moment/locale/it');
break;
case 'ja':
TRANSLATIONS.ja = require('assets/i18n/ja.json');
localeData = require('react-intl/locale-data/ja');
momentData = require('moment/locale/ja');
break;
case 'ko':
TRANSLATIONS.ko = require('assets/i18n/ko.json');
localeData = require('react-intl/locale-data/ko');
momentData = require('moment/locale/ko');
break;
case 'nl':
TRANSLATIONS.nl = require('assets/i18n/nl.json');
localeData = require('react-intl/locale-data/nl');
momentData = require('moment/locale/nl');
break;
case 'pl':
TRANSLATIONS.pl = require('assets/i18n/pl.json');
localeData = require('react-intl/locale-data/pl');
momentData = require('moment/locale/pl');
break;
case 'pt-BR':
TRANSLATIONS[locale] = require('assets/i18n/pt-BR.json');
localeData = require('react-intl/locale-data/pt');
momentData = require('moment/locale/pt-br');
break;
case 'ro':
TRANSLATIONS.ro = require('assets/i18n/ro.json');
localeData = require('react-intl/locale-data/ro');
momentData = require('moment/locale/ro');
break;
case 'ru':
TRANSLATIONS.ru = require('assets/i18n/ru.json');
localeData = require('react-intl/locale-data/ru');
momentData = require('moment/locale/ru');
break;
case 'tr':
TRANSLATIONS.tr = require('assets/i18n/tr.json');
localeData = require('react-intl/locale-data/tr');
momentData = require('moment/locale/tr');
break;
case 'uk':
TRANSLATIONS.tr = require('assets/i18n/uk.json');
localeData = require('react-intl/locale-data/uk');
momentData = require('moment/locale/uk');
break;
case 'zh-CN':
TRANSLATIONS[locale] = require('assets/i18n/zh-CN.json');
localeData = require('react-intl/locale-data/zh');
momentData = require('moment/locale/zh-cn');
break;
case 'zh-TW':
TRANSLATIONS[locale] = require('assets/i18n/zh-TW.json');
localeData = require('react-intl/locale-data/zh');
momentData = require('moment/locale/zh-tw');
break;
}
if (localeData) {
addLocaleData(localeData);
}
if (momentData) {
moment.updateLocale(locale, momentData);
}
} catch (e) {
console.error('NO Translation found', e); //eslint-disable-line no-console
}

View file

@ -4,25 +4,35 @@
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import intitialState from 'app/initial_state';
import PushNotification from 'app/push_notifications';
import GlobalEventHandler from './global_event_handler';
jest.mock('app/init/credentials', () => ({
getCurrentServerUrl: jest.fn().mockResolvedValue(''),
getAppCredentials: jest.fn(),
removeAppCredentials: jest.fn(),
}));
jest.mock('react-native-notifications', () => ({
addEventListener: jest.fn(),
cancelAllLocalNotifications: jest.fn(),
setBadgesCount: jest.fn(),
NotificationAction: jest.fn(),
NotificationCategory: jest.fn(),
}));
const mockStore = configureMockStore([thunk]);
const store = mockStore({});
const store = mockStore(intitialState);
GlobalEventHandler.store = store;
// TODO: Add Android test as part of https://mattermost.atlassian.net/browse/MM-17110
describe('GlobalEventHandler', () => {
it('should clear notifications on logout', () => {
it('should clear notifications on logout', async () => {
const clearNotifications = jest.spyOn(PushNotification, 'clearNotifications');
GlobalEventHandler.onLogout();
await GlobalEventHandler.onLogout();
expect(clearNotifications).toHaveBeenCalled();
});
});
});

View file

@ -36,6 +36,7 @@ export default class ChannelInfoHeader extends React.PureComponent {
isArchived: PropTypes.bool.isRequired,
isBot: PropTypes.bool.isRequired,
isGroupConstrained: PropTypes.bool,
timeZone: PropTypes.string,
};
static contextTypes = {
@ -98,6 +99,7 @@ export default class ChannelInfoHeader extends React.PureComponent {
isArchived,
isBot,
isGroupConstrained,
timeZone,
} = this.props;
const style = getStyleSheet(theme);
@ -128,76 +130,76 @@ export default class ChannelInfoHeader extends React.PureComponent {
{displayName}
</Text>
</View>
{this.renderHasGuestText(style)}
{purpose.length > 0 &&
<View style={style.section}>
<TouchableHighlight
underlayColor={changeOpacity(theme.centerChannelColor, 0.1)}
onLongPress={this.handlePurposeLongPress}
>
<View style={style.row}>
<FormattedText
style={style.header}
id='channel_info.purpose'
defaultMessage='Purpose'
/>
<Markdown
onPermalinkPress={onPermalinkPress}
baseTextStyle={baseTextStyle}
textStyles={textStyles}
blockStyles={blockStyles}
value={purpose}
/>
</View>
</TouchableHighlight>
</View>
<View style={style.section}>
<TouchableHighlight
underlayColor={changeOpacity(theme.centerChannelColor, 0.1)}
onLongPress={this.handlePurposeLongPress}
>
<View style={style.row}>
<FormattedText
style={style.header}
id='channel_info.purpose'
defaultMessage='Purpose'
/>
<Markdown
onPermalinkPress={onPermalinkPress}
baseTextStyle={baseTextStyle}
textStyles={textStyles}
blockStyles={blockStyles}
value={purpose}
/>
</View>
</TouchableHighlight>
</View>
}
{header.length > 0 &&
<View style={style.section}>
<TouchableHighlight
underlayColor={changeOpacity(theme.centerChannelColor, 0.1)}
onLongPress={this.handleHeaderLongPress}
>
<View style={style.row}>
<FormattedText
style={style.header}
id='channel_info.header'
defaultMessage='Header'
/>
<Markdown
onPermalinkPress={onPermalinkPress}
baseTextStyle={baseTextStyle}
textStyles={textStyles}
blockStyles={blockStyles}
value={header}
/>
</View>
</TouchableHighlight>
</View>
<View style={style.section}>
<TouchableHighlight
underlayColor={changeOpacity(theme.centerChannelColor, 0.1)}
onLongPress={this.handleHeaderLongPress}
>
<View style={style.row}>
<FormattedText
style={style.header}
id='channel_info.header'
defaultMessage='Header'
/>
<Markdown
onPermalinkPress={onPermalinkPress}
baseTextStyle={baseTextStyle}
textStyles={textStyles}
blockStyles={blockStyles}
value={header}
/>
</View>
</TouchableHighlight>
</View>
}
{isGroupConstrained &&
<Text style={[style.createdBy, style.row]}>
<FormattedText
id='mobile.routes.channelInfo.groupManaged'
defaultMessage='Members are managed by linked groups'
/>
</Text>
<Text style={[style.createdBy, style.row]}>
<FormattedText
id='mobile.routes.channelInfo.groupManaged'
defaultMessage='Members are managed by linked groups'
/>
</Text>
}
{creator &&
<Text style={[style.createdBy, style.row]}>
<FormattedText
id='mobile.routes.channelInfo.createdBy'
defaultMessage='Created by {creator} on '
values={{
creator,
}}
/>
<FormattedDate
value={new Date(createAt)}
year='numeric'
month='long'
day='2-digit'
/>
</Text>
<Text style={[style.createdBy, style.row]}>
<FormattedText
id='mobile.routes.channelInfo.createdBy'
defaultMessage='Created by {creator} on '
values={{
creator,
}}
/>
<FormattedDate
format='LL'
timeZone={timeZone}
value={createAt}
/>
</Text>
}
</View>
);

View file

@ -29,6 +29,8 @@ import {getCurrentUserId, getUser, getStatusForUserId, getCurrentUserRoles} from
import {areChannelMentionsIgnored, getUserIdFromChannelName, isChannelMuted, showDeleteOption, showManagementOptions} from 'mattermost-redux/utils/channel_utils';
import {isAdmin as checkIsAdmin, isChannelAdmin as checkIsChannelAdmin, isSystemAdmin as checkIsSystemAdmin} from 'mattermost-redux/utils/user_utils';
import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general';
import {isTimezoneEnabled} from 'mattermost-redux/selectors/entities/timezone';
import {getUserCurrentTimezone} from 'mattermost-redux/utils/timezone_utils';
import {
popTopScreen,
@ -45,6 +47,7 @@ import {
selectPenultimateChannel,
setChannelDisplayName,
} from 'app/actions/views/channel';
import {isLandscape} from 'app/selectors/device';
import ChannelInfo from './channel_info';
@ -87,6 +90,12 @@ function mapStateToProps(state) {
const canEditChannel = !channelIsReadOnly && showManagementOptions(state, config, license, currentChannel, isAdmin, isSystemAdmin, isChannelAdmin);
const viewArchivedChannels = config.ExperimentalViewArchivedChannels === 'true';
const enableTimezone = isTimezoneEnabled(state);
let timeZone = null;
if (enableTimezone) {
timeZone = getUserCurrentTimezone(currentUser.timezone);
}
return {
canDeleteChannel: showDeleteOption(state, config, license, currentChannel, isAdmin, isSystemAdmin, isChannelAdmin),
viewArchivedChannels,
@ -103,6 +112,8 @@ function mapStateToProps(state) {
theme: getTheme(state),
canManageUsers,
isBot,
isLandscape: isLandscape(state),
timeZone,
};
}

View file

@ -8,6 +8,7 @@ configure({adapter: new Adapter()});
const mockImpl = new MockAsyncStorage();
jest.mock('@react-native-community/async-storage', () => mockImpl);
global.window = {};
/* eslint-disable no-console */
@ -38,6 +39,10 @@ jest.mock('NativeModules', () => {
SECURITY_LEVEL_SECURE_SOFTWARE: 'SOFTWARE',
SECURITY_LEVEL_SECURE_HARDWARE: 'HARDWARE',
},
RNCNetInfo: {
addEventListener: jest.fn(),
getCurrentState: jest.fn().mockResolvedValue({isConnected: true}),
},
};
});
jest.mock('NativeEventEmitter');
@ -48,6 +53,7 @@ jest.mock('react-native-device-info', () => {
getBuildNumber: () => '0',
getModel: () => 'iPhone X',
isTablet: () => false,
getDeviceLocale: () => 'en-US',
};
});