Migrate to moment to fix timestamps based on timezone or local time (#3126)
This commit is contained in:
parent
8416525487
commit
b8b7d54185
10 changed files with 150 additions and 129 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}/>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue