diff --git a/.eslintrc.json b/.eslintrc.json
index f95474148..85621dc15 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -47,7 +47,9 @@
"@typescript-eslint/no-use-before-define": 0,
"@typescript-eslint/no-var-requires": 0,
"@typescript-eslint/explicit-function-return-type": 0,
- "@typescript-eslint/explicit-module-boundary-types": "off"
+ "@typescript-eslint/explicit-module-boundary-types": "off",
+ "no-shadow": "off",
+ "@typescript-eslint/no-shadow": "error"
},
"overrides": [
{
diff --git a/app/components/custom_status/__snapshots__/custom_status_expiry.test.tsx.snap b/app/components/custom_status/__snapshots__/custom_status_expiry.test.tsx.snap
new file mode 100644
index 000000000..4ec31afe8
--- /dev/null
+++ b/app/components/custom_status/__snapshots__/custom_status_expiry.test.tsx.snap
@@ -0,0 +1,45 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`components/custom_status/custom_status_expiry should match snapshot 1`] = `
+
+
+ Today
+
+
+`;
+
+exports[`components/custom_status/custom_status_expiry should match snapshot with prefix and brackets 1`] = `
+
+ (
+
+ Until
+
+
+
+ Today
+
+ )
+
+`;
diff --git a/app/components/custom_status/custom_status_emoji.test.tsx b/app/components/custom_status/custom_status_emoji.test.tsx
index 3ed874891..3951137b4 100644
--- a/app/components/custom_status/custom_status_emoji.test.tsx
+++ b/app/components/custom_status/custom_status_emoji.test.tsx
@@ -6,6 +6,7 @@ import React from 'react';
import CustomStatusEmoji from '@components/custom_status/custom_status_emoji';
import * as CustomStatusSelectors from '@selectors/custom_status';
import {renderWithRedux} from 'test/testing_library';
+import {CustomStatusDuration} from '@mm-redux/types/users';
jest.mock('@selectors/custom_status');
@@ -14,6 +15,7 @@ describe('components/custom_status/custom_status_emoji', () => {
return {
emoji: 'calendar',
text: 'In a meeting',
+ duration: CustomStatusDuration.DONT_CLEAR,
};
};
(CustomStatusSelectors.makeGetCustomStatus as jest.Mock).mockReturnValue(getCustomStatus);
diff --git a/app/components/custom_status/custom_status_emoji.tsx b/app/components/custom_status/custom_status_emoji.tsx
index f5fcce0da..2b4c08c1f 100644
--- a/app/components/custom_status/custom_status_emoji.tsx
+++ b/app/components/custom_status/custom_status_emoji.tsx
@@ -7,7 +7,7 @@ import {useSelector} from 'react-redux';
import Emoji from '@components/emoji';
import {GlobalState} from '@mm-redux/types/store';
-import {makeGetCustomStatus} from '@selectors/custom_status';
+import {makeGetCustomStatus, isCustomStatusExpired} from '@selectors/custom_status';
interface ComponentProps {
emojiSize?: number;
@@ -21,7 +21,9 @@ const CustomStatusEmoji = ({emojiSize, userID, style, testID}: ComponentProps) =
const customStatus = useSelector((state: GlobalState) => {
return getCustomStatus(state, userID);
});
- if (!customStatus?.emoji) {
+ const customStatusExpired = useSelector((state: GlobalState) => isCustomStatusExpired(state, customStatus));
+
+ if (!customStatus?.emoji || customStatusExpired) {
return null;
}
diff --git a/app/components/custom_status/custom_status_expiry.test.tsx b/app/components/custom_status/custom_status_expiry.test.tsx
new file mode 100644
index 000000000..e1df7996b
--- /dev/null
+++ b/app/components/custom_status/custom_status_expiry.test.tsx
@@ -0,0 +1,61 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react';
+import moment from 'moment-timezone';
+
+import Preferences from '@mm-redux/constants/preferences';
+import * as PreferenceSelectors from '@mm-redux/selectors/entities/preferences';
+import {renderWithReduxIntl} from 'test/testing_library';
+import CustomStatusExpiry from './custom_status_expiry';
+
+jest.mock('@mm-redux/selectors/entities/preferences');
+
+describe('components/custom_status/custom_status_expiry', () => {
+ const date = moment().endOf('day');
+
+ const baseProps = {
+ theme: Preferences.THEMES.default,
+ time: date.toDate(),
+ };
+
+ (PreferenceSelectors.getBool as jest.Mock).mockReturnValue(false);
+ it('should match snapshot', () => {
+ const wrapper = renderWithReduxIntl(
+ ,
+ );
+ expect(wrapper.toJSON()).toMatchSnapshot();
+ expect(wrapper.getByText('Today')).toBeDefined();
+ });
+
+ it('should match snapshot with prefix and brackets', () => {
+ const props = {
+ ...baseProps,
+ showPrefix: true,
+ withinBrackets: true,
+ };
+ const wrapper = renderWithReduxIntl(
+ ,
+ );
+ expect(wrapper.toJSON()).toMatchSnapshot();
+ expect(wrapper.getByText('Until')).toBeDefined();
+ expect(wrapper.getByText('Today')).toBeDefined();
+ });
+
+ it("should render Tomorrow if given tomorrow's date", () => {
+ const props = {
+ ...baseProps,
+ time: moment().add(1, 'day').endOf('day').toDate(),
+ };
+ const wrapper = renderWithReduxIntl(
+ ,
+ );
+ expect(wrapper.getByText('Tomorrow')).toBeDefined();
+ });
+});
diff --git a/app/components/custom_status/custom_status_expiry.tsx b/app/components/custom_status/custom_status_expiry.tsx
new file mode 100644
index 000000000..1d5465772
--- /dev/null
+++ b/app/components/custom_status/custom_status_expiry.tsx
@@ -0,0 +1,124 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import moment from 'moment-timezone';
+import React from 'react';
+import {Text, TextStyle} from 'react-native';
+import {useSelector} from 'react-redux';
+
+import FormattedDate from '@components/formatted_date';
+import FormattedText from '@components/formatted_text';
+import FormattedTime from '@components/formatted_time';
+import Preferences from '@mm-redux/constants/preferences';
+import {getBool} from '@mm-redux/selectors/entities/preferences';
+import {getCurrentUserTimezone} from '@mm-redux/selectors/entities/timezone';
+import {makeStyleSheetFromTheme} from '@utils/theme';
+import {getCurrentMomentForTimezone} from '@utils/timezone';
+
+import {Theme} from '@mm-redux/types/preferences';
+import {GlobalState} from '@mm-redux/types/store';
+
+type Props = {
+ theme: Theme;
+ time: Date;
+ textStyles?: TextStyle;
+ testID?: string;
+ showPrefix?: boolean;
+ withinBrackets?: boolean;
+ showToday?: boolean;
+ showTimeCompulsory?: boolean;
+}
+
+const CustomStatusExpiry = ({time, theme, textStyles = {}, showPrefix, withinBrackets, testID = '', showTimeCompulsory, showToday}: Props) => {
+ const timezone = useSelector(getCurrentUserTimezone);
+ const styles = createStyleSheet(theme);
+ const militaryTime = useSelector((state: GlobalState) => getBool(state, Preferences.CATEGORY_DISPLAY_SETTINGS, 'use_military_time'));
+ const currentMomentTime = getCurrentMomentForTimezone(timezone);
+
+ const expiryMomentTime = timezone ? moment(time).tz(timezone) : moment(time);
+
+ const plusSixDaysEndTime = currentMomentTime.clone().add(6, 'days').endOf('day');
+ const tomorrowEndTime = currentMomentTime.clone().add(1, 'day').endOf('day');
+ const todayEndTime = currentMomentTime.clone().endOf('day');
+ const isCurrentYear = currentMomentTime.get('y') === expiryMomentTime.get('y');
+
+ let dateComponent;
+ if ((showToday && expiryMomentTime.isBefore(todayEndTime)) || expiryMomentTime.isSame(todayEndTime)) {
+ dateComponent = (
+
+ );
+ } else if (expiryMomentTime.isAfter(todayEndTime) && expiryMomentTime.isSameOrBefore(tomorrowEndTime)) {
+ dateComponent = (
+
+ );
+ } else if (expiryMomentTime.isAfter(tomorrowEndTime)) {
+ let format = 'dddd';
+ if (expiryMomentTime.isAfter(plusSixDaysEndTime) && isCurrentYear) {
+ format = 'MMM DD';
+ } else if (!isCurrentYear) {
+ format = 'MMM DD, YYYY';
+ }
+
+ dateComponent = (
+
+ );
+ }
+
+ const useTime = showTimeCompulsory || !(expiryMomentTime.isSame(todayEndTime) || expiryMomentTime.isAfter(tomorrowEndTime));
+
+ return (
+
+ {withinBrackets && '('}
+ {showPrefix && (
+
+ )}
+ {showPrefix && ' '}
+ {dateComponent}
+ {useTime && dateComponent && (
+ <>
+ {' '}
+
+ {' '}
+ >
+ )}
+ {useTime && (
+
+ )}
+ {withinBrackets && ')'}
+
+ );
+};
+
+export default CustomStatusExpiry;
+
+const createStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
+ return {
+ text: {
+ fontSize: 15,
+ color: theme.centerChannelColor,
+ },
+ };
+});
diff --git a/app/components/sidebars/settings/__snapshots__/settings_sidebar.test.js.snap b/app/components/sidebars/settings/__snapshots__/settings_sidebar.test.js.snap
index 4684891a9..b1c058148 100644
--- a/app/components/sidebars/settings/__snapshots__/settings_sidebar.test.js.snap
+++ b/app/components/sidebars/settings/__snapshots__/settings_sidebar.test.js.snap
@@ -31,3 +31,19 @@ exports[`SettingsSidebar should match snapshot with custom status enabled 1`] =
useNativeAnimations={true}
/>
`;
+
+exports[`SettingsSidebar should match snapshot with custom status expiry 1`] = `
+
+`;
diff --git a/app/components/sidebars/settings/index.js b/app/components/sidebars/settings/index.js
index 4195fb1e0..413520e16 100644
--- a/app/components/sidebars/settings/index.js
+++ b/app/components/sidebars/settings/index.js
@@ -8,7 +8,7 @@ import {unsetCustomStatus} from '@actions/views/custom_status';
import {setStatus} from '@mm-redux/actions/users';
import {getTheme} from '@mm-redux/selectors/entities/preferences';
import {getCurrentUser, getStatusForUserId} from '@mm-redux/selectors/entities/users';
-import {isCustomStatusEnabled, makeGetCustomStatus} from '@selectors/custom_status';
+import {isCustomStatusEnabled, isCustomStatusExpired, isCustomStatusExpirySupported, makeGetCustomStatus} from '@selectors/custom_status';
import {logout} from 'app/actions/views/user';
@@ -29,6 +29,8 @@ function makeMapStateToProps() {
theme: getTheme(state),
isCustomStatusEnabled: customStatusEnabled,
customStatus,
+ isCustomStatusExpired: customStatusEnabled ? isCustomStatusExpired(state, customStatus) : true,
+ isCustomStatusExpirySupported: customStatusEnabled ? isCustomStatusExpirySupported(state) : false,
};
};
}
diff --git a/app/components/sidebars/settings/settings_sidebar.test.js b/app/components/sidebars/settings/settings_sidebar.test.js
index cd7d1f53b..34caa7837 100644
--- a/app/components/sidebars/settings/settings_sidebar.test.js
+++ b/app/components/sidebars/settings/settings_sidebar.test.js
@@ -7,11 +7,13 @@ import {shallowWithIntl} from 'test/intl-test-helper';
import Preferences from '@mm-redux/constants/preferences';
import SettingsSidebar from './settings_sidebar.ios';
+import {CustomStatusDuration} from '@mm-redux/types/users';
describe('SettingsSidebar', () => {
const customStatus = {
emoji: 'calendar',
text: 'In a meeting',
+ duration: CustomStatusDuration.DONT_CLEAR,
};
const baseProps = {
@@ -26,6 +28,7 @@ describe('SettingsSidebar', () => {
status: 'offline',
theme: Preferences.THEMES.default,
isCustomStatusEnabled: false,
+ isCustomStatusExpired: false,
customStatus,
};
@@ -47,4 +50,20 @@ describe('SettingsSidebar', () => {
expect(wrapper.getElement()).toMatchSnapshot();
});
+
+ it('should match snapshot with custom status expiry', () => {
+ const wrapper = shallowWithIntl(
+ ,
+ );
+
+ expect(wrapper.getElement()).toMatchSnapshot();
+ });
});
diff --git a/app/components/sidebars/settings/settings_sidebar_base.js b/app/components/sidebars/settings/settings_sidebar_base.js
index b75e4ae98..d7c26719f 100644
--- a/app/components/sidebars/settings/settings_sidebar_base.js
+++ b/app/components/sidebars/settings/settings_sidebar_base.js
@@ -10,6 +10,7 @@ import EventEmitter from '@mm-redux/utils/event_emitter';
import {showModal, showModalOverCurrentContext, dismissModal} from '@actions/navigation';
import CompassIcon from '@components/compass_icon';
+import CustomStatusExpiry from '@components/custom_status/custom_status_expiry';
import CustomStatusText from '@components/custom_status/custom_status_text';
import ClearButton from '@components/custom_status/clear_button';
import Emoji from '@components/emoji';
@@ -37,6 +38,8 @@ export default class SettingsSidebarBase extends PureComponent {
theme: PropTypes.object.isRequired,
isCustomStatusEnabled: PropTypes.bool.isRequired,
customStatus: PropTypes.object,
+ isCustomStatusExpired: PropTypes.bool.isRequired,
+ isCustomStatusExpirySupported: PropTypes.bool.isRequired,
};
static defaultProps = {
@@ -77,7 +80,7 @@ export default class SettingsSidebarBase extends PureComponent {
handleCustomStatusChange = (prevCustomStatus, customStatus) => {
const isStatusSet = Boolean(customStatus?.emoji);
if (isStatusSet) {
- const isStatusChanged = prevCustomStatus?.emoji !== customStatus.emoji || prevCustomStatus?.text !== customStatus.text;
+ const isStatusChanged = prevCustomStatus?.emoji !== customStatus?.emoji || prevCustomStatus?.text !== customStatus?.text || prevCustomStatus?.expires_at !== customStatus?.expires_at;
if (isStatusChanged) {
this.setState({
showStatus: true,
@@ -240,7 +243,7 @@ export default class SettingsSidebarBase extends PureComponent {
}
renderCustomStatus = () => {
- const {isCustomStatusEnabled, customStatus, theme} = this.props;
+ const {isCustomStatusEnabled, customStatus, theme, isCustomStatusExpired, isCustomStatusExpirySupported} = this.props;
const {showStatus, showRetryMessage} = this.state;
if (!isCustomStatusEnabled) {
@@ -248,7 +251,7 @@ export default class SettingsSidebarBase extends PureComponent {
}
const style = getStyleSheet(theme);
- const isStatusSet = customStatus?.emoji && showStatus;
+ const isStatusSet = !isCustomStatusExpired && customStatus?.emoji && showStatus;
const customStatusEmoji = (
);
- const clearButton = isStatusSet ?
- (
-
- ) : null;
-
- const retryMessage = showRetryMessage ?
- (
-
- ) : null;
-
const text = isStatusSet ? customStatus.text : (
+ {Boolean(isStatusSet && isCustomStatusExpirySupported && customStatus?.duration) && (
+
+ )}
- {retryMessage}
- {clearButton &&
+ {showRetryMessage && (
+
+ )}
+ {isStatusSet && (
- {clearButton}
+
- }
+ )}
>
);
@@ -428,6 +433,11 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
customStatusIcon: {
color: changeOpacity(theme.centerChannelColor, 0.64),
},
+ customStatusExpiryText: {
+ paddingTop: 3,
+ fontSize: 15,
+ color: changeOpacity(theme.centerChannelColor, 0.35),
+ },
clearButton: {
position: 'absolute',
top: 4,
diff --git a/app/constants/custom_status.ts b/app/constants/custom_status.ts
index 29dea9894..855ead848 100644
--- a/app/constants/custom_status.ts
+++ b/app/constants/custom_status.ts
@@ -1,5 +1,48 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
+import {CustomStatusDuration} from '@mm-redux/types/users';
+import {t} from '@utils/i18n';
+
+const {
+ DONT_CLEAR,
+ THIRTY_MINUTES,
+ ONE_HOUR,
+ FOUR_HOURS,
+ TODAY,
+ THIS_WEEK,
+ DATE_AND_TIME,
+} = CustomStatusDuration;
+
+export const durationValues = {
+ [DONT_CLEAR]: {
+ id: t('custom_status.expiry_dropdown.dont_clear'),
+ defaultMessage: "Don't clear",
+ },
+ [THIRTY_MINUTES]: {
+ id: t('custom_status.expiry_dropdown.thirty_minutes'),
+ defaultMessage: '30 minutes',
+ },
+ [ONE_HOUR]: {
+ id: t('custom_status.expiry_dropdown.one_hour'),
+ defaultMessage: '1 hour',
+ },
+ [FOUR_HOURS]: {
+ id: t('custom_status.expiry_dropdown.four_hours'),
+ defaultMessage: '4 hours',
+ },
+ [TODAY]: {
+ id: t('custom_status.expiry_dropdown.today'),
+ defaultMessage: 'Today',
+ },
+ [THIS_WEEK]: {
+ id: t('custom_status.expiry_dropdown.this_week'),
+ defaultMessage: 'This week',
+ },
+ [DATE_AND_TIME]: {
+ id: t('custom_status.expiry_dropdown.date_and_time'),
+ defaultMessage: 'Date and Time',
+ },
+};
export const CUSTOM_STATUS_TEXT_CHARACTER_LIMIT = 100;
export const SET_CUSTOM_STATUS_FAILURE = 'set_custom_status_failure';
diff --git a/app/mm-redux/selectors/entities/timezone.ts b/app/mm-redux/selectors/entities/timezone.ts
index e0c4431a0..7b439d40b 100644
--- a/app/mm-redux/selectors/entities/timezone.ts
+++ b/app/mm-redux/selectors/entities/timezone.ts
@@ -1,11 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
+import {createSelector} from 'reselect';
+
+import {getCurrentUser} from '@mm-redux/selectors/entities/common';
+
import {GlobalState} from '@mm-redux/types/store';
+import {UserProfile} from '@mm-redux/types/users';
export function getUserTimezone(state: GlobalState, id: string) {
const profile = state.entities.users.profiles[id];
+ return getTimezoneForUserProfile(profile);
+}
+function getTimezoneForUserProfile(profile: UserProfile) {
if (profile && profile.timezone) {
return {
...profile.timezone,
@@ -24,3 +32,17 @@ export function isTimezoneEnabled(state: GlobalState) {
const {config} = state.entities.general;
return config.ExperimentalTimezone === 'true';
}
+
+export const getCurrentUserTimezone = createSelector(
+ getCurrentUser,
+ isTimezoneEnabled,
+ (user, enabledTimezone) => {
+ let timezone;
+ if (enabledTimezone) {
+ const userTimezone = getTimezoneForUserProfile(user);
+ timezone = userTimezone.useAutomaticTimezone ? userTimezone.automaticTimezone : userTimezone.manualTimezone;
+ }
+
+ return timezone;
+ },
+);
diff --git a/app/mm-redux/types/users.ts b/app/mm-redux/types/users.ts
index 6fe83c19b..f6c671a5d 100644
--- a/app/mm-redux/types/users.ts
+++ b/app/mm-redux/types/users.ts
@@ -80,7 +80,19 @@ export type UserStatus = {
active_channel?: string;
}
+export enum CustomStatusDuration {
+ DONT_CLEAR = '',
+ THIRTY_MINUTES = 'thirty_minutes',
+ ONE_HOUR = 'one_hour',
+ FOUR_HOURS = 'four_hours',
+ TODAY = 'today',
+ THIS_WEEK = 'this_week',
+ DATE_AND_TIME = 'date_and_time',
+}
+
export type UserCustomStatus = {
emoji: string;
text: string;
+ expires_at?: string;
+ duration: CustomStatusDuration;
}
diff --git a/app/screens/channel_info/__snapshots__/channel_info.test.js.snap b/app/screens/channel_info/__snapshots__/channel_info.test.js.snap
index 3c014e90d..09ecf8c1b 100644
--- a/app/screens/channel_info/__snapshots__/channel_info.test.js.snap
+++ b/app/screens/channel_info/__snapshots__/channel_info.test.js.snap
@@ -34,6 +34,7 @@ exports[`channelInfo should match snapshot 1`] = `
header=""
isArchived={false}
isCustomStatusEnabled={false}
+ isCustomStatusExpired={false}
isGroupConstrained={false}
isTeammateGuest={false}
memberCount={2}
diff --git a/app/screens/channel_info/__snapshots__/channel_info_header.test.js.snap b/app/screens/channel_info/__snapshots__/channel_info_header.test.js.snap
index 22110b1e7..49325807b 100644
--- a/app/screens/channel_info/__snapshots__/channel_info_header.test.js.snap
+++ b/app/screens/channel_info/__snapshots__/channel_info_header.test.js.snap
@@ -2034,7 +2034,6 @@ exports[`channel_info_header should match snapshot with custom status enabled 1`
"alignItems": "center",
"flexDirection": "row",
"paddingVertical": 10,
- "position": "relative",
},
]
}
@@ -2045,54 +2044,494 @@ exports[`channel_info_header should match snapshot with custom status enabled 1`
size={20}
testID="custom_status.emoji.calendar"
textStyle={
- Object {
- "color": "#3d3c40",
- "marginRight": 8,
- }
+ Array [
+ Object {
+ "color": "#3d3c40",
+ "marginRight": 8,
+ },
+ Object {
+ "bottom": 0,
+ },
+ ]
}
/>
-
+
+ theme={
+ Object {
+ "awayIndicator": "#ffbc42",
+ "buttonBg": "#166de0",
+ "buttonColor": "#ffffff",
+ "centerChannelBg": "#ffffff",
+ "centerChannelColor": "#3d3c40",
+ "codeTheme": "github",
+ "dndIndicator": "#f74343",
+ "errorTextColor": "#fd5960",
+ "linkColor": "#2389d7",
+ "mentionBg": "#ffffff",
+ "mentionBj": "#ffffff",
+ "mentionColor": "#145dbf",
+ "mentionHighlightBg": "#ffe577",
+ "mentionHighlightLink": "#166de0",
+ "newMessageSeparator": "#ff8800",
+ "onlineIndicator": "#06d6a0",
+ "sidebarBg": "#145dbf",
+ "sidebarHeaderBg": "#1153ab",
+ "sidebarHeaderTextColor": "#ffffff",
+ "sidebarText": "#ffffff",
+ "sidebarTextActiveBorder": "#579eff",
+ "sidebarTextActiveColor": "#ffffff",
+ "sidebarTextHoverBg": "#4578bf",
+ "sidebarUnreadText": "#ffffff",
+ "type": "Mattermost",
+ }
+ }
+ />
+
+
+
+
+
+
+
+ Purpose string
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`channel_info_header should match snapshot with custom status expiry 1`] = `
+
+
+
+
+ Channel name
+
+
+
+
+
+
+
+
}
diff --git a/app/screens/channel_info/channel_info.test.js b/app/screens/channel_info/channel_info.test.js
index eb62c9447..0d319b16c 100644
--- a/app/screens/channel_info/channel_info.test.js
+++ b/app/screens/channel_info/channel_info.test.js
@@ -49,6 +49,7 @@ describe('channelInfo', () => {
isDirectMessage: false,
isLandscape: false,
isCustomStatusEnabled: false,
+ isCustomStatusExpired: false,
actions: {
getChannelStats: jest.fn(),
getCustomEmojisInText: jest.fn(),
diff --git a/app/screens/channel_info/channel_info_header.js b/app/screens/channel_info/channel_info_header.js
index af346f74a..f7ddf04ad 100644
--- a/app/screens/channel_info/channel_info_header.js
+++ b/app/screens/channel_info/channel_info_header.js
@@ -14,6 +14,7 @@ import Clipboard from '@react-native-community/clipboard';
import {popToRoot} from '@actions/navigation';
import ChannelIcon from '@components/channel_icon';
+import CustomStatusExpiry from '@components/custom_status/custom_status_expiry';
import CustomStatusText from '@components/custom_status/custom_status_text';
import Emoji from '@components/emoji';
import FormattedDate from '@components/formatted_date';
@@ -47,6 +48,8 @@ export default class ChannelInfoHeader extends React.PureComponent {
timezone: PropTypes.string,
customStatus: PropTypes.object,
isCustomStatusEnabled: PropTypes.bool.isRequired,
+ isCustomStatusExpired: PropTypes.bool.isRequired,
+ isCustomStatusExpirySupported: PropTypes.bool.isRequired,
};
static contextTypes = {
@@ -147,6 +150,8 @@ export default class ChannelInfoHeader extends React.PureComponent {
timezone,
customStatus,
isCustomStatusEnabled,
+ isCustomStatusExpired,
+ isCustomStatusExpirySupported,
} = this.props;
const style = getStyleSheet(theme);
@@ -157,6 +162,9 @@ export default class ChannelInfoHeader extends React.PureComponent {
android: style.detail,
});
+ const showCustomStatus = isCustomStatusEnabled && type === General.DM_CHANNEL && customStatus?.emoji && !isCustomStatusExpired;
+ const showCustomStatusExpiry = Boolean(customStatus?.duration && isCustomStatusExpirySupported);
+
return (
@@ -180,7 +188,7 @@ export default class ChannelInfoHeader extends React.PureComponent {
{displayName}
- {isCustomStatusEnabled && type === General.DM_CHANNEL && customStatus?.emoji &&
+ {showCustomStatus && (
-
+
+
+ {showCustomStatusExpiry && (
+
+ )}
+
- }
+ )}
{this.renderHasGuestText(style)}
{purpose.length > 0 &&
@@ -299,16 +317,22 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
color: theme.centerChannelColor,
},
customStatusContainer: {
- position: 'relative',
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 10,
},
+ customStatus: {
+ width: '90%',
+ },
+ customStatusExpiry: {
+ color: changeOpacity(theme.centerChannelColor, 0.5),
+ fontSize: 13,
+ },
customStatusText: {
flex: 1,
fontSize: 15,
color: theme.centerChannelColor,
- width: '80%',
+ height: '100%',
},
channelNameContainer: {
flexDirection: 'row',
diff --git a/app/screens/channel_info/channel_info_header.test.js b/app/screens/channel_info/channel_info_header.test.js
index c84c520c8..d20d94ce9 100644
--- a/app/screens/channel_info/channel_info_header.test.js
+++ b/app/screens/channel_info/channel_info_header.test.js
@@ -5,6 +5,7 @@ import {shallow} from 'enzyme';
import Preferences from '@mm-redux/constants/preferences';
import {General} from '@mm-redux/constants';
+import {CustomStatusDuration} from '@mm-redux/types/users';
import ChannelInfoHeader from './channel_info_header.js';
@@ -44,6 +45,8 @@ describe('channel_info_header', () => {
isGroupConstrained: false,
testID: 'channel_info.header',
isCustomStatusEnabled: false,
+ isCustomStatusExpired: false,
+ isCustomStatusExpirySupported: false,
};
test('should match snapshot', async () => {
@@ -120,6 +123,7 @@ describe('channel_info_header', () => {
const customStatus = {
emoji: 'calendar',
text: 'In a meeting',
+ duration: CustomStatusDuration.DONT_CLEAR,
};
const wrapper = shallow(
@@ -133,4 +137,25 @@ describe('channel_info_header', () => {
);
expect(wrapper.getElement()).toMatchSnapshot();
});
+
+ test('should match snapshot with custom status expiry', () => {
+ const customStatus = {
+ emoji: 'calendar',
+ text: 'In a meeting',
+ duration: CustomStatusDuration.DATE_AND_TIME,
+ expires_at: '2200-04-13T18:09:12.451Z',
+ };
+
+ const wrapper = shallow(
+ ,
+ {context: {intl: intlMock}},
+ );
+ expect(wrapper.getElement()).toMatchSnapshot();
+ });
});
diff --git a/app/screens/channel_info/index.js b/app/screens/channel_info/index.js
index 9de3a5187..cb29bd562 100644
--- a/app/screens/channel_info/index.js
+++ b/app/screens/channel_info/index.js
@@ -13,7 +13,7 @@ import {getCurrentChannel, getCurrentChannelStats} from '@mm-redux/selectors/ent
import {getCurrentUserId, getUser} from '@mm-redux/selectors/entities/users';
import {getUserIdFromChannelName} from '@mm-redux/utils/channel_utils';
import {displayUsername} from '@mm-redux/utils/user_utils';
-import {makeGetCustomStatus, isCustomStatusEnabled} from '@selectors/custom_status';
+import {makeGetCustomStatus, isCustomStatusEnabled, isCustomStatusExpired, isCustomStatusExpirySupported} from '@selectors/custom_status';
import {isGuest} from '@utils/users';
import ChannelInfo from './channel_info';
@@ -34,6 +34,8 @@ function makeMapStateToProps() {
let isTeammateGuest = false;
let customStatusEnabled = false;
let customStatus;
+ let customStatusExpired = true;
+ let customStatusExpirySupported = false;
const isDirectMessage = currentChannel.type === General.DM_CHANNEL;
if (isDirectMessage) {
@@ -44,7 +46,9 @@ function makeMapStateToProps() {
currentChannelGuestCount = 1;
}
customStatusEnabled = isCustomStatusEnabled(state);
- customStatus = customStatusEnabled ? getCustomStatus(state, teammateId) : undefined;
+ customStatus = customStatusEnabled && getCustomStatus(state, teammateId);
+ customStatusExpired = customStatusEnabled ? isCustomStatusExpired(state, customStatus) : true;
+ customStatusExpirySupported = customStatusEnabled ? isCustomStatusExpirySupported(state) : false;
}
if (currentChannel.type === General.GM_CHANNEL) {
@@ -63,6 +67,8 @@ function makeMapStateToProps() {
theme: getTheme(state),
customStatus,
isCustomStatusEnabled: customStatusEnabled,
+ isCustomStatusExpired: customStatusExpired,
+ isCustomStatusExpirySupported: customStatusExpirySupported,
};
};
}
diff --git a/app/screens/custom_status/__snapshots__/custom_status_modal.test.tsx.snap b/app/screens/custom_status/__snapshots__/custom_status_modal.test.tsx.snap
index afc623737..a9c5ce781 100644
--- a/app/screens/custom_status/__snapshots__/custom_status_modal.test.tsx.snap
+++ b/app/screens/custom_status/__snapshots__/custom_status_modal.test.tsx.snap
@@ -11,6 +11,7 @@ exports[`screens/custom_status_modal should match snapshot 1`] = `
}
customStatus={
Object {
+ "duration": "",
"emoji": "calendar",
"text": "In a meeting",
}
@@ -45,6 +46,7 @@ exports[`screens/custom_status_modal should match snapshot 1`] = `
recentCustomStatuses={
Array [
Object {
+ "duration": "",
"emoji": "calendar",
"text": "In a meeting",
},
@@ -122,6 +124,7 @@ exports[`screens/custom_status_modal should match snapshot when user has no cust
recentCustomStatuses={
Array [
Object {
+ "duration": "",
"emoji": "calendar",
"text": "In a meeting",
},
@@ -170,6 +173,7 @@ exports[`screens/custom_status_modal should match snapshot when user has no rece
}
customStatus={
Object {
+ "duration": "",
"emoji": "calendar",
"text": "In a meeting",
}
diff --git a/app/screens/custom_status/__snapshots__/custom_status_suggestion.test.tsx.snap b/app/screens/custom_status/__snapshots__/custom_status_suggestion.test.tsx.snap
index 66ef1e248..0ddc9e51e 100644
--- a/app/screens/custom_status/__snapshots__/custom_status_suggestion.test.tsx.snap
+++ b/app/screens/custom_status/__snapshots__/custom_status_suggestion.test.tsx.snap
@@ -49,43 +49,45 @@ exports[`screens/custom_status_suggestion should match snapshot 1`] = `
}
}
>
-
+
+ />
+
@@ -141,95 +143,52 @@ exports[`screens/custom_status_suggestion should match snapshot with separator a
}
}
>
-
+
-
-
-
+ />
+
diff --git a/app/screens/custom_status/custom_status_modal.test.tsx b/app/screens/custom_status/custom_status_modal.test.tsx
index 5c590b18e..84bdb139f 100644
--- a/app/screens/custom_status/custom_status_modal.test.tsx
+++ b/app/screens/custom_status/custom_status_modal.test.tsx
@@ -7,11 +7,13 @@ import Preferences from '@mm-redux/constants/preferences';
import CustomStatusModal from '@screens/custom_status/custom_status_modal';
import {shallowWithIntl} from 'test/intl-test-helper';
+import {CustomStatusDuration} from '@mm-redux/types/users';
describe('screens/custom_status_modal', () => {
const customStatus = {
emoji: 'calendar',
text: 'In a meeting',
+ duration: CustomStatusDuration.DONT_CLEAR,
};
const baseProps = {
diff --git a/app/screens/custom_status/custom_status_modal.tsx b/app/screens/custom_status/custom_status_modal.tsx
index 68a9f4559..f876c1fb3 100644
--- a/app/screens/custom_status/custom_status_modal.tsx
+++ b/app/screens/custom_status/custom_status_modal.tsx
@@ -1,46 +1,65 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
+import moment, {Moment} from 'moment-timezone';
import React from 'react';
import {intlShape, injectIntl} from 'react-intl';
import {View, Text, TouchableOpacity, TextInput, Keyboard, KeyboardAvoidingView, Platform, ScrollView, StyleProp, ViewStyle} from 'react-native';
import {Navigation, NavigationComponent, NavigationComponentProps, OptionsTopBarButton, Options, NavigationButtonPressedEvent} from 'react-native-navigation';
import {SafeAreaView} from 'react-native-safe-area-context';
-import {dismissModal, showModal, mergeNavigationOptions} from '@actions/navigation';
+import {dismissModal, showModal, mergeNavigationOptions, goToScreen} from '@actions/navigation';
import Emoji from '@components/emoji';
import CompassIcon from '@components/compass_icon';
import ClearButton from '@components/custom_status/clear_button';
+import CustomStatusExpiry from '@components/custom_status/custom_status_expiry';
import FormattedText from '@components/formatted_text';
import StatusBar from '@components/status_bar';
import {CustomStatus, DeviceTypes} from '@constants';
+import {durationValues} from '@constants/custom_status';
import {ActionFunc, ActionResult} from '@mm-redux/types/actions';
import {Theme} from '@mm-redux/types/preferences';
-import {UserCustomStatus} from '@mm-redux/types/users';
+import {CustomStatusDuration, UserCustomStatus} from '@mm-redux/types/users';
import EventEmitter from '@mm-redux/utils/event_emitter';
import CustomStatusSuggestion from '@screens/custom_status/custom_status_suggestion';
+import {getRoundedTime} from '@screens/custom_status_clear_after/date_time_selector';
import {t} from '@utils/i18n';
import {preventDoubleTap} from '@utils/tap';
+import {getCurrentMomentForTimezone} from '@utils/timezone';
import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme';
type DefaultUserCustomStatus = {
emoji: string;
message: string;
messageDefault: string;
+ durationDefault: CustomStatusDuration;
};
+const {
+ DONT_CLEAR,
+ THIRTY_MINUTES,
+ ONE_HOUR,
+ FOUR_HOURS,
+ TODAY,
+ THIS_WEEK,
+ DATE_AND_TIME,
+} = CustomStatusDuration;
+
const defaultCustomStatusSuggestions: DefaultUserCustomStatus[] = [
- {emoji: 'calendar', message: t('custom_status.suggestions.in_a_meeting'), messageDefault: 'In a meeting'},
- {emoji: 'hamburger', message: t('custom_status.suggestions.out_for_lunch'), messageDefault: 'Out for lunch'},
- {emoji: 'sneezing_face', message: t('custom_status.suggestions.out_sick'), messageDefault: 'Out sick'},
- {emoji: 'house', message: t('custom_status.suggestions.working_from_home'), messageDefault: 'Working from home'},
- {emoji: 'palm_tree', message: t('custom_status.suggestions.on_a_vacation'), messageDefault: 'On a vacation'},
+ {emoji: 'calendar', message: t('custom_status.suggestions.in_a_meeting'), messageDefault: 'In a meeting', durationDefault: ONE_HOUR},
+ {emoji: 'hamburger', message: t('custom_status.suggestions.out_for_lunch'), messageDefault: 'Out for lunch', durationDefault: THIRTY_MINUTES},
+ {emoji: 'sneezing_face', message: t('custom_status.suggestions.out_sick'), messageDefault: 'Out sick', durationDefault: TODAY},
+ {emoji: 'house', message: t('custom_status.suggestions.working_from_home'), messageDefault: 'Working from home', durationDefault: TODAY},
+ {emoji: 'palm_tree', message: t('custom_status.suggestions.on_a_vacation'), messageDefault: 'On a vacation', durationDefault: THIS_WEEK},
];
+const defaultDuration: CustomStatusDuration = TODAY;
+
interface Props extends NavigationComponentProps {
intl: typeof intlShape;
theme: Theme;
customStatus: UserCustomStatus;
+ userTimezone: string;
recentCustomStatuses: UserCustomStatus[];
isLandscape: boolean;
actions: {
@@ -48,11 +67,15 @@ interface Props extends NavigationComponentProps {
unsetCustomStatus: () => ActionFunc;
removeRecentCustomStatus: (customStatus: UserCustomStatus) => ActionFunc;
};
+ isExpirySupported: boolean;
+ isCustomStatusExpired: boolean;
}
type State = {
emoji: string;
text: string;
+ duration: CustomStatusDuration;
+ expires_at: Moment;
}
class CustomStatusModal extends NavigationComponent {
@@ -75,21 +98,31 @@ class CustomStatusModal extends NavigationComponent {
constructor(props: Props) {
super(props);
+ const {customStatus, userTimezone, isCustomStatusExpired, intl, theme, componentId} = props;
- this.rightButton.text = props.intl.formatMessage({id: 'mobile.custom_status.modal_confirm', defaultMessage: 'Done'});
- this.rightButton.color = props.theme.sidebarHeaderTextColor;
+ this.rightButton.text = intl.formatMessage({id: 'mobile.custom_status.modal_confirm', defaultMessage: 'Done'});
+ this.rightButton.color = theme.sidebarHeaderTextColor;
const options: Options = {
topBar: {
rightButtons: [this.rightButton],
},
};
+ mergeNavigationOptions(componentId, options);
- mergeNavigationOptions(props.componentId, options);
+ const currentTime = getCurrentMomentForTimezone(userTimezone);
+
+ let initialCustomExpiryTime: Moment = getRoundedTime(currentTime);
+ const isCurrentCustomStatusSet = !isCustomStatusExpired && (customStatus?.text || customStatus?.emoji);
+ if (isCurrentCustomStatusSet && customStatus?.duration === DATE_AND_TIME && customStatus?.expires_at) {
+ initialCustomExpiryTime = moment(customStatus?.expires_at);
+ }
this.state = {
- emoji: props.customStatus?.emoji,
- text: props.customStatus?.text || '',
+ emoji: isCurrentCustomStatusSet ? customStatus?.emoji : '',
+ text: isCurrentCustomStatusSet ? customStatus?.text : '',
+ duration: isCurrentCustomStatusSet ? (customStatus?.duration ?? DONT_CLEAR) : defaultDuration,
+ expires_at: initialCustomExpiryTime,
};
}
@@ -106,16 +139,25 @@ class CustomStatusModal extends NavigationComponent {
}
handleSetStatus = async () => {
- const {emoji, text} = this.state;
+ const {emoji, text, duration} = this.state;
const isStatusSet = emoji || text;
- const {customStatus} = this.props;
+ const {customStatus, isExpirySupported} = this.props;
if (isStatusSet) {
- const isStatusSame = customStatus?.emoji === emoji && customStatus?.text === text;
+ let isStatusSame = customStatus?.emoji === emoji && customStatus?.text === text && customStatus?.duration === duration;
+ if (isStatusSame && duration === DATE_AND_TIME) {
+ isStatusSame = customStatus?.expires_at === this.calculateExpiryTime(duration);
+ }
if (!isStatusSame) {
- const status = {
+ const status: UserCustomStatus = {
emoji: emoji || 'speech_balloon',
text: text.trim(),
+ duration: DONT_CLEAR,
};
+
+ if (isExpirySupported) {
+ status.duration = duration;
+ status.expires_at = this.calculateExpiryTime(duration);
+ }
const {error} = await this.props.actions.setCustomStatus(status);
if (error) {
EventEmitter.emit(CustomStatus.SET_CUSTOM_STATUS_FAILURE);
@@ -128,30 +170,65 @@ class CustomStatusModal extends NavigationComponent {
dismissModal();
};
+ calculateExpiryTime = (duration: CustomStatusDuration): string => {
+ const {userTimezone} = this.props;
+ const currentTime = getCurrentMomentForTimezone(userTimezone);
+ const {expires_at} = this.state;
+ switch (duration) {
+ case THIRTY_MINUTES:
+ return currentTime.add(30, 'minutes').seconds(0).milliseconds(0).toISOString();
+ case ONE_HOUR:
+ return currentTime.add(1, 'hour').seconds(0).milliseconds(0).toISOString();
+ case FOUR_HOURS:
+ return currentTime.add(4, 'hours').seconds(0).milliseconds(0).toISOString();
+ case TODAY:
+ return currentTime.endOf('day').toISOString();
+ case THIS_WEEK:
+ return currentTime.endOf('week').toISOString();
+ case DATE_AND_TIME:
+ return expires_at.toISOString();
+ case DONT_CLEAR:
+ default:
+ return '';
+ }
+ };
+
handleTextChange = (value: string) => this.setState({text: value});
handleRecentCustomStatusClear = (status: UserCustomStatus) => this.props.actions.removeRecentCustomStatus(status);
clearHandle = () => {
- this.setState({emoji: '', text: ''});
+ this.setState({emoji: '', text: '', duration: defaultDuration});
};
- handleSuggestionClick = (status: UserCustomStatus) => {
- const {emoji, text} = status;
- this.setState({emoji, text});
+ handleCustomStatusSuggestionClick = (status: UserCustomStatus) => {
+ const {emoji, text, duration} = status;
+ this.setState({emoji, text, duration});
+ };
+
+ handleRecentCustomStatusSuggestionClick = (status: UserCustomStatus) => {
+ const {emoji, text, duration} = status;
+ this.setState({emoji, text, duration: duration || DONT_CLEAR});
+ if (duration === DATE_AND_TIME) {
+ this.openClearAfterModal();
+ }
};
renderRecentCustomStatuses = (style: Record>) => {
- const {recentCustomStatuses, theme} = this.props;
+ const {recentCustomStatuses, theme, isExpirySupported} = this.props;
+
const recentStatuses = recentCustomStatuses.map((status: UserCustomStatus, index: number) => (
));
@@ -177,22 +254,25 @@ class CustomStatusModal extends NavigationComponent {
};
renderCustomStatusSuggestions = (style: Record>) => {
- const {recentCustomStatuses, theme, intl} = this.props;
+ const {recentCustomStatuses, theme, intl, isExpirySupported} = this.props;
const recentCustomStatusTexts = recentCustomStatuses.map((status: UserCustomStatus) => status.text);
const customStatusSuggestions = defaultCustomStatusSuggestions.
map((status) => ({
emoji: status.emoji,
text: intl.formatMessage({id: status.message, defaultMessage: status.messageDefault}),
+ duration: status.durationDefault,
})).
filter((status: UserCustomStatus) => !recentCustomStatusTexts.includes(status.text)).
map((status: UserCustomStatus, index: number, arr: UserCustomStatus[]) => (
));
@@ -236,11 +316,31 @@ class CustomStatusModal extends NavigationComponent {
this.setState({emoji});
}
- render() {
- const {emoji, text} = this.state;
- const {theme, isLandscape} = this.props;
+ handleClearAfterClick = (duration: CustomStatusDuration, expires_at: string) =>
+ this.setState({
+ duration,
+ expires_at: duration === DATE_AND_TIME && expires_at ? moment(expires_at) : this.state.expires_at,
+ });
- const isStatusSet = emoji || text;
+ openClearAfterModal = async () => {
+ const {intl, theme} = this.props;
+ const screen = 'ClearAfter';
+ const title = intl.formatMessage({id: 'mobile.custom_status.clear_after', defaultMessage: 'Clear After'});
+ const passProps = {
+ handleClearAfterClick: this.handleClearAfterClick,
+ initialDuration: this.state.duration,
+ intl,
+ theme,
+ };
+
+ goToScreen(screen, title, passProps);
+ };
+
+ render() {
+ const {emoji, text, duration, expires_at} = this.state;
+ const {theme, isLandscape, intl, isExpirySupported} = this.props;
+
+ const isStatusSet = Boolean(emoji || text);
const style = getStyleSheet(theme);
const customStatusEmoji = (
{
);
+ const clearAfterTime = duration && duration === DATE_AND_TIME ? (
+
+
+
+ ) : (
+
+ );
+
+ const clearAfter = isExpirySupported && (
+
+
+ {intl.formatMessage({id: 'mobile.custom_status.clear_after', defaultMessage: 'Clear After'})}
+ {clearAfterTime}
+
+
+
+ );
+
const customStatusInput = (
{
underlineColorAndroid='transparent'
value={text}
/>
+ {isStatusSet && (
+
+ )}
{customStatusEmoji}
{isStatusSet ? (
{
>
- {customStatusInput}
+
+ {customStatusInput}
+ {isStatusSet && clearAfter}
+
{this.renderRecentCustomStatuses(style)}
{this.renderCustomStatusSuggestions(style)}
+
@@ -338,20 +481,15 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
flex: 1,
+ backgroundColor: changeOpacity(theme.centerChannelColor, 0.03),
},
scrollView: {
flex: 1,
- backgroundColor: changeOpacity(theme.centerChannelColor, 0.03),
paddingTop: 32,
},
inputContainer: {
- position: 'relative',
- flexDirection: 'row',
- alignItems: 'center',
- borderTopWidth: 1,
- borderBottomWidth: 1,
- borderTopColor: changeOpacity(theme.centerChannelColor, 0.1),
- borderBottomColor: changeOpacity(theme.centerChannelColor, 0.1),
+ justifyContent: 'center',
+ height: 48,
backgroundColor: theme.centerChannelBg,
},
input: {
@@ -361,7 +499,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
fontSize: 17,
paddingHorizontal: 52,
textAlignVertical: 'center',
- height: 48,
+ height: '100%',
},
icon: {
color: changeOpacity(theme.centerChannelColor, 0.64),
@@ -372,7 +510,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
iconContainer: {
position: 'absolute',
left: 14,
- top: 12,
+ top: 10,
},
separator: {
marginTop: 32,
@@ -394,5 +532,30 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
top: 3,
right: 14,
},
+ customStatusExpiry: {
+ color: changeOpacity(theme.centerChannelColor, 0.5),
+ },
+ divider: {
+ backgroundColor: changeOpacity(theme.centerChannelColor, 0.2),
+ height: 1,
+ marginRight: 16,
+ marginLeft: 52,
+ },
+ expiryTimeLabel: {
+ fontSize: 17,
+ paddingLeft: 16,
+ textAlignVertical: 'center',
+ color: theme.centerChannelColor,
+ },
+ expiryTime: {
+ position: 'absolute',
+ right: 42,
+ color: changeOpacity(theme.centerChannelColor, 0.5),
+ },
+ rightIcon: {
+ position: 'absolute',
+ right: 18,
+ color: changeOpacity(theme.centerChannelColor, 0.5),
+ },
};
});
diff --git a/app/screens/custom_status/custom_status_suggestion.test.tsx b/app/screens/custom_status/custom_status_suggestion.test.tsx
index 774b73039..a1b8b1226 100644
--- a/app/screens/custom_status/custom_status_suggestion.test.tsx
+++ b/app/screens/custom_status/custom_status_suggestion.test.tsx
@@ -1,12 +1,13 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
-import {shallow} from 'enzyme';
import React from 'react';
import {TouchableOpacity} from 'react-native';
import Preferences from '@mm-redux/constants/preferences';
import CustomStatusSuggestion from '@screens/custom_status/custom_status_suggestion';
+import {CustomStatusDuration} from '@mm-redux/types/users';
+import {shallowWithIntl} from 'test/intl-test-helper';
describe('screens/custom_status_suggestion', () => {
const baseProps = {
@@ -15,20 +16,21 @@ describe('screens/custom_status_suggestion', () => {
text: 'In a meeting',
theme: Preferences.THEMES.default,
separator: false,
+ duration: CustomStatusDuration.DONT_CLEAR,
};
it('should match snapshot', () => {
- const wrapper = shallow(
+ const wrapper = shallowWithIntl(
,
);
- expect(wrapper.getElement()).toMatchSnapshot();
+ expect(wrapper.dive().getElement()).toMatchSnapshot();
});
it('should match snapshot with separator and clear button', () => {
- const wrapper = shallow(
+ const wrapper = shallowWithIntl(
{
/>,
);
- expect(wrapper.getElement()).toMatchSnapshot();
+ expect(wrapper.dive().getElement()).toMatchSnapshot();
});
it('should call handleSuggestionClick on clicking the suggestion', () => {
- const wrapper = shallow(
+ const wrapper = shallowWithIntl(
,
);
- wrapper.find(TouchableOpacity).simulate('press');
+ wrapper.dive().find(TouchableOpacity).simulate('press');
expect(baseProps.handleSuggestionClick).toBeCalled();
});
});
diff --git a/app/screens/custom_status/custom_status_suggestion.tsx b/app/screens/custom_status/custom_status_suggestion.tsx
index 35d8cffc7..17edcb301 100644
--- a/app/screens/custom_status/custom_status_suggestion.tsx
+++ b/app/screens/custom_status/custom_status_suggestion.tsx
@@ -1,41 +1,46 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
+import {injectIntl, intlShape} from 'react-intl';
import React, {useCallback} from 'react';
import {View, TouchableOpacity, Text} from 'react-native';
import Emoji from '@components/emoji';
import ClearButton from '@components/custom_status/clear_button';
import CustomStatusText from '@components/custom_status/custom_status_text';
+import {durationValues} from '@constants/custom_status';
import {Theme} from '@mm-redux/types/preferences';
-import {UserCustomStatus} from '@mm-redux/types/users';
+import {CustomStatusDuration, UserCustomStatus} from '@mm-redux/types/users';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
type Props = {
+ intl: typeof intlShape;
handleSuggestionClick: (status: UserCustomStatus) => void;
emoji: string;
text: string;
handleClear?: (status: UserCustomStatus) => void;
theme: Theme;
separator: boolean;
+ duration: CustomStatusDuration;
+ isExpirySupported: boolean;
+ expires_at?: string;
};
-const CustomStatusSuggestion = (props: Props) => {
- const {handleSuggestionClick, emoji, text, theme, separator, handleClear} = props;
+const CustomStatusSuggestion = ({handleSuggestionClick, emoji, text, theme, separator, handleClear, duration, expires_at, intl, isExpirySupported}: Props) => {
const style = getStyleSheet(theme);
const handleClick = useCallback(preventDoubleTap(() => {
- handleSuggestionClick({emoji, text});
+ handleSuggestionClick({emoji, text, duration});
}), []);
const handleSuggestionClear = useCallback(() => {
if (handleClear) {
- handleClear({emoji, text});
+ handleClear({emoji, text, duration, expires_at});
}
}, []);
- const clearButton = handleClear ?
+ const clearButton = handleClear && expires_at ?
(
{
-
+
+
+
+ {Boolean(duration && isExpirySupported) && (
+
+
+
+ )}
{clearButton}
{separator && }
@@ -76,7 +92,7 @@ const CustomStatusSuggestion = (props: Props) => {
);
};
-export default CustomStatusSuggestion;
+export default injectIntl(CustomStatusSuggestion);
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
@@ -111,6 +127,14 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
divider: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.2),
height: 1,
+ marginRight: 16,
+ },
+ customStatusDuration: {
+ color: changeOpacity(theme.centerChannelColor, 0.6),
+ fontSize: 15,
+ },
+ customStatusText: {
+ color: theme.centerChannelColor,
},
};
});
diff --git a/app/screens/custom_status/index.ts b/app/screens/custom_status/index.ts
index e5c0615a8..c7accb8de 100644
--- a/app/screens/custom_status/index.ts
+++ b/app/screens/custom_status/index.ts
@@ -6,24 +6,32 @@ import {bindActionCreators, Dispatch} from 'redux';
import {setCustomStatus, unsetCustomStatus, removeRecentCustomStatus} from '@actions/views/custom_status';
import {getTheme} from '@mm-redux/selectors/entities/preferences';
+import {getCurrentUserTimezone} from '@mm-redux/selectors/entities/timezone';
import {GenericAction} from '@mm-redux/types/actions';
import {GlobalState} from '@mm-redux/types/store';
+import {UserCustomStatus} from '@mm-redux/types/users';
import CustomStatusModal from '@screens/custom_status/custom_status_modal';
-import {getRecentCustomStatuses, makeGetCustomStatus} from '@selectors/custom_status';
+import {getRecentCustomStatuses, isCustomStatusExpired, isCustomStatusExpirySupported, makeGetCustomStatus} from '@selectors/custom_status';
import {isLandscape} from '@selectors/device';
function makeMapStateToProps() {
const getCustomStatus = makeGetCustomStatus();
return (state: GlobalState) => {
- const customStatus = getCustomStatus(state);
+ const customStatus: UserCustomStatus | undefined = getCustomStatus(state);
const recentCustomStatuses = getRecentCustomStatuses(state);
const theme = getTheme(state);
+ const userTimezone = getCurrentUserTimezone(state);
+ const isExpirySupported = isCustomStatusExpirySupported(state);
+ const customStatusExpired = isCustomStatusExpired(state, customStatus);
return {
+ userTimezone,
customStatus,
recentCustomStatuses,
theme,
isLandscape: isLandscape(state),
+ isExpirySupported,
+ isCustomStatusExpired: customStatusExpired,
};
};
}
diff --git a/app/screens/custom_status_clear_after/__snapshots__/clear_after_menu_item.test.tsx.snap b/app/screens/custom_status_clear_after/__snapshots__/clear_after_menu_item.test.tsx.snap
new file mode 100644
index 000000000..7d48130b6
--- /dev/null
+++ b/app/screens/custom_status_clear_after/__snapshots__/clear_after_menu_item.test.tsx.snap
@@ -0,0 +1,173 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`screens/clear_after_menu_item should match snapshot 1`] = `
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`screens/clear_after_menu_item should match snapshot with separator and selected check 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/app/screens/custom_status_clear_after/__snapshots__/clear_after_modal.test.tsx.snap b/app/screens/custom_status_clear_after/__snapshots__/clear_after_modal.test.tsx.snap
new file mode 100644
index 000000000..ddbba91d0
--- /dev/null
+++ b/app/screens/custom_status_clear_after/__snapshots__/clear_after_modal.test.tsx.snap
@@ -0,0 +1,64 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`screens/clear_after_modal should match snapshot 1`] = `
+
+`;
diff --git a/app/screens/custom_status_clear_after/__snapshots__/date_time_selector.test.tsx.snap b/app/screens/custom_status_clear_after/__snapshots__/date_time_selector.test.tsx.snap
new file mode 100644
index 000000000..bdb867bfe
--- /dev/null
+++ b/app/screens/custom_status_clear_after/__snapshots__/date_time_selector.test.tsx.snap
@@ -0,0 +1,116 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`screens/date_time_selector should match snapshot 1`] = `
+
+
+
+
+
+ Select Date
+
+
+
+
+
+
+ Select Time
+
+
+
+
+
+`;
diff --git a/app/screens/custom_status_clear_after/clear_after_menu_item.test.tsx b/app/screens/custom_status_clear_after/clear_after_menu_item.test.tsx
new file mode 100644
index 000000000..12248d650
--- /dev/null
+++ b/app/screens/custom_status_clear_after/clear_after_menu_item.test.tsx
@@ -0,0 +1,50 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+import React from 'react';
+import {TouchableOpacity} from 'react-native';
+
+import Preferences from '@mm-redux/constants/preferences';
+import {CustomStatusDuration} from '@mm-redux/types/users';
+import {shallowWithIntl} from 'test/intl-test-helper';
+import ClearAfterMenuItem from './clear_after_menu_item';
+
+describe('screens/clear_after_menu_item', () => {
+ const baseProps = {
+ theme: Preferences.THEMES.default,
+ duration: CustomStatusDuration.DONT_CLEAR,
+ separator: false,
+ isSelected: false,
+ handleItemClick: jest.fn(),
+ };
+
+ it('should match snapshot', () => {
+ const wrapper = shallowWithIntl(
+ ,
+ );
+
+ expect(wrapper.dive().getElement()).toMatchSnapshot();
+ });
+
+ it('should match snapshot with separator and selected check', () => {
+ const wrapper = shallowWithIntl(
+ ,
+ );
+
+ expect(wrapper.dive().getElement()).toMatchSnapshot();
+ });
+
+ it('should call handleItemClick on clicking the suggestion', () => {
+ const wrapper = shallowWithIntl(
+ ,
+ );
+
+ wrapper.dive().find(TouchableOpacity).simulate('press');
+ expect(baseProps.handleItemClick).toBeCalled();
+ });
+});
diff --git a/app/screens/custom_status_clear_after/clear_after_menu_item.tsx b/app/screens/custom_status_clear_after/clear_after_menu_item.tsx
new file mode 100644
index 000000000..b4b4982ea
--- /dev/null
+++ b/app/screens/custom_status_clear_after/clear_after_menu_item.tsx
@@ -0,0 +1,149 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import moment, {Moment} from 'moment';
+import React, {useCallback, useState} from 'react';
+import {injectIntl, intlShape} from 'react-intl';
+import {View, TouchableOpacity} from 'react-native';
+
+import CompassIcon from '@components/compass_icon';
+import CustomStatusExpiry from '@components/custom_status/custom_status_expiry';
+import CustomStatusText from '@components/custom_status/custom_status_text';
+import {durationValues} from '@constants/custom_status';
+import {Theme} from '@mm-redux/types/preferences';
+import {CustomStatusDuration} from '@mm-redux/types/users';
+import {preventDoubleTap} from '@utils/tap';
+import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
+
+import DateTimePicker from './date_time_selector';
+
+type Props = {
+ handleItemClick: (duration: CustomStatusDuration, expiresAt: string) => void;
+ duration: CustomStatusDuration;
+ theme: Theme;
+ separator: boolean;
+ isSelected: boolean;
+ intl: typeof intlShape;
+ showExpiryTime?: boolean;
+ showDateTimePicker?: boolean;
+};
+
+const {
+ DONT_CLEAR,
+ THIRTY_MINUTES,
+ ONE_HOUR,
+ FOUR_HOURS,
+ TODAY,
+ THIS_WEEK,
+ DATE_AND_TIME,
+} = CustomStatusDuration;
+
+const ClearAfterMenuItem = ({handleItemClick, duration, theme, separator, isSelected, intl, showExpiryTime = false, showDateTimePicker = false}: Props) => {
+ const style = getStyleSheet(theme);
+
+ const [expiry, setExpiry] = useState('');
+
+ const expiryMenuItems: { [key in CustomStatusDuration]: string } = {
+ [DONT_CLEAR]: intl.formatMessage(durationValues[DONT_CLEAR]),
+ [THIRTY_MINUTES]: intl.formatMessage(durationValues[THIRTY_MINUTES]),
+ [ONE_HOUR]: intl.formatMessage(durationValues[ONE_HOUR]),
+ [FOUR_HOURS]: intl.formatMessage(durationValues[FOUR_HOURS]),
+ [TODAY]: intl.formatMessage(durationValues[TODAY]),
+ [THIS_WEEK]: intl.formatMessage(durationValues[THIS_WEEK]),
+ [DATE_AND_TIME]: intl.formatMessage({id: 'custom_status.expiry_dropdown.custom', defaultMessage: 'Custom'}),
+ };
+
+ const handleClick = useCallback(
+ preventDoubleTap(() => {
+ handleItemClick(duration, '');
+ }), [handleItemClick, duration]);
+
+ const handleCustomExpiresAtChange = useCallback((expiresAt: Moment) => {
+ setExpiry(expiresAt.toISOString());
+ handleItemClick(duration, expiresAt.toISOString());
+ }, [handleItemClick, duration]);
+
+ return (
+
+
+
+
+
+ {isSelected && (
+
+
+
+ )}
+ {showExpiryTime && expiry !== '' && (
+
+
+
+ )}
+
+
+ {separator && }
+
+ {showDateTimePicker && (
+
+ )}
+
+ );
+};
+
+export default injectIntl(ClearAfterMenuItem);
+
+const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
+ return {
+ container: {
+ backgroundColor: theme.centerChannelBg,
+ display: 'flex',
+ flexDirection: 'row',
+ padding: 10,
+ },
+ textContainer: {
+ marginLeft: 5,
+ marginBottom: 2,
+ alignItems: 'center',
+ width: '70%',
+ flex: 1,
+ flexDirection: 'row',
+ position: 'relative',
+ },
+ rightPosition: {
+ position: 'absolute',
+ right: 14,
+ },
+ divider: {
+ backgroundColor: changeOpacity(theme.centerChannelColor, 0.2),
+ height: 1,
+ marginHorizontal: 16,
+ },
+ button: {
+ borderRadius: 1000,
+ color: theme.buttonBg,
+ },
+ customStatusExpiry: {
+ color: theme.linkColor,
+ },
+ };
+});
diff --git a/app/screens/custom_status_clear_after/clear_after_modal.test.tsx b/app/screens/custom_status_clear_after/clear_after_modal.test.tsx
new file mode 100644
index 000000000..c78149ee7
--- /dev/null
+++ b/app/screens/custom_status_clear_after/clear_after_modal.test.tsx
@@ -0,0 +1,25 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react';
+
+import Preferences from '@mm-redux/constants/preferences';
+import {CustomStatusDuration} from '@mm-redux/types/users';
+import ClearAfterModal from '@screens/custom_status_clear_after/clear_after_modal';
+import {shallowWithIntl} from 'test/intl-test-helper';
+
+describe('screens/clear_after_modal', () => {
+ const baseProps = {
+ theme: Preferences.THEMES.default,
+ initialDuration: CustomStatusDuration.DONT_CLEAR,
+ handleClearAfterClick: jest.fn(),
+ };
+
+ it('should match snapshot', () => {
+ const wrapper = shallowWithIntl(
+ ,
+ );
+
+ expect(wrapper.getElement()).toMatchSnapshot();
+ });
+});
diff --git a/app/screens/custom_status_clear_after/clear_after_modal.tsx b/app/screens/custom_status_clear_after/clear_after_modal.tsx
new file mode 100644
index 000000000..f4c373e2f
--- /dev/null
+++ b/app/screens/custom_status_clear_after/clear_after_modal.tsx
@@ -0,0 +1,188 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {SafeAreaView, View, StatusBar} from 'react-native';
+import React from 'react';
+import {intlShape, injectIntl} from 'react-intl';
+import {KeyboardAwareScrollView} from 'react-native-keyboard-aware-scrollview';
+import {
+ Navigation,
+ NavigationButtonPressedEvent,
+ NavigationComponent,
+ NavigationComponentProps,
+ Options,
+ OptionsTopBarButton,
+} from 'react-native-navigation';
+
+import {Theme} from '@mm-redux/types/preferences';
+import {CustomStatusDuration} from '@mm-redux/types/users';
+import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
+
+import {mergeNavigationOptions, popTopScreen} from 'app/actions/navigation';
+import ClearAfterMenuItem from './clear_after_menu_item';
+interface Props extends NavigationComponentProps {
+ intl: typeof intlShape;
+ theme: Theme;
+ handleClearAfterClick: (duration: CustomStatusDuration, expiresAt: string) => void;
+ initialDuration: CustomStatusDuration;
+}
+
+type State = {
+ duration: CustomStatusDuration;
+ expiresAt: string;
+ showExpiryTime: boolean;
+}
+
+const {DATE_AND_TIME} = CustomStatusDuration;
+class ClearAfterModal extends NavigationComponent {
+ rightButton: OptionsTopBarButton = {
+ id: 'update-custom-status-clear-after',
+ testID: 'clear_after.done.button',
+ enabled: true,
+ showAsAction: 'always',
+ };
+
+ static options(): Options {
+ return {
+ topBar: {
+ title: {
+ alignment: 'center',
+ },
+ },
+ };
+ }
+
+ constructor(props: Props) {
+ super(props);
+ this.rightButton.text = props.intl.formatMessage({
+ id: 'mobile.custom_status.modal_confirm',
+ defaultMessage: 'Done',
+ });
+
+ this.rightButton.color = props.theme.sidebarHeaderTextColor;
+ const options: Options = {
+ topBar: {
+ rightButtons: [this.rightButton],
+ },
+ };
+
+ mergeNavigationOptions(props.componentId, options);
+
+ this.state = {
+ duration: props.initialDuration,
+ expiresAt: '',
+ showExpiryTime: false,
+ };
+ }
+
+ componentDidMount() {
+ Navigation.events().bindComponent(this);
+ }
+
+ navigationButtonPressed({buttonId}: NavigationButtonPressedEvent) {
+ switch (buttonId) {
+ case 'update-custom-status-clear-after':
+ this.onDone();
+ break;
+ }
+ }
+
+ onDone = () => {
+ this.props.handleClearAfterClick(this.state.duration, this.state.expiresAt);
+ popTopScreen();
+ };
+
+ handleItemClick = (duration: CustomStatusDuration, expiresAt: string) =>
+ this.setState({
+ duration,
+ expiresAt,
+ showExpiryTime: duration === DATE_AND_TIME && expiresAt !== '',
+ });
+
+ renderClearAfterMenu = () => {
+ const {theme} = this.props;
+ const style = getStyleSheet(theme);
+ const {duration} = this.state;
+
+ const clearAfterMenu = Object.values(CustomStatusDuration).map(
+ (item, index, arr) => {
+ if (index === arr.length - 1) {
+ return null;
+ }
+
+ return (
+
+ );
+ },
+ );
+
+ if (clearAfterMenu.length === 0) {
+ return null;
+ }
+
+ return (
+
+ {clearAfterMenu}
+
+ );
+ };
+
+ render() {
+ const {theme} = this.props;
+ const style = getStyleSheet(theme);
+ const {duration, expiresAt, showExpiryTime} = this.state;
+ return (
+
+
+
+
+ {this.renderClearAfterMenu()}
+
+
+
+
+
+
+ );
+ }
+}
+
+export default injectIntl(ClearAfterModal);
+
+const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
+ return {
+ container: {
+ flex: 1,
+ backgroundColor: changeOpacity(theme.centerChannelColor, 0.03),
+ },
+ scrollView: {
+ flex: 1,
+ paddingTop: 32,
+ paddingBottom: 32,
+ },
+ block: {
+ borderBottomColor: changeOpacity(theme.centerChannelColor, 0.1),
+ borderBottomWidth: 1,
+ borderTopColor: changeOpacity(theme.centerChannelColor, 0.1),
+ borderTopWidth: 1,
+ },
+ };
+});
diff --git a/app/screens/custom_status_clear_after/date_time_selector.test.tsx b/app/screens/custom_status_clear_after/date_time_selector.test.tsx
new file mode 100644
index 000000000..63af3ddb3
--- /dev/null
+++ b/app/screens/custom_status_clear_after/date_time_selector.test.tsx
@@ -0,0 +1,23 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react';
+
+import Preferences from '@mm-redux/constants/preferences';
+import {renderWithRedux} from 'test/testing_library';
+import DateTimeSelector from './date_time_selector';
+
+describe('screens/date_time_selector', () => {
+ const baseProps = {
+ theme: Preferences.THEMES.default,
+ handleChange: jest.fn(),
+ };
+
+ it('should match snapshot', () => {
+ const wrapper = renderWithRedux(
+ ,
+ );
+
+ expect(wrapper.toJSON()).toMatchSnapshot();
+ });
+});
diff --git a/app/screens/custom_status_clear_after/date_time_selector.tsx b/app/screens/custom_status_clear_after/date_time_selector.tsx
new file mode 100644
index 000000000..99e6a52b1
--- /dev/null
+++ b/app/screens/custom_status_clear_after/date_time_selector.tsx
@@ -0,0 +1,125 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import moment, {Moment} from 'moment-timezone';
+import React, {useState} from 'react';
+import {View, Button, Platform} from 'react-native';
+import {useSelector} from 'react-redux';
+
+import Preferences from '@mm-redux/constants/preferences';
+import {getBool} from '@mm-redux/selectors/entities/preferences';
+import {getCurrentUserTimezone} from '@mm-redux/selectors/entities/timezone';
+import {Theme} from '@mm-redux/types/preferences';
+import {GlobalState} from '@mm-redux/types/store';
+import DateTimePicker from '@react-native-community/datetimepicker';
+import {makeStyleSheetFromTheme} from '@utils/theme';
+import {getCurrentMomentForTimezone, getUtcOffsetForTimeZone} from '@utils/timezone';
+
+type Props = {
+ theme: Theme;
+ handleChange: (currentDate: Moment) => void;
+}
+
+type AndroidMode = 'date' | 'time';
+
+const CUSTOM_STATUS_TIME_PICKER_INTERVALS_IN_MINUTES = 30;
+export function getRoundedTime(value: Moment) {
+ const roundedTo = CUSTOM_STATUS_TIME_PICKER_INTERVALS_IN_MINUTES;
+ const start = moment(value);
+ const diff = start.minute() % roundedTo;
+ if (diff === 0) {
+ return value;
+ }
+ const remainder = roundedTo - diff;
+ return start.add(remainder, 'm').seconds(0).milliseconds(0);
+}
+
+const DateTimeSelector = (props: Props) => {
+ const {theme} = props;
+ const styles = getStyleSheet(theme);
+ const militaryTime = useSelector((state: GlobalState) => getBool(state, Preferences.CATEGORY_DISPLAY_SETTINGS, 'use_military_time'));
+ const timezone = useSelector(getCurrentUserTimezone);
+ const currentTime = getCurrentMomentForTimezone(timezone);
+ const timezoneOffSetInMinutes = timezone ? getUtcOffsetForTimeZone(timezone) : undefined;
+
+ const minimumDate = getRoundedTime(currentTime);
+
+ const [date, setDate] = useState(minimumDate);
+ const [mode, setMode] = useState('date');
+ const [show, setShow] = useState(false);
+
+ const onChange = (_: React.ChangeEvent, selectedDate: Date) => {
+ const currentDate = selectedDate || date;
+ setShow(Platform.OS === 'ios');
+ if (moment(currentDate).isAfter(minimumDate)) {
+ setDate(moment(currentDate));
+ props.handleChange(moment(currentDate));
+ }
+ };
+
+ const showMode = (currentMode: AndroidMode) => {
+ setShow(true);
+ setMode(currentMode);
+ };
+
+ const showDatepicker = () => {
+ showMode('date');
+ props.handleChange(moment(date));
+ };
+
+ const showTimepicker = () => {
+ showMode('time');
+ props.handleChange(moment(date));
+ };
+
+ return (
+
+
+
+
+
+ {show && (
+
+ )}
+
+ );
+};
+
+const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
+ return {
+ container: {
+ flex: 1,
+ paddingTop: 10,
+ backgroundColor: theme.centerChannelBg,
+ },
+ buttonContainer: {
+ flex: 1,
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-evenly',
+ marginBottom: 10,
+ },
+ };
+});
+export default DateTimeSelector;
diff --git a/app/screens/index.js b/app/screens/index.js
index b8f7ba9e3..593810501 100644
--- a/app/screens/index.js
+++ b/app/screens/index.js
@@ -63,6 +63,9 @@ Navigation.setLazyComponentRegistrator((screenName) => {
case 'ChannelNotificationPreference':
screen = require('@screens/channel_notification_preference').default;
break;
+ case 'ClearAfter':
+ screen = require('@screens/custom_status_clear_after/clear_after_modal').default;
+ break;
case 'ClockDisplaySettings':
screen = require('@screens/settings/clock_display').default;
break;
diff --git a/app/screens/user_profile/__snapshots__/user_profile.test.js.snap b/app/screens/user_profile/__snapshots__/user_profile.test.js.snap
index 0dfa5e004..acc75b0ed 100644
--- a/app/screens/user_profile/__snapshots__/user_profile.test.js.snap
+++ b/app/screens/user_profile/__snapshots__/user_profile.test.js.snap
@@ -478,6 +478,7 @@ exports[`user_profile should match snapshot with custom status 1`] = `
}
>
Status
+
-
- In a meeting
-
+ theme={
+ Object {
+ "awayIndicator": "#ffbc42",
+ "buttonBg": "#166de0",
+ "buttonColor": "#ffffff",
+ "centerChannelBg": "#ffffff",
+ "centerChannelColor": "#3d3c40",
+ "codeTheme": "github",
+ "dndIndicator": "#f74343",
+ "errorTextColor": "#fd5960",
+ "linkColor": "#2389d7",
+ "mentionBg": "#ffffff",
+ "mentionBj": "#ffffff",
+ "mentionColor": "#145dbf",
+ "mentionHighlightBg": "#ffe577",
+ "mentionHighlightLink": "#166de0",
+ "newMessageSeparator": "#ff8800",
+ "onlineIndicator": "#06d6a0",
+ "sidebarBg": "#145dbf",
+ "sidebarHeaderBg": "#1153ab",
+ "sidebarHeaderTextColor": "#ffffff",
+ "sidebarText": "#ffffff",
+ "sidebarTextActiveBorder": "#579eff",
+ "sidebarTextActiveColor": "#ffffff",
+ "sidebarTextHoverBg": "#4578bf",
+ "sidebarUnreadText": "#ffffff",
+ "type": "Mattermost",
+ }
+ }
+ />
@@ -694,6 +724,7 @@ exports[`user_profile should match snapshot with custom status and isMyUser true
}
>
Status
+
-
- In a meeting
-
+ theme={
+ Object {
+ "awayIndicator": "#ffbc42",
+ "buttonBg": "#166de0",
+ "buttonColor": "#ffffff",
+ "centerChannelBg": "#ffffff",
+ "centerChannelColor": "#3d3c40",
+ "codeTheme": "github",
+ "dndIndicator": "#f74343",
+ "errorTextColor": "#fd5960",
+ "linkColor": "#2389d7",
+ "mentionBg": "#ffffff",
+ "mentionBj": "#ffffff",
+ "mentionColor": "#145dbf",
+ "mentionHighlightBg": "#ffe577",
+ "mentionHighlightLink": "#166de0",
+ "newMessageSeparator": "#ff8800",
+ "onlineIndicator": "#06d6a0",
+ "sidebarBg": "#145dbf",
+ "sidebarHeaderBg": "#1153ab",
+ "sidebarHeaderTextColor": "#ffffff",
+ "sidebarText": "#ffffff",
+ "sidebarTextActiveBorder": "#579eff",
+ "sidebarTextActiveColor": "#ffffff",
+ "sidebarTextHoverBg": "#4578bf",
+ "sidebarUnreadText": "#ffffff",
+ "type": "Mattermost",
+ }
+ }
+ />
`;
+
+exports[`user_profile should match snapshot with custom status expiry 1`] = `
+
+
+
+
+
+
+ @fred
+
+
+
+
+
+
+ Status
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Nickname
+
+
+ nick
+
+
+
+
+
+
+
+`;
diff --git a/app/screens/user_profile/index.js b/app/screens/user_profile/index.js
index 4d944df93..1fd9a3b43 100644
--- a/app/screens/user_profile/index.js
+++ b/app/screens/user_profile/index.js
@@ -7,15 +7,15 @@ import {connect} from 'react-redux';
import {setChannelDisplayName} from '@actions/views/channel';
import {unsetCustomStatus} from '@actions/views/custom_status';
import {makeDirectChannel} from '@actions/views/more_dms';
+import {loadBot} from '@mm-redux/actions/bots';
+import {getRemoteClusterInfo} from '@mm-redux/actions/remote_cluster';
+import Preferences from '@mm-redux/constants/preferences';
+import {getBotAccounts} from '@mm-redux/selectors/entities/bots';
import {getConfig} from '@mm-redux/selectors/entities/general';
import {getTeammateNameDisplaySetting, getTheme, getBool} from '@mm-redux/selectors/entities/preferences';
import {isTimezoneEnabled} from '@mm-redux/selectors/entities/timezone';
-import Preferences from '@mm-redux/constants/preferences';
-import {loadBot} from '@mm-redux/actions/bots';
-import {getRemoteClusterInfo} from '@mm-redux/actions/remote_cluster';
-import {getBotAccounts} from '@mm-redux/selectors/entities/bots';
import {getCurrentUserId} from '@mm-redux/selectors/entities/users';
-import {makeGetCustomStatus, isCustomStatusEnabled} from '@selectors/custom_status';
+import {makeGetCustomStatus, isCustomStatusEnabled, isCustomStatusExpired, isCustomStatusExpirySupported} from '@selectors/custom_status';
import UserProfile from './user_profile';
@@ -28,7 +28,8 @@ function makeMapStateToProps() {
const enableTimezone = isTimezoneEnabled(state);
const user = state.entities.users.profiles[ownProps.userId];
- const customStatus = isCustomStatusEnabled(state) ? getCustomStatus(state, user?.id) : undefined;
+ const customStatusEnabled = isCustomStatusEnabled(state);
+ const customStatus = customStatusEnabled ? getCustomStatus(state, user?.id) : undefined;
return {
config,
createChannelRequest,
@@ -42,6 +43,8 @@ function makeMapStateToProps() {
isMyUser: getCurrentUserId(state) === ownProps.userId,
remoteClusterInfo: state.entities.remoteCluster.info[user?.remote_id],
customStatus,
+ isCustomStatusExpired: customStatusEnabled ? isCustomStatusExpired(state, customStatus) : true,
+ isCustomStatusExpirySupported: customStatusEnabled ? isCustomStatusExpirySupported(state) : false,
};
};
}
diff --git a/app/screens/user_profile/user_profile.js b/app/screens/user_profile/user_profile.js
index 4f31f7920..9238b875a 100644
--- a/app/screens/user_profile/user_profile.js
+++ b/app/screens/user_profile/user_profile.js
@@ -20,9 +20,11 @@ import {
dismissAllModalsAndPopToRoot,
} from '@actions/navigation';
import Config from '@assets/config';
-import Emoji from '@components/emoji';
-import ClearButton from '@components/custom_status/clear_button';
import ChannelIcon from '@components/channel_icon';
+import ClearButton from '@components/custom_status/clear_button';
+import CustomStatusExpiry from '@components/custom_status/custom_status_expiry';
+import CustomStatusText from '@components/custom_status/custom_status_text';
+import Emoji from '@components/emoji';
import FormattedTime from '@components/formatted_time';
import ProfilePicture from '@components/profile_picture';
import FormattedText from '@components/formatted_text';
@@ -60,6 +62,8 @@ export default class UserProfile extends PureComponent {
isMyUser: PropTypes.bool.isRequired,
remoteClusterInfo: PropTypes.object,
customStatus: PropTypes.object,
+ isCustomStatusExpired: PropTypes.bool.isRequired,
+ isCustomStatusExpirySupported: PropTypes.bool.isRequired,
};
static contextTypes = {
@@ -232,20 +236,33 @@ export default class UserProfile extends PureComponent {
buildCustomStatusBlock = () => {
const {formatMessage} = this.context.intl;
- const {customStatus, theme, isMyUser} = this.props;
+ const {customStatus, theme, isMyUser, isCustomStatusExpired, isCustomStatusExpirySupported} = this.props;
const style = createStyleSheet(theme);
- const isStatusSet = customStatus?.emoji;
+ const isStatusSet = !isCustomStatusExpired && customStatus?.emoji;
if (!isStatusSet) {
return null;
}
const label = formatMessage({id: 'user.settings.general.status', defaultMessage: 'Status'});
+
return (
- {label}
+
+ {label}
+ {' '}
+ {Boolean(customStatus?.duration && isCustomStatusExpirySupported) && (
+
+ )}
+
-
- {customStatus.text}
-
+
{isMyUser && (
@@ -494,6 +513,7 @@ const createStyleSheet = makeStyleSheetFromTheme((theme) => {
flex: 1,
},
iconContainer: {
+ marginBottom: 3,
marginRight: 5,
color: theme.centerChannelColor,
},
@@ -501,8 +521,14 @@ const createStyleSheet = makeStyleSheetFromTheme((theme) => {
flexDirection: 'row',
},
customStatusTextContainer: {
- justifyContent: 'center',
width: '80%',
+ justifyContent: 'center',
+ },
+ customStatusExpiry: {
+ fontSize: 13,
+ fontWeight: '600',
+ textTransform: 'uppercase',
+ color: changeOpacity(theme.centerChannelColor, 0.5),
},
clearButton: {
position: 'absolute',
diff --git a/app/screens/user_profile/user_profile.test.js b/app/screens/user_profile/user_profile.test.js
index ac6d4ead3..d8007e4a2 100644
--- a/app/screens/user_profile/user_profile.test.js
+++ b/app/screens/user_profile/user_profile.test.js
@@ -5,6 +5,7 @@ import React from 'react';
import * as NavigationActions from '@actions/navigation';
import {BotTag, GuestTag} from '@components/tag';
import Preferences from '@mm-redux/constants/preferences';
+import {CustomStatusDuration} from '@mm-redux/types/users';
import {shallowWithIntl} from 'test/intl-test-helper';
import UserProfile from './user_profile.js';
@@ -37,6 +38,8 @@ describe('user_profile', () => {
isMilitaryTime: false,
isMyUser: false,
componentId: 'component-id',
+ isCustomStatusExpired: false,
+ isCustomStatusExpirySupported: false,
};
const user = {
@@ -52,6 +55,7 @@ describe('user_profile', () => {
const customStatus = {
emoji: 'calendar',
text: 'In a meeting',
+ duration: CustomStatusDuration.DONT_CLEAR,
};
const customStatusProps = {
@@ -94,6 +98,23 @@ describe('user_profile', () => {
expect(wrapper.getElement()).toMatchSnapshot();
});
+ it('should match snapshot with custom status expiry', () => {
+ const wrapper = shallowWithIntl(
+ ,
+ {context: {intl: {formatMessage: jest.fn()}}},
+ );
+
+ expect(wrapper.getElement()).toMatchSnapshot();
+ });
+
test('should contain bot tag', () => {
const botUser = {
email: 'test@test.com',
diff --git a/app/selectors/custom_status.ts b/app/selectors/custom_status.ts
index af7011a64..ea842c48d 100644
--- a/app/selectors/custom_status.ts
+++ b/app/selectors/custom_status.ts
@@ -1,15 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
+import moment from 'moment-timezone';
import {createSelector} from 'reselect';
+import {GlobalState} from '@mm-redux/types/store';
+import {CustomStatusDuration, UserCustomStatus} from '@mm-redux/types/users';
+
import {Preferences} from '@mm-redux/constants';
import {getConfig} from '@mm-redux/selectors/entities/general';
import {get} from '@mm-redux/selectors/entities/preferences';
+import {getCurrentUserTimezone} from '@mm-redux/selectors/entities/timezone';
import {getCurrentUser, getUser} from '@mm-redux/selectors/entities/users';
-import {UserCustomStatus} from '@mm-redux/types/users';
-import {GlobalState} from '@mm-redux/types/store';
import {isMinimumServerVersion} from '@mm-redux/utils/helpers';
+import {getCurrentMomentForTimezone} from '@utils/timezone';
export function makeGetCustomStatus(): (state: GlobalState, userID?: string) => UserCustomStatus {
return createSelector(
@@ -25,6 +29,21 @@ export function makeGetCustomStatus(): (state: GlobalState, userID?: string) =>
);
}
+export function isCustomStatusExpired(state: GlobalState, customStatus?: UserCustomStatus) {
+ if (!customStatus) {
+ return true;
+ }
+
+ if (customStatus.duration === CustomStatusDuration.DONT_CLEAR || !customStatus.hasOwnProperty('duration')) {
+ return false;
+ }
+
+ const expiryTime = moment(customStatus.expires_at);
+ const timezone = getCurrentUserTimezone(state);
+ const currentTime = getCurrentMomentForTimezone(timezone);
+ return currentTime.isSameOrAfter(expiryTime);
+}
+
export const getRecentCustomStatuses = createSelector(
(state: GlobalState) => get(state, Preferences.CATEGORY_CUSTOM_STATUS, Preferences.NAME_RECENT_CUSTOM_STATUSES),
(value) => {
@@ -41,3 +60,8 @@ export function isCustomStatusEnabled(state: GlobalState) {
const serverVersion = state.entities.general.serverVersion;
return config && config.EnableCustomUserStatuses === 'true' && isMinimumServerVersion(serverVersion, 5, 36);
}
+
+export function isCustomStatusExpirySupported(state: GlobalState) {
+ const serverVersion = state.entities.general.serverVersion;
+ return isMinimumServerVersion(serverVersion, 5, 37);
+}
diff --git a/app/utils/timezone.js b/app/utils/timezone.js
index de1f57a92..5bb05327d 100644
--- a/app/utils/timezone.js
+++ b/app/utils/timezone.js
@@ -16,3 +16,6 @@ export function getUtcOffsetForTimeZone(timezone) {
return moment.tz(timezone).utcOffset();
}
+export function getCurrentMomentForTimezone(timezone) {
+ return timezone ? moment.tz(timezone) : moment();
+}
diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json
index 3b03b92ce..2d316f7bd 100644
--- a/assets/base/i18n/en.json
+++ b/assets/base/i18n/en.json
@@ -129,6 +129,18 @@
"create_comment.addComment": "Add a comment...",
"create_post.deactivated": "You are viewing an archived channel with a deactivated user.",
"create_post.write": "Write to {channelDisplayName}",
+ "custom_status.expiry_dropdown.custom": "Custom",
+ "custom_status.expiry_dropdown.date_and_time": "Date and Time",
+ "custom_status.expiry_dropdown.dont_clear": "Don't clear",
+ "custom_status.expiry_dropdown.four_hours": "4 hours",
+ "custom_status.expiry_dropdown.one_hour": "1 hour",
+ "custom_status.expiry_dropdown.thirty_minutes": "30 minutes",
+ "custom_status.expiry_dropdown.this_week": "This week",
+ "custom_status.expiry_dropdown.today": "Today",
+ "custom_status.expiry_time.today": "Today",
+ "custom_status.expiry_time.tomorrow": "Tomorrow",
+ "custom_status.expiry.at": "at",
+ "custom_status.expiry.until": "Until",
"custom_status.failure_message": "Failed to update status. Try again",
"custom_status.set_status": "Set a status",
"custom_status.suggestions.in_a_meeting": "In a meeting",
@@ -301,6 +313,7 @@
"mobile.create_post.read_only": "This channel is read-only",
"mobile.custom_list.no_results": "No Results",
"mobile.custom_status.choose_emoji": "Choose an emoji",
+ "mobile.custom_status.clear_after": "Clear After",
"mobile.custom_status.modal_confirm": "Done",
"mobile.display_settings.sidebar": "Sidebar",
"mobile.display_settings.theme": "Theme",
diff --git a/detox/e2e/support/ui/component/date_time_picker.js b/detox/e2e/support/ui/component/date_time_picker.js
new file mode 100644
index 000000000..b0bd97186
--- /dev/null
+++ b/detox/e2e/support/ui/component/date_time_picker.js
@@ -0,0 +1,40 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+class DateTimePicker {
+ testID = {
+ dateTimePicker: 'clear_after.date_time_picker',
+ }
+
+ changeTimeAndroid = async (hour, minute) => {
+ const keyboardIconButton = element(
+ by.type('androidx.appcompat.widget.AppCompatImageButton'),
+ );
+
+ await keyboardIconButton.tap();
+
+ const hourTextinput = element(
+ by.type('androidx.appcompat.widget.AppCompatEditText'),
+ ).atIndex(0);
+
+ const minuteTextinput = element(
+ by.type('androidx.appcompat.widget.AppCompatEditText'),
+ ).atIndex(1);
+
+ await hourTextinput.replaceText(hour);
+ await minuteTextinput.replaceText(minute);
+ }
+
+ tapCancelButtonAndroid = async () => {
+ await element(by.text('Cancel')).tap();
+ }
+
+ tapOkButtonAndroid = async () => {
+ await element(by.text('OK')).tap();
+ }
+
+ getDateTimePickerIOS = () => element(by.type('UIPickerView').withAncestor(by.id(this.testID.dateTimePicker)))
+}
+
+const dateTimePicker = new DateTimePicker();
+export default dateTimePicker;
diff --git a/detox/e2e/support/ui/component/index.js b/detox/e2e/support/ui/component/index.js
index f3208381e..ff969fe30 100644
--- a/detox/e2e/support/ui/component/index.js
+++ b/detox/e2e/support/ui/component/index.js
@@ -5,6 +5,7 @@ import Alert from './alert';
import Autocomplete from './autocomplete';
import BottomSheet from './bottom_sheet';
import CameraQuickAction from './camera_quick_action';
+import DateTimePicker from './date_time_picker';
import EditChannelInfo from './edit_channel_info';
import FileQuickAction from './file_quick_action';
import ImageQuickAction from './image_quick_action';
@@ -27,6 +28,7 @@ export {
Autocomplete,
BottomSheet,
CameraQuickAction,
+ DateTimePicker,
EditChannelInfo,
FileQuickAction,
ImageQuickAction,
diff --git a/detox/e2e/support/ui/screen/custom_status.js b/detox/e2e/support/ui/screen/custom_status.js
index 061e2879d..1b7b25240 100644
--- a/detox/e2e/support/ui/screen/custom_status.js
+++ b/detox/e2e/support/ui/screen/custom_status.js
@@ -8,10 +8,12 @@ class CustomStatusScreen {
customStatusScreen: 'custom_status.screen',
input: 'custom_status.input',
selectedEmojiPrefix: 'custom_status.emoji.',
+ selectedDurationPrefix: 'custom_status.duration.',
inputClearButton: 'custom_status.input.clear.button',
doneButton: 'custom_status.done.button',
suggestionPrefix: 'custom_status_suggestion.',
suggestionClearButton: 'custom_status_suggestion.clear.button',
+ clearAfterAction: 'custom_status.clear_after.action',
}
customStatusScreen = element(by.id(this.testID.customStatusScreen));
@@ -24,6 +26,11 @@ class CustomStatusScreen {
return element(by.id(emojiTestID));
}
+ getCustomStatusSelectedDuration = (duration) => {
+ const durationTestID = `${this.testID.selectedDurationPrefix}${duration}`;
+ return element(by.id(durationTestID));
+ }
+
getCustomStatusSuggestion = (text) => {
const suggestionID = `${this.testID.suggestionPrefix}${text}`;
return element(by.id(suggestionID));
@@ -54,6 +61,10 @@ class CustomStatusScreen {
await expect(this.getCustomStatusSelectedEmoji(emoji)).toBeVisible();
}
+ openClearAfterModal = async () => {
+ await element(by.id(this.testID.clearAfterAction)).tap();
+ }
+
close = async () => {
await this.doneButton.tap();
return expect(this.customStatusScreen).not.toBeVisible();
diff --git a/detox/e2e/support/ui/screen/custom_status_clear_after.js b/detox/e2e/support/ui/screen/custom_status_clear_after.js
new file mode 100644
index 000000000..7a41ecd6f
--- /dev/null
+++ b/detox/e2e/support/ui/screen/custom_status_clear_after.js
@@ -0,0 +1,86 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import moment from 'moment-timezone';
+
+import {CustomStatusScreen} from '@support/ui/screen';
+
+class ClearAfterScreen {
+ testID = {
+ clearAfterScreen: 'clear_after.screen',
+ doneButton: 'clear_after.done.button',
+ menuItemPrefix: 'clear_after.menu_item.',
+ selectDateButton: 'clear_after.menu_item.date_and_time.button.date',
+ selectTimeButton: 'clear_after.menu_item.date_and_time.button.time',
+ }
+
+ clearAfterScreen = element(by.id(this.testID.clearAfterScreen));
+ doneButton = element(by.id(this.testID.doneButton));
+ selectDateButton = element(by.id(this.testID.selectDateButton));
+ selectTimeButton = element(by.id(this.testID.selectTimeButton));
+
+ getClearAfterMenuItem = (duration) => {
+ const menuItemID = `${this.testID.menuItemPrefix}${duration}`;
+ return element(by.id(menuItemID));
+ }
+
+ toBeVisible = async () => {
+ await expect(this.clearAfterScreen).toBeVisible();
+
+ return this.clearAfterScreen;
+ }
+
+ open = async () => {
+ // # Open clear after screen
+ await CustomStatusScreen.openClearAfterModal();
+
+ return this.toBeVisible();
+ }
+
+ tapSuggestion = async (duration) => {
+ await this.getClearAfterMenuItem(duration).tap();
+ }
+
+ openDatePicker = async () => {
+ await this.selectDateButton.tap();
+ }
+
+ openTimePicker = async () => {
+ await this.selectTimeButton.tap();
+ }
+
+ close = async () => {
+ await this.doneButton.tap();
+ return expect(this.clearAfterScreen).not.toBeVisible();
+ }
+
+ getExpiryText = (minutes) => {
+ const currentMomentTime = moment();
+ const expiryMomentTime = currentMomentTime.clone().add(minutes, 'm');
+ const tomorrowEndTime = currentMomentTime.clone().add(1, 'day').endOf('day');
+ const todayEndTime = currentMomentTime.clone().endOf('day');
+
+ let isTomorrow = false;
+ let isToday = false;
+
+ if (expiryMomentTime.isSame(todayEndTime)) {
+ isToday = true;
+ }
+ if (expiryMomentTime.isAfter(todayEndTime) && expiryMomentTime.isSameOrBefore(tomorrowEndTime)) {
+ isTomorrow = true;
+ }
+
+ const showTime = expiryMomentTime.format('h:mm A');
+ const showTomorrow = isTomorrow ? 'Tomorrow at ' : '';
+ const showToday = isToday ? 'Today' : '';
+
+ let expiryText = '';
+ expiryText += showToday;
+ expiryText += showTomorrow;
+ expiryText += showTime;
+ return expiryText;
+ }
+}
+
+const clearAfterScreen = new ClearAfterScreen();
+export default clearAfterScreen;
diff --git a/detox/e2e/support/ui/screen/index.js b/detox/e2e/support/ui/screen/index.js
index ec5dbcc14..fdbe2b188 100644
--- a/detox/e2e/support/ui/screen/index.js
+++ b/detox/e2e/support/ui/screen/index.js
@@ -9,6 +9,7 @@ import ChannelAddMembersScreen from './channel_add_members';
import ChannelMembersScreen from './channel_members';
import ChannelNotificationPreferenceScreen from './channel_notification_preference';
import ChannelScreen from './channel';
+import ClearAfterScreen from './custom_status_clear_after';
import ClockDisplaySettingsScreen from './clock_display_settings';
import CreateChannelScreen from './create_channel';
import CustomStatusScreen from './custom_status';
@@ -46,6 +47,7 @@ export {
ChannelMembersScreen,
ChannelNotificationPreferenceScreen,
ChannelScreen,
+ ClearAfterScreen,
ClockDisplaySettingsScreen,
CreateChannelScreen,
CustomStatusScreen,
diff --git a/detox/e2e/test/custom_statuses/custom_status_expiry.e2e.js b/detox/e2e/test/custom_statuses/custom_status_expiry.e2e.js
new file mode 100644
index 000000000..cc755dbca
--- /dev/null
+++ b/detox/e2e/test/custom_statuses/custom_status_expiry.e2e.js
@@ -0,0 +1,215 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import moment from 'moment-timezone';
+
+import {
+ Setup,
+ System,
+} from '@support/server_api';
+import {DateTimePicker} from '@support/ui/component';
+import {
+ ChannelInfoScreen,
+ ChannelScreen,
+ CustomStatusScreen,
+ MoreDirectMessagesScreen,
+ UserProfileScreen,
+ ClearAfterScreen,
+} from '@support/ui/screen';
+import {wait, timeouts, isAndroid} from '@support/utils';
+
+describe('Custom status expiry', () => {
+ const {
+ closeSettingsSidebar,
+ openSettingsSidebar,
+ } = ChannelScreen;
+ const {
+ getCustomStatusSuggestion,
+ tapSuggestion,
+ getCustomStatusSelectedDuration,
+ } = CustomStatusScreen;
+ const defaultCustomStatuses = ['In a meeting', 'Out for lunch', 'Out sick', 'Working from home', 'On a vacation'];
+ const defaultStatus = {
+ emoji: 'hamburger',
+ text: 'Out for lunch',
+ duration: 'thirty_minutes',
+ };
+ let testUser;
+
+ beforeAll(async () => {
+ await System.apiUpdateConfig({TeamSettings: {EnableCustomUserStatuses: true}});
+ });
+
+ beforeEach(async () => {
+ const {user} = await Setup.apiInit();
+ testUser = user;
+
+ // # Open channel screen
+ await ChannelScreen.open(user);
+ });
+
+ afterEach(async () => {
+ await ChannelScreen.logout();
+ });
+
+ xit('MM-T4090 RN apps: Custom Status Expiry (mobile)', async () => {// enable only when running locally
+ const expiryText = '(Until ' + ClearAfterScreen.getExpiryText(30) + ')';
+
+ // # Open custom status screen
+ await openSettingsSidebar();
+ await CustomStatusScreen.open();
+
+ // * Check if all the default suggestions are visible
+ const isSuggestionPresentPromiseArray = [];
+ defaultCustomStatuses.map(async (text) => {
+ isSuggestionPresentPromiseArray.push(expect(getCustomStatusSuggestion(text)).toBeVisible());
+ });
+ await Promise.all(isSuggestionPresentPromiseArray);
+
+ // * Tap a suggestion and check if it is selected
+ await tapSuggestion(defaultStatus);
+ await expect(getCustomStatusSelectedDuration(defaultStatus.duration)).toBeVisible();
+
+ // # Tap on Done button and check if the modal closes
+ await CustomStatusScreen.close();
+
+ // * Check if the selected emoji and text are visible in the sidebar
+ await openSettingsSidebar();
+ await expect(element(by.text(defaultStatus.text))).toBeVisible();
+ await expect(element(by.id(`custom_status.emoji.${defaultStatus.emoji}`))).toBeVisible();
+ await expect(element(by.text(expiryText))).toBeVisible();
+
+ // # Wait for status to get cleared
+ await wait(timeouts.ONE_MIN * 31);
+ await expect(element(by.text(expiryText))).toBeNotVisible();
+ await closeSettingsSidebar();
+ }, timeouts.ONE_MIN * 35);
+
+ it('MM-T4091 RN apps: Custom Expiry Visibility (mobile)', async () => {
+ const message = 'Hello';
+ const expiryText = ClearAfterScreen.getExpiryText(30);
+
+ // # Open custom status screen
+ await openSettingsSidebar();
+ await CustomStatusScreen.open();
+
+ // * Check if all the default suggestions are visible
+ const isSuggestionPresentPromiseArray = [];
+ defaultCustomStatuses.map(async (text) => {
+ isSuggestionPresentPromiseArray.push(expect(getCustomStatusSuggestion(text)).toBeVisible());
+ });
+ await Promise.all(isSuggestionPresentPromiseArray);
+
+ // * Tap a suggestion and check if it is selected
+ await tapSuggestion(defaultStatus);
+ await expect(getCustomStatusSelectedDuration(defaultStatus.duration)).toBeVisible();
+
+ // # Tap on Done button and check if the modal closes
+ await CustomStatusScreen.close();
+
+ // * Check if the selected emoji, text and expiry time are visible in the sidebar
+ await openSettingsSidebar();
+ await expect(element(by.text(defaultStatus.text))).toBeVisible();
+ await expect(element(by.id(`custom_status.emoji.${defaultStatus.emoji}`))).toBeVisible();
+ await expect(element(by.text('(Until ' + expiryText + ')'))).toBeVisible();
+
+ // # Close settings sidebar
+ await closeSettingsSidebar();
+
+ // # Post a message and check if custom status and expiry time is present in the user popover
+ await ChannelScreen.postMessage(message);
+
+ // # Open user profile screen
+ await openSettingsSidebar();
+ await UserProfileScreen.open();
+ await UserProfileScreen.toBeVisible();
+
+ // * Check if custom status is present in the user profile screen and close it
+ await expect(element(by.id(`custom_status.emoji.${defaultStatus.emoji}`)).atIndex(0)).toExist();
+ await expect(element(by.text(defaultStatus.text).withAncestor(by.id(UserProfileScreen.testID.customStatus)))).toBeVisible();
+ await expect(element(by.text('STATUS (UNTIL ' + expiryText + ')'))).toBeVisible();
+
+ await UserProfileScreen.close();
+
+ // # Open the main sidebar and click on more direct messages button
+ await ChannelScreen.openMainSidebar();
+ await MoreDirectMessagesScreen.open();
+
+ // # Type the logged in user's username and tap it to open the DM
+ await MoreDirectMessagesScreen.searchInput.typeText(testUser.username);
+ await MoreDirectMessagesScreen.getUserAtIndex(0).tap();
+
+ // # Open the channel info screen
+ await ChannelInfoScreen.open();
+
+ // * Check if the custom status is present in the channel info screen and then close the screen
+ await expect(element(by.id(`custom_status.emoji.${defaultStatus.emoji}`)).atIndex(0)).toExist();
+ await expect(element(by.text(defaultStatus.text).withAncestor(by.id(ChannelInfoScreen.testID.headerCustomStatus)))).toBeVisible();
+ await expect(element(by.text('Until ' + expiryText))).toBeVisible();
+
+ await ChannelInfoScreen.close();
+ });
+
+ it("MM-T4092 RN apps: Custom Status Expiry - Editing 'Clear After' Time (mobile)", async () => {
+ // # Open custom status screen
+ await openSettingsSidebar();
+ await CustomStatusScreen.open();
+
+ // * Check if all the default suggestions are visible
+ const isSuggestionPresentPromiseArray = [];
+ defaultCustomStatuses.map(async (text) => {
+ isSuggestionPresentPromiseArray.push(expect(getCustomStatusSuggestion(text)).toBeVisible());
+ });
+ await Promise.all(isSuggestionPresentPromiseArray);
+
+ // * Tap a suggestion and check if it is selected
+ await tapSuggestion(defaultStatus);
+ await expect(getCustomStatusSelectedDuration(defaultStatus.duration)).toBeVisible();
+
+ // # Click on the Clear After option and check if the modal opens
+ await ClearAfterScreen.open();
+
+ // # Select a different expiry time and check if it is shown in Clear After
+ await ClearAfterScreen.tapSuggestion('four_hours');
+ await ClearAfterScreen.close();
+
+ // Check if selected duration is shown in clear after
+ await expect(getCustomStatusSelectedDuration('four_hours')).toBeVisible();
+
+ // * Open Clear After modal
+ await ClearAfterScreen.open();
+
+ // * Tap 'Custom' and check if it is selected
+ await element(by.text('Custom')).tap();
+
+ // * Check if select date and select time buttons are visible
+ expect(element(by.id(ClearAfterScreen.testID.selectDateButton))).toBeVisible();
+ expect(element(by.id(ClearAfterScreen.testID.selectTimeButton))).toBeVisible();
+
+ const am_pm = moment().format('A').toString();
+
+ // * Select some time in future in date time picker
+ await ClearAfterScreen.openTimePicker();
+
+ if (isAndroid()) {
+ await DateTimePicker.changeTimeAndroid('11', '30');
+ await DateTimePicker.tapOkButtonAndroid();
+ } else {
+ const timePicker = DateTimePicker.getDateTimePickerIOS();
+ await timePicker.setColumnToValue(0, '11');
+ await timePicker.setColumnToValue(1, '30');
+ }
+
+ await ClearAfterScreen.close();
+ await CustomStatusScreen.close();
+
+ // * Check if the selected emoji and text are visible in the sidebar
+ await openSettingsSidebar();
+ await expect(element(by.text(defaultStatus.text))).toBeVisible();
+ await expect(element(by.id(`custom_status.emoji.${defaultStatus.emoji}`))).toBeVisible();
+ await expect(element(by.text('(Until 11:30 ' + am_pm + ')'))).toBeVisible();
+
+ // # Close settings sidebar
+ await closeSettingsSidebar();
+ });
+});
diff --git a/ios/Podfile b/ios/Podfile
index 5a660e40d..2fac8bf66 100644
--- a/ios/Podfile
+++ b/ios/Podfile
@@ -34,4 +34,4 @@ target 'Mattermost' do
puts 'Patching XCDYouTube so it can playback videos'
%x(patch Pods/XCDYouTubeKit/XCDYouTubeKit/XCDYouTubeVideoOperation.m < patches/XCDYouTubeVideoOperation.patch)
end
-end
\ No newline at end of file
+end
diff --git a/package-lock.json b/package-lock.json
index c9d80e336..f626c1b89 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,6 +13,7 @@
"@react-native-community/async-storage": "1.12.1",
"@react-native-community/cameraroll": "4.0.4",
"@react-native-community/clipboard": "1.5.1",
+ "@react-native-community/datetimepicker": "3.5.2",
"@react-native-community/masked-view": "0.1.11",
"@react-native-community/netinfo": "6.0.0",
"@react-native-cookies/cookies": "6.0.8",
@@ -4545,6 +4546,14 @@
"react-native": ">=0.57.0"
}
},
+ "node_modules/@react-native-community/datetimepicker": {
+ "version": "3.5.2",
+ "resolved": "https://registry.npmjs.org/@react-native-community/datetimepicker/-/datetimepicker-3.5.2.tgz",
+ "integrity": "sha512-TWRuAtr/DnrEcRewqvXMLea2oB+YF+SbtuYLHguALLxNJQLl/RFB7aTNZeF+OoH75zKFqtXECXV1/uxQUpA+sg==",
+ "dependencies": {
+ "invariant": "^2.2.4"
+ }
+ },
"node_modules/@react-native-community/eslint-config": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@react-native-community/eslint-config/-/eslint-config-3.0.0.tgz",
@@ -42172,6 +42181,14 @@
"integrity": "sha512-AHAmrkLEH5UtPaDiRqoULERHh3oNv7Dgs0bTC0hO5Z2GdNokAMPT5w8ci8aMcRemcwbtdHjxChgtjbeA38GBdA==",
"requires": {}
},
+ "@react-native-community/datetimepicker": {
+ "version": "3.5.2",
+ "resolved": "https://registry.npmjs.org/@react-native-community/datetimepicker/-/datetimepicker-3.5.2.tgz",
+ "integrity": "sha512-TWRuAtr/DnrEcRewqvXMLea2oB+YF+SbtuYLHguALLxNJQLl/RFB7aTNZeF+OoH75zKFqtXECXV1/uxQUpA+sg==",
+ "requires": {
+ "invariant": "^2.2.4"
+ }
+ },
"@react-native-community/eslint-config": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@react-native-community/eslint-config/-/eslint-config-3.0.0.tgz",
diff --git a/package.json b/package.json
index 266b6dbfb..5f89b8122 100644
--- a/package.json
+++ b/package.json
@@ -11,6 +11,7 @@
"@react-native-community/async-storage": "1.12.1",
"@react-native-community/cameraroll": "4.0.4",
"@react-native-community/clipboard": "1.5.1",
+ "@react-native-community/datetimepicker": "3.5.2",
"@react-native-community/masked-view": "0.1.11",
"@react-native-community/netinfo": "6.0.0",
"@react-native-cookies/cookies": "6.0.8",