MM-36917 Custom status expiry (#5434)

* Started with the custom status feature
Added custom status actions and API integration
Added custom status selectors and types
Added custom status emoji component
Added EnableCustomStatuses flag in config
Added Set a status option in the settings sidebar

* Fixed emoji and added clear button
Fixed the default emoji in the set a status in the settings sidebar
Made a custom status label component to match the styling of rest of the drawer items
Fixed the bug in the selectors
Added localization id in en.json

* Added icon for clear button
Fixed the custom status label issue
Added styling and icon for the clear button
Fixed order of the localization ids

* Made custom status modal with suggestions
Fixed the long custom status overflowing in the sidebar with ellipses
Added the functionality to open custom status modal from sidebar
Made the constants for custom status
Made the custom status suggestion functional component
Made the custom status modal functional component
Made the selector for the recent custom statuses
Added some localization ids

* Changed the custom status modal to class component
Added recent custom statuses in the modal
Fixed the styling of titles
Fixed lint errors except one

* Fix types and use i18n translations

* Update types

* Refactored some code and made clear button component
Refactored the code of the custom status label to a different file and used it everywhere
Made a separate component for clear button and added it in recents and sidebar
Fixed the styling of the custom status text according to the new figma designs

* Added emojipicker in custom status modal
Fixed the Done button in custom status modal
Added the functionality to clear status from inside the modal

* Added custom status in user profile and post header and review fixes
Added custom status in user profile page
Added custom status emoji in post header
Added functionality to preventDoubleTap everywhere
Added localization ids
Review fixes

* Fixed several issues
Increased touch area of clear button everywhere
Fixed the event propagation issue in clearing suggestions
Fixed the center alignment issue of title in custom status modal
Added clear button in user profile page
Review fixes

* Fix types for custom status modal

* Fix tsc

* Fixed types and separated functions for Recent and suggestions in custom status modal

* Update app/components/sidebars/settings/settings_sidebar_base.js

* Updated localization id in settings sidebar

* Changed the method in removeRecentCustomStatus API call
Changed names of some variables

* Fixed the input height issue in ios and modalContext issue in ios

* Fixed various issues
Fixed clear button border radius issue in ios
Changed the method to post in removeRecentCustomStatus API call
Made the custom status modal input single line and fixed the height
Changed the height of iconContainer in custom status suggestion

* Fixed the event propagation issue without use of PanResponder

* Unit tests

Added unit tests for custom status emoji component
Added unit tests for custom status text component
Added unit tests for clear button component
Added unit tests for custom status suggestion component
Added unit tests for custom status modal component
Added tests and updated snapshtots in user profile screen

* Added custom status in several new locations
Added custom status emoji in LHS channel list
Added custom status emoji in channel header
Added custom status in channel info header
Modified the styles of some components

* Fixed width issue in LHS channel list and channel title (#8)

Added one check in channel info header

* E2E tests for custom status (#6)

* Completed MM-T3890, MM-T3891, MM-T3892, MM-T3893 e2e tests

Added EnableCustomUserStatuses in default config of detox
Added several testIDs in custom status modal and clear button components
Modified settings_sidebar ui component in detox support
Made custom status screen ui screen in detox support
Added testIDs in custom status suggestion and clear buttons
Modified custom status ui screen component in detox support
Added appropriate comments in the tests
Modified custom status emoji component to accept styling and added testID
Modified the custom status emoji styling in post header, user profile, LHS channel list, channel info header, channel title
Made user profile screen in detox support
Added testID prop in custom status emoji component
Added testIDs in channel info header and user profile and user profile row components

* Added unit tests for new components and updated tests for custom status emoji

Updated snapshots for existing components
Added new tests for channel title and LHS channel item
Added new tests for channel info and channel info header
Updated snapshots

* Fixed styles and emoji opacity in custom status input in modal

* Fixed the color of text and emoji in custom status suggestions
* Fixed the color of emoji and font size in the custom status input in modal

* Updated snapshots

* Fix the MM-T3893 E2E test case

* Update detox/e2e/test/smoke_test/custom_status.e2e.js

* Apply suggestions from code review

Co-authored-by: Joseph Baylon <joseph.baylon@mattermost.com>

* Updated snapshots and moved the custom status e2e test to different location

* Fix styles

* Fix ios styles

* Update snapshots

* Updated styles and localization ids

* Changed the API call for the remove recent custom status action (#12)

* Fix styles for ios

* Fixed styles in Android
Fixed the styling of clear button everywhere
Fixed the separator not showing issue in custom status suggestion
Fixed the emoji positioning issue in custom status suggestion

* Fixed tests and updated snapshots

* Fix stylessss for ios

* Updated snapshots

* Added close button and title in emojipicker screen opened from custom status modal

* Fixed the delay issue in clearing and setting the custom status in settings sidebar (#13)

* Fixed the delay issue in clearing the custom status in settings sidebar
Modified the unsetCustomStatus action to use request statuses
Modified the settings sidebar to use the request status to show or hide custom status
Made proper types and reducers for the unset custom status request

* Added the Retry message for setting and clearing the custom status
Made set custom status request action types and reducers
Modified the setCustomStatus action to dispatch request status actions
Made the retryMessage component
Added the logic for when to show the retry message

* Modified set custom status action to dispatch Update me action with the new custom status

* Modified the settings sidebar to use componentDidUpdate for the delay issue in custom status clearing and setting

* Modified componentDidUpdate and made a new function for handling request status change

* Modified handleRequestStatusChange function to two separate functions

* Code refactoring

* Updated tests

* Fixed the issue for custom status not updating in settings sidebar when changed from another client

* Fixed the issue for custom status not updating in settings sidebar when changed from another client
* Fixed the IOS UI issues
    - Fixed the font-weight of the channel header custom status
    - Fixed the extra space added on typing in input issue
* Fix custom status e2e tests

* Fixed the custom status input click area issue

* Fix custom status input touchable area

* added expiry time to custom status

* removed spaces from en.json

* Updated snapshots

* Updated snapshots

* added some ui changes to expiry stuff in custom status

* Apply suggestions from code review

Co-authored-by: Michel Engelen <32863416+michelengelen@users.noreply.github.com>

* Review fixes (#15)

* Review fixes
Sorted the import order in all files
Modified removeRecentCustomStatus action not to use bindClientFunction
Added types for custom status client functions
Added containerSize prop in clear button component
Removed the check for custom status enabled from custom status emoji and added it in post_header, channel_title, channel_item
Modified getCustomStatusSelector to return undefined if custom status does not exist
Modified settings_sidebar, channel_info, channel_info_header, custom_status_modal, user_profile to handle undefined custom status
Removed the text wrapper from the custom status emoji in channel_info_header
Added check for identifying the navigation button pressed in custom status modal

* Added server version check in isCustomStatusEnabled selector

* Refactored some code and added types in style in custom status modal
Added type in custom status suggestion functions in custom status modal
Refactored some code

* Fixed failing tests and updated snapshots

* Expiry Time functionality added

* Updated snapshot for clear button component

* ran npm run fix

* Added timezone functionality to custom status expiry

* Merged custom status into test-1230

* Review fixes

Removed logic for tracking requests for setting and unsetting custom status
Added logic for clearing status without delay in settings sidebar
Added event for set custom status failure and attached listener in settings sidebar
Added feature in custom status text component to accept string or FormattedText as prop and ellipsizeMode and numberOfLines as well
Removed labelSibling and failureText props from drawer item
Passed clearButton and retryMessage in labelComponent from settings sidebar to drawer item
Replaced Text with CustomStatusText in channel info header
Added localization in the custom status suggestions
Added exception handling in the getRecentCustomStatuses selector

* Fixed drawer item unit tests and updated snapshots

* Fixed UI issues for IOS in settings sidebar and main sidebar

* Updated snapshots

* Refactored duration code in custom status suggestion

* Review fixes
Removed check for user null or undefined from setCustomStatus action
Refactored some code in custom status emoji and post header
Memoized handleClear in custom status suggestion
Made stylesheet in settings_sidebar and added all styles there
Consolidated openCustomStatusModal function inside goToCustomStatusScreen

* Refactored some code
Refactored some code in set custom status action
Updated name of a unit test in drawer item and updated snapshot

* added ui fixes to date time picker component and displayExpiryTime
component

* Removed server version check for testing

* bug fix with timezone and statusSame

* Review UI fixes (#18)

Changed the padding for custom status both in settings sidebar and custom status suggestions
Changed the position of clear button in the settings sidebar

* UI fix in user profile screen
Fixed the call to apiGetChannelByName in custom status e2e test

* Added server version check for custom status feature

* Updated snapshots

* modified testIDs, output rendering logic

* Review fixes
Converted mapStateToProps to makeMapStateToProps and moved makeGetCustomStatus call inside them in several components

* Removed the server version check and fixed showing custom status in user profile bug

* Updated datetime picker version and fixed timezone issue
Fixed the memoization issue in getCurrentUserTimezone selector
Refactored the getCurrentDateTimeForTimezone to getCurrentMomentForTimezone
Added the timezoneOffsetInMinutes prop to the datetime picker

* Fixed ios UI issues
Fixed the positioning of Clear after in custom status modal in ios
Fixed the positioning of spinner date time picker in ios

* expiry time now showing in custom status,clear after modals

* Fixed the custom status not showing in user profile of other users if own status is not set

* Changed the emoji size in the channel title

* Updated snapshots

* Added custom status emoji in post header
Fixed the lint errors and type check errors

* Updated snapshots
Fixed the prop type isMilitaryTime for user_profile screen

* UI fixes and code refactoring (#19)

* added date time picker, fixed spacing in some components

* UI fixes, added isCustomStatusExpired selector

* removed extra spaces from package-lock.json

* Review Fixes, upgraded datetimepicker version

* Refactored some code

* Refactored some code

* Added server version check for the custom status feature

* Merge master into custom-status-expiry

* Review fixes and corrected import order

* Resolved tsc errors, corrected intl usage

* Code refactoring and minor bug fixes
Fixed the default value of isCustomStatusExpired in mapStateToProps from false to true in all components
Changed the ids of various expiry dropdown texts
Fixed the order of ids in en.json

* Changed date time format in front of Custom option in Clear after modal (#20)

* Corrected 'check' usage in front of 'Custom' in clear-after-modal

* Renamed clear_after_suggestions to clear_after_menu_item. removed unused
code

* Added unit tests and e2e tests

* Review fixes
Removed custom status clear after modal as a connected component

* Updated failing snapshot

* Review UI fixes
Changed the size of expiry time in channel header and fixed the width of custom status text along with the position of the emoji
Fixed the divider between custom status input and Clear after in the custom status modal
Changed the size of expiry time in custom status suggestions
Fixed the position of the tick in the clear after modal

* Updated snapshots

* Review fixes: Code refactoring

* Updated snapshots

* Code refactoring and fixed several issues
Updated and added unit tests for custom_status_expiry component
Refactored some code in custom_status_modal and clear_after_modal
Increased the size of the back icon in the Clear after modal
Added the logic for showing Clear after option only when the status is set
Added 30 minute intervals in the timepicker

* Custom status expiry review fixes (#24)

* Added server version check for custom status expiry and review fixes
Made an isCustomStatusExpirySupported selector for checking server version
Modified custom status modal not to show clear after row if expiry not supported
Fixed the bug of status not setting if expiry not supported
Added the logic for hiding the duratiion from suggestions if expiry not supported
Changed the transitioning of opening Clear After modal to open in the same stack level
Added the expiry supported check in settings sidebar and channel info screen and fixed the UI issues
Code refactoring

* Fixed the Back icon in the Clear after modal

* Fixed the Clear after flow and e2e tests
Fixed the Set status modal closing issue on closing Clear after modal
Added the feature to show Today in the custom status expiry component
Refactored and fixed the e2e tests for custom status expiry
Refactored some code in the Clear After modal

* Fixed unit tests and updated snapshots

* Fixed the bug showing invalid date in the Clear after field in the Set status modal

* Updated package-lock.json

* Code refactoring

* UI fixes in the user profile page
Added isExpirySupported check in the user profile page

* Fixed unit test and updated snapshot

* Updated snapshot for user_profile

* Review fixes: Code refactoring

* Fixed failing snapshots

* Fixed the MM-T4092 e2e test for ios (#25)

* Review fixes: Code refactoring
Deleted app/screens/custom_status_clear_after/index.ts file and changed import paths

* Code refactoring in e2e tests and updated eslint no shadow rule

* Disallowed the selection of past time in custom status date time picker

Co-authored-by: Chetanya Kandhari <chetanya.kandhari@brightscout.com>
Co-authored-by: Manoj <77336594+manojosh@users.noreply.github.com>
Co-authored-by: Joseph Baylon <joseph.baylon@mattermost.com>
Co-authored-by: Chetanya Kandhari <availchet@gmail.com>
Co-authored-by: Michel Engelen <32863416+michelengelen@users.noreply.github.com>
This commit is contained in:
Manoj Malik
2021-07-23 01:32:19 +05:30
committed by GitHub
parent 60e41c3f25
commit 14d377e713
53 changed files with 3042 additions and 290 deletions

View File

@@ -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": [
{

View File

@@ -0,0 +1,45 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/custom_status/custom_status_expiry should match snapshot 1`] = `
<Text
style={
Array [
Object {
"color": "#3d3c40",
"fontSize": 15,
},
Object {},
]
}
testID=""
>
<Text>
Today
</Text>
</Text>
`;
exports[`components/custom_status/custom_status_expiry should match snapshot with prefix and brackets 1`] = `
<Text
style={
Array [
Object {
"color": "#3d3c40",
"fontSize": 15,
},
Object {},
]
}
testID=""
>
(
<Text>
Until
</Text>
<Text>
Today
</Text>
)
</Text>
`;

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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(
<CustomStatusExpiry
{...baseProps}
/>,
);
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(
<CustomStatusExpiry
{...props}
/>,
);
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(
<CustomStatusExpiry
{...props}
/>,
);
expect(wrapper.getByText('Tomorrow')).toBeDefined();
});
});

View File

@@ -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 = (
<FormattedText
id='custom_status.expiry_time.today'
defaultMessage='Today'
/>
);
} else if (expiryMomentTime.isAfter(todayEndTime) && expiryMomentTime.isSameOrBefore(tomorrowEndTime)) {
dateComponent = (
<FormattedText
id='custom_status.expiry_time.tomorrow'
defaultMessage='Tomorrow'
/>
);
} else if (expiryMomentTime.isAfter(tomorrowEndTime)) {
let format = 'dddd';
if (expiryMomentTime.isAfter(plusSixDaysEndTime) && isCurrentYear) {
format = 'MMM DD';
} else if (!isCurrentYear) {
format = 'MMM DD, YYYY';
}
dateComponent = (
<FormattedDate
format={format}
timezone={timezone}
value={expiryMomentTime.toDate()}
/>
);
}
const useTime = showTimeCompulsory || !(expiryMomentTime.isSame(todayEndTime) || expiryMomentTime.isAfter(tomorrowEndTime));
return (
<Text
testID={testID}
style={[styles.text, textStyles]}
>
{withinBrackets && '('}
{showPrefix && (
<FormattedText
id='custom_status.expiry.until'
defaultMessage='Until'
/>
)}
{showPrefix && ' '}
{dateComponent}
{useTime && dateComponent && (
<>
{' '}
<FormattedText
id='custom_status.expiry.at'
defaultMessage='at'
/>
{' '}
</>
)}
{useTime && (
<FormattedTime
isMilitaryTime={militaryTime}
timezone={timezone || ''}
value={expiryMomentTime.toDate()}
/>
)}
{withinBrackets && ')'}
</Text>
);
};
export default CustomStatusExpiry;
const createStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
text: {
fontSize: 15,
color: theme.centerChannelColor,
},
};
});

View File

@@ -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`] = `
<DrawerLayoutAdapter
drawerPosition="right"
drawerWidth={710}
forwardRef={
Object {
"current": null,
}
}
onDrawerClose={[Function]}
onDrawerOpen={[Function]}
renderNavigationView={[Function]}
useNativeAnimations={true}
/>
`;

View File

@@ -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,
};
};
}

View File

@@ -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(
<SettingsSidebar
{...baseProps}
isCustomStatusEnabled={true}
customStatus={{
...customStatus,
duration: CustomStatusDuration.DATE_AND_TIME,
expires_at: '2200-04-13T18:09:12.451Z',
}}
/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
});
});

View File

@@ -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 = (
<View
@@ -269,24 +272,6 @@ export default class SettingsSidebarBase extends PureComponent {
</View>
);
const clearButton = isStatusSet ?
(
<ClearButton
handlePress={this.clearCustomStatus}
theme={theme}
testID='settings.sidebar.custom_status.action.clear'
/>
) : null;
const retryMessage = showRetryMessage ?
(
<FormattedText
id='custom_status.failure_message'
defaultMessage='Failed to update status. Try again'
style={style.retryMessage}
/>
) : null;
const text = isStatusSet ? customStatus.text : (
<FormattedText
id='mobile.routes.custom_status'
@@ -303,15 +288,35 @@ export default class SettingsSidebarBase extends PureComponent {
text={text}
theme={theme}
/>
{Boolean(isStatusSet && isCustomStatusExpirySupported && customStatus?.duration) && (
<CustomStatusExpiry
time={customStatus?.expires_at}
theme={theme}
textStyles={style.customStatusExpiryText}
withinBrackets={true}
showPrefix={true}
testID={'custom_status.expiry'}
/>
)}
</View>
{retryMessage}
{clearButton &&
{showRetryMessage && (
<FormattedText
id='custom_status.failure_message'
defaultMessage='Failed to update status. Try again'
style={style.retryMessage}
/>
)}
{isStatusSet && (
<View
style={style.clearButton}
>
{clearButton}
<ClearButton
handlePress={this.clearCustomStatus}
theme={theme}
testID='settings.sidebar.custom_status.action.clear'
/>
</View>
}
)}
</>
);
@@ -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,

View File

@@ -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';

View File

@@ -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;
},
);

View File

@@ -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;
}

View File

@@ -34,6 +34,7 @@ exports[`channelInfo should match snapshot 1`] = `
header=""
isArchived={false}
isCustomStatusEnabled={false}
isCustomStatusExpired={false}
isGroupConstrained={false}
isTeammateGuest={false}
memberCount={2}

View File

@@ -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,
},
]
}
/>
<CustomStatusText
ellipsizeMode="tail"
numberOfLines={1}
text="In a meeting"
textStyle={
<View
style={
Object {
"color": "#3d3c40",
"flex": 1,
"fontSize": 15,
"width": "80%",
"width": "90%",
}
}
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",
>
<CustomStatusText
ellipsizeMode="tail"
numberOfLines={1}
text="In a meeting"
textStyle={
Object {
"color": "#3d3c40",
"flex": 1,
"fontSize": 15,
"height": "100%",
}
}
}
/>
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",
}
}
/>
</View>
</View>
<View
style={
Object {
"marginTop": 15,
}
}
>
<ForwardRef
onLongPress={[Function]}
>
<View
style={
Object {
"paddingHorizontal": 15,
}
}
>
<InjectIntl(FormattedText)
defaultMessage="Purpose"
id="channel_info.purpose"
style={
Object {
"backgroundColor": "transparent",
"color": "#3d3c40",
"fontSize": 13,
"marginBottom": 10,
}
}
/>
<Text
style={
Object {
"color": "#3d3c40",
"fontSize": 13,
"lineHeight": 20,
}
}
testID="channel_info.header.purpose"
>
Purpose string
</Text>
</View>
</ForwardRef>
</View>
<View
style={
Object {
"marginTop": 15,
}
}
>
<ForwardRef
onLongPress={[Function]}
>
<View
style={
Object {
"paddingHorizontal": 15,
}
}
>
<InjectIntl(FormattedText)
defaultMessage="Header"
id="channel_info.header"
style={
Object {
"backgroundColor": "transparent",
"color": "#3d3c40",
"fontSize": 13,
"marginBottom": 10,
}
}
/>
<Connect(Markdown)
baseTextStyle={
Object {
"color": "#3d3c40",
"fontSize": 13,
"lineHeight": 20,
}
}
blockStyles={
Object {
"adjacentParagraph": Object {
"marginTop": 6,
},
"horizontalRule": Object {
"backgroundColor": "#3d3c40",
"height": 0.5,
"marginVertical": 10,
},
"quoteBlockIcon": Object {
"color": undefined,
},
}
}
disableAtChannelMentionHighlight={true}
disableGallery={true}
onChannelLinkPress={[MockFunction]}
testID="channel_info.header.header"
textStyles={
Object {
"code": Object {
"alignSelf": "center",
"backgroundColor": undefined,
"fontFamily": "Menlo",
},
"codeBlock": Object {
"fontFamily": "Menlo",
},
"del": Object {
"textDecorationLine": "line-through",
},
"emph": Object {
"fontStyle": "italic",
},
"error": Object {
"color": "#fd5960",
},
"heading1": Object {
"fontSize": 17,
"fontWeight": "700",
"lineHeight": 25,
},
"heading1Text": Object {
"paddingBottom": 8,
},
"heading2": Object {
"fontSize": 17,
"fontWeight": "700",
"lineHeight": 25,
},
"heading2Text": Object {
"paddingBottom": 8,
},
"heading3": Object {
"fontSize": 17,
"fontWeight": "700",
"lineHeight": 25,
},
"heading3Text": Object {
"paddingBottom": 8,
},
"heading4": Object {
"fontSize": 17,
"fontWeight": "700",
"lineHeight": 25,
},
"heading4Text": Object {
"paddingBottom": 8,
},
"heading5": Object {
"fontSize": 17,
"fontWeight": "700",
"lineHeight": 25,
},
"heading5Text": Object {
"paddingBottom": 8,
},
"heading6": Object {
"fontSize": 17,
"fontWeight": "700",
"lineHeight": 25,
},
"heading6Text": Object {
"paddingBottom": 8,
},
"link": Object {
"color": "#2389d7",
},
"mention": Object {
"color": "#2389d7",
},
"mention_highlight": Object {
"backgroundColor": "#ffe577",
"color": "#166de0",
},
"strong": Object {
"fontWeight": "bold",
},
"table_header_row": Object {
"fontWeight": "700",
},
}
}
value="Header string"
/>
</View>
</ForwardRef>
</View>
<Text
style={
Array [
Object {
"backgroundColor": "transparent",
"color": undefined,
"flexDirection": "row",
"fontSize": 12,
"marginTop": 5,
},
Object {
"paddingHorizontal": 15,
},
]
}
>
<InjectIntl(FormattedText)
defaultMessage="Created by {creator} on "
id="mobile.routes.channelInfo.createdBy"
values={
Object {
"creator": "Creator",
}
}
/>
<FormattedDate
format="LL"
value={123}
/>
</Text>
</View>
`;
exports[`channel_info_header should match snapshot with custom status expiry 1`] = `
<View
style={
Object {
"backgroundColor": "#ffffff",
"borderBottomColor": undefined,
"borderBottomWidth": 1,
"marginBottom": 40,
"paddingVertical": 15,
}
}
>
<View
style={
Array [
Object {
"alignItems": "center",
"flexDirection": "row",
"paddingVertical": 10,
},
Object {
"paddingHorizontal": 15,
},
]
}
>
<ChannelIcon
hasDraft={false}
isActive={false}
isArchived={false}
isInfo={true}
isUnread={false}
membersCount={3}
size={24}
testID="channel_info.header.channel_icon"
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",
}
}
type="D"
/>
<Text
ellipsizeMode="tail"
numberOfLines={1}
style={
Object {
"color": "#3d3c40",
"flex": 1,
"fontSize": 15,
"fontWeight": "600",
"marginLeft": 13,
}
}
testID="channel_info.header.display_name"
>
Channel name
</Text>
</View>
<View
style={
Array [
Object {
"paddingHorizontal": 15,
},
Object {
"alignItems": "center",
"flexDirection": "row",
"paddingVertical": 10,
},
]
}
testID="channel_info.header.custom_status"
>
<Connect(Emoji)
emojiName="calendar"
size={20}
testID="custom_status.emoji.calendar"
textStyle={
Array [
Object {
"color": "#3d3c40",
"marginRight": 8,
},
Object {
"bottom": 8,
},
]
}
/>
<View
style={
Object {
"width": "90%",
}
}
>
<CustomStatusText
ellipsizeMode="tail"
numberOfLines={1}
text="In a meeting"
textStyle={
Object {
"color": "#3d3c40",
"flex": 1,
"fontSize": 15,
"height": "100%",
}
}
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",
}
}
/>
<CustomStatusExpiry
showPrefix={true}
textStyles={
Object {
"color": undefined,
"fontSize": 13,
}
}
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",
}
}
time="2200-04-13T18:09:12.451Z"
/>
</View>
</View>
<View
style={

View File

@@ -50,6 +50,8 @@ export default class ChannelInfo extends PureComponent {
theme: PropTypes.object.isRequired,
customStatus: PropTypes.object,
isCustomStatusEnabled: PropTypes.bool.isRequired,
isCustomStatusExpired: PropTypes.bool.isRequired,
isCustomStatusExpirySupported: PropTypes.bool.isRequired,
};
static defaultProps = {
@@ -172,6 +174,8 @@ export default class ChannelInfo extends PureComponent {
isTeammateGuest,
customStatus,
isCustomStatusEnabled,
isCustomStatusExpired,
isCustomStatusExpirySupported,
} = this.props;
const style = getStyleSheet(theme);
@@ -207,6 +211,8 @@ export default class ChannelInfo extends PureComponent {
testID='channel_info.header'
customStatus={customStatus}
isCustomStatusEnabled={isCustomStatusEnabled}
isCustomStatusExpired={isCustomStatusExpired}
isCustomStatusExpirySupported={isCustomStatusExpirySupported}
/>
}
<View style={style.rowsContainer}>

View File

@@ -49,6 +49,7 @@ describe('channelInfo', () => {
isDirectMessage: false,
isLandscape: false,
isCustomStatusEnabled: false,
isCustomStatusExpired: false,
actions: {
getChannelStats: jest.fn(),
getCustomEmojisInText: jest.fn(),

View File

@@ -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 (
<View style={style.container}>
<View style={[style.channelNameContainer, style.row]}>
@@ -180,7 +188,7 @@ export default class ChannelInfoHeader extends React.PureComponent {
{displayName}
</Text>
</View>
{isCustomStatusEnabled && type === General.DM_CHANNEL && customStatus?.emoji &&
{showCustomStatus && (
<View
style={[style.row, style.customStatusContainer]}
testID={`${testID}.custom_status`}
@@ -188,18 +196,28 @@ export default class ChannelInfoHeader extends React.PureComponent {
<Emoji
emojiName={customStatus.emoji}
size={20}
textStyle={style.iconContainer}
textStyle={[style.iconContainer, {bottom: showCustomStatusExpiry ? 8 : 0}]}
testID={`custom_status.emoji.${customStatus.emoji}`}
/>
<CustomStatusText
text={customStatus.text}
theme={theme}
textStyle={style.customStatusText}
ellipsizeMode='tail'
numberOfLines={1}
/>
<View style={style.customStatus}>
<CustomStatusText
text={customStatus.text}
theme={theme}
textStyle={style.customStatusText}
ellipsizeMode='tail'
numberOfLines={1}
/>
{showCustomStatusExpiry && (
<CustomStatusExpiry
time={customStatus.expires_at}
theme={theme}
textStyles={style.customStatusExpiry}
showPrefix={true}
/>
)}
</View>
</View>
}
)}
{this.renderHasGuestText(style)}
{purpose.length > 0 &&
<View style={style.section}>
@@ -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',

View File

@@ -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(
<ChannelInfoHeader
{...baseProps}
isCustomStatusEnabled={true}
isCustomStatusExpirySupported={true}
type={General.DM_CHANNEL}
customStatus={customStatus}
/>,
{context: {intl: intlMock}},
);
expect(wrapper.getElement()).toMatchSnapshot();
});
});

View File

@@ -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,
};
};
}

View File

@@ -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",
}

View File

@@ -49,43 +49,45 @@ exports[`screens/custom_status_suggestion should match snapshot 1`] = `
}
}
>
<CustomStatusText
text="In a meeting"
textStyle={
Object {
"color": "#3d3c40",
<View>
<CustomStatusText
text="In a meeting"
textStyle={
Object {
"color": "#3d3c40",
}
}
}
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",
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",
}
}
}
/>
/>
</View>
</View>
</View>
</View>
@@ -141,95 +143,52 @@ exports[`screens/custom_status_suggestion should match snapshot with separator a
}
}
>
<CustomStatusText
text="In a meeting"
textStyle={
Object {
"color": "#3d3c40",
<View>
<CustomStatusText
text="In a meeting"
textStyle={
Object {
"color": "#3d3c40",
}
}
}
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",
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",
}
}
}
/>
</View>
<View
style={
Object {
"position": "absolute",
"right": 13,
"top": 4,
}
}
>
<ClearButton
containerSize={40}
handlePress={[Function]}
iconName="close"
size={18}
testID="custom_status_suggestion.clear.button"
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",
}
}
/>
/>
</View>
</View>
<View
style={
Object {
"backgroundColor": "rgba(61,60,64,0.2)",
"height": 1,
"marginRight": 16,
}
}
/>

View File

@@ -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 = {

View File

@@ -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<Props, State> {
@@ -75,21 +98,31 @@ class CustomStatusModal extends NavigationComponent<Props, State> {
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<Props, State> {
}
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<Props, State> {
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<string, StyleProp<ViewStyle>>) => {
const {recentCustomStatuses, theme} = this.props;
const {recentCustomStatuses, theme, isExpirySupported} = this.props;
const recentStatuses = recentCustomStatuses.map((status: UserCustomStatus, index: number) => (
<CustomStatusSuggestion
key={status.text}
handleSuggestionClick={this.handleSuggestionClick}
handleSuggestionClick={this.handleRecentCustomStatusSuggestionClick}
handleClear={this.handleRecentCustomStatusClear}
emoji={status.emoji}
text={status.text}
theme={theme}
separator={index !== recentCustomStatuses.length - 1}
duration={status.duration}
expires_at={status.expires_at}
isExpirySupported={isExpirySupported}
/>
));
@@ -177,22 +254,25 @@ class CustomStatusModal extends NavigationComponent<Props, State> {
};
renderCustomStatusSuggestions = (style: Record<string, StyleProp<ViewStyle>>) => {
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[]) => (
<CustomStatusSuggestion
key={status.text}
handleSuggestionClick={this.handleSuggestionClick}
handleSuggestionClick={this.handleCustomStatusSuggestionClick}
emoji={status.emoji}
text={status.text}
theme={theme}
separator={index !== arr.length - 1}
duration={status.duration}
isExpirySupported={isExpirySupported}
/>
));
@@ -236,11 +316,31 @@ class CustomStatusModal extends NavigationComponent<Props, State> {
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 = (
<TouchableOpacity
@@ -265,6 +365,42 @@ class CustomStatusModal extends NavigationComponent<Props, State> {
</TouchableOpacity>
);
const clearAfterTime = duration && duration === DATE_AND_TIME ? (
<View style={style.expiryTime}>
<CustomStatusExpiry
time={expires_at.toDate()}
theme={theme}
textStyles={style.customStatusExpiry}
/>
</View>
) : (
<FormattedText
id={durationValues[duration].id}
defaultMessage={durationValues[duration].defaultMessage}
style={style.expiryTime}
/>
);
const clearAfter = isExpirySupported && (
<TouchableOpacity
testID={'custom_status.clear_after.action'}
onPress={this.openClearAfterModal}
>
<View
testID={`custom_status.duration.${duration}`}
style={style.inputContainer}
>
<Text style={style.expiryTimeLabel}>{intl.formatMessage({id: 'mobile.custom_status.clear_after', defaultMessage: 'Clear After'})}</Text>
{clearAfterTime}
<CompassIcon
name='chevron-right'
size={24}
style={style.rightIcon}
/>
</View>
</TouchableOpacity>
);
const customStatusInput = (
<View style={style.inputContainer}>
<TextInput
@@ -285,6 +421,9 @@ class CustomStatusModal extends NavigationComponent<Props, State> {
underlineColorAndroid='transparent'
value={text}
/>
{isStatusSet && (
<View style={style.divider}/>
)}
{customStatusEmoji}
{isStatusSet ? (
<View
@@ -321,10 +460,14 @@ class CustomStatusModal extends NavigationComponent<Props, State> {
>
<StatusBar/>
<View style={style.scrollView}>
{customStatusInput}
<View style={style.block}>
{customStatusInput}
{isStatusSet && clearAfter}
</View>
{this.renderRecentCustomStatuses(style)}
{this.renderCustomStatusSuggestions(style)}
</View>
<View style={style.separator}/>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
@@ -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),
},
};
});

View File

@@ -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(
<CustomStatusSuggestion
{...baseProps}
/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
expect(wrapper.dive().getElement()).toMatchSnapshot();
});
it('should match snapshot with separator and clear button', () => {
const wrapper = shallow(
const wrapper = shallowWithIntl(
<CustomStatusSuggestion
{...baseProps}
separator={true}
@@ -36,17 +38,17 @@ describe('screens/custom_status_suggestion', () => {
/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
expect(wrapper.dive().getElement()).toMatchSnapshot();
});
it('should call handleSuggestionClick on clicking the suggestion', () => {
const wrapper = shallow(
const wrapper = shallowWithIntl(
<CustomStatusSuggestion
{...baseProps}
/>,
);
wrapper.find(TouchableOpacity).simulate('press');
wrapper.dive().find(TouchableOpacity).simulate('press');
expect(baseProps.handleSuggestionClick).toBeCalled();
});
});

View File

@@ -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 ?
(
<View style={style.clearButtonContainer}>
<ClearButton
@@ -62,11 +67,22 @@ const CustomStatusSuggestion = (props: Props) => {
</Text>
<View style={style.wrapper}>
<View style={style.textContainer}>
<CustomStatusText
text={text}
theme={theme}
textStyle={{color: theme.centerChannelColor}}
/>
<View>
<CustomStatusText
text={text}
theme={theme}
textStyle={style.customStatusText}
/>
</View>
{Boolean(duration && isExpirySupported) && (
<View style={{paddingTop: 5}}>
<CustomStatusText
text={intl.formatMessage(durationValues[duration])}
theme={theme}
textStyle={style.customStatusDuration}
/>
</View>
)}
</View>
{clearButton}
{separator && <View style={style.divider}/>}
@@ -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,
},
};
});

View File

@@ -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,
};
};
}

View File

@@ -0,0 +1,173 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`screens/clear_after_menu_item should match snapshot 1`] = `
<View>
<ForwardRef
onPress={[Function]}
testID="clear_after.menu_item."
>
<View
style={
Object {
"backgroundColor": "#ffffff",
"display": "flex",
"flexDirection": "row",
"padding": 10,
}
}
>
<View
style={
Object {
"alignItems": "center",
"flex": 1,
"flexDirection": "row",
"marginBottom": 2,
"marginLeft": 5,
"position": "relative",
"width": "70%",
}
}
>
<CustomStatusText
text="Don't clear"
textStyle={
Object {
"color": "#3d3c40",
}
}
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",
}
}
/>
</View>
</View>
</ForwardRef>
</View>
`;
exports[`screens/clear_after_menu_item should match snapshot with separator and selected check 1`] = `
<View>
<ForwardRef
onPress={[Function]}
testID="clear_after.menu_item."
>
<View
style={
Object {
"backgroundColor": "#ffffff",
"display": "flex",
"flexDirection": "row",
"padding": 10,
}
}
>
<View
style={
Object {
"alignItems": "center",
"flex": 1,
"flexDirection": "row",
"marginBottom": 2,
"marginLeft": 5,
"position": "relative",
"width": "70%",
}
}
>
<CustomStatusText
text="Don't clear"
textStyle={
Object {
"color": "#3d3c40",
}
}
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",
}
}
/>
<View
style={
Object {
"position": "absolute",
"right": 14,
}
}
>
<CompassIcon
name="check"
size={24}
style={
Object {
"borderRadius": 1000,
"color": "#166de0",
}
}
/>
</View>
</View>
</View>
<View
style={
Object {
"backgroundColor": "rgba(61,60,64,0.2)",
"height": 1,
"marginHorizontal": 16,
}
}
/>
</ForwardRef>
</View>
`;

View File

@@ -0,0 +1,64 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`screens/clear_after_modal should match snapshot 1`] = `
<ClearAfterModal
handleClearAfterClick={[MockFunction]}
initialDuration=""
intl={
Object {
"defaultFormats": Object {},
"defaultLocale": "en",
"formatDate": [Function],
"formatHTMLMessage": [Function],
"formatMessage": [Function],
"formatNumber": [Function],
"formatPlural": [Function],
"formatRelative": [Function],
"formatTime": [Function],
"formats": Object {},
"formatters": Object {
"getDateTimeFormat": [Function],
"getMessageFormat": [Function],
"getNumberFormat": [Function],
"getPluralFormat": [Function],
"getRelativeFormat": [Function],
},
"locale": "en",
"messages": Object {},
"now": [Function],
"onError": [Function],
"textComponent": "span",
"timeZone": null,
}
}
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",
}
}
/>
`;

View File

@@ -0,0 +1,116 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`screens/date_time_selector should match snapshot 1`] = `
<View
style={
Object {
"backgroundColor": "#ffffff",
"flex": 1,
"paddingTop": 10,
}
}
>
<View
style={
Object {
"alignItems": "center",
"flex": 1,
"flexDirection": "row",
"justifyContent": "space-evenly",
"marginBottom": 10,
}
}
>
<View
accessibilityRole="button"
accessibilityState={Object {}}
accessible={true}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
Object {
"opacity": 1,
}
}
testID="clear_after.menu_item.date_and_time.button.date"
>
<View
style={
Array [
Object {},
]
}
>
<Text
style={
Array [
Object {
"color": "#007AFF",
"fontSize": 18,
"margin": 8,
"textAlign": "center",
},
Object {
"color": "#166de0",
},
]
}
>
Select Date
</Text>
</View>
</View>
<View
accessibilityRole="button"
accessibilityState={Object {}}
accessible={true}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
Object {
"opacity": 1,
}
}
testID="clear_after.menu_item.date_and_time.button.time"
>
<View
style={
Array [
Object {},
]
}
>
<Text
style={
Array [
Object {
"color": "#007AFF",
"fontSize": 18,
"margin": 8,
"textAlign": "center",
},
Object {
"color": "#166de0",
},
]
}
>
Select Time
</Text>
</View>
</View>
</View>
</View>
`;

View File

@@ -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(
<ClearAfterMenuItem {...baseProps}/>,
);
expect(wrapper.dive().getElement()).toMatchSnapshot();
});
it('should match snapshot with separator and selected check', () => {
const wrapper = shallowWithIntl(
<ClearAfterMenuItem
{...baseProps}
separator={true}
isSelected={true}
/>,
);
expect(wrapper.dive().getElement()).toMatchSnapshot();
});
it('should call handleItemClick on clicking the suggestion', () => {
const wrapper = shallowWithIntl(
<ClearAfterMenuItem
{...baseProps}
/>,
);
wrapper.dive().find(TouchableOpacity).simulate('press');
expect(baseProps.handleItemClick).toBeCalled();
});
});

View File

@@ -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<string>('');
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 (
<View>
<TouchableOpacity
testID={`clear_after.menu_item.${duration}`}
onPress={handleClick}
>
<View style={style.container}>
<View style={style.textContainer}>
<CustomStatusText
text={expiryMenuItems[duration]}
theme={theme}
textStyle={{color: theme.centerChannelColor}}
/>
{isSelected && (
<View style={style.rightPosition}>
<CompassIcon
name={'check'}
size={24}
style={style.button}
/>
</View>
)}
{showExpiryTime && expiry !== '' && (
<View style={style.rightPosition}>
<CustomStatusExpiry
theme={theme}
time={moment(expiry).toDate()}
textStyles={style.customStatusExpiry}
showTimeCompulsory={true}
showToday={true}
/>
</View>
)}
</View>
</View>
{separator && <View style={style.divider}/>}
</TouchableOpacity>
{showDateTimePicker && (
<DateTimePicker
theme={theme}
handleChange={handleCustomExpiresAtChange}
/>
)}
</View>
);
};
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,
},
};
});

View File

@@ -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(
<ClearAfterModal {...baseProps}/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
});
});

View File

@@ -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<Props, State> {
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 (
<ClearAfterMenuItem
key={item}
handleItemClick={this.handleItemClick}
duration={item}
theme={theme}
separator={index !== arr.length - 2}
isSelected={duration === item}
/>
);
},
);
if (clearAfterMenu.length === 0) {
return null;
}
return (
<View testID='clear_after.menu'>
<View style={style.block}>{clearAfterMenu}</View>
</View>
);
};
render() {
const {theme} = this.props;
const style = getStyleSheet(theme);
const {duration, expiresAt, showExpiryTime} = this.state;
return (
<SafeAreaView
testID='clear_after.screen'
style={style.container}
>
<StatusBar/>
<KeyboardAwareScrollView bounces={false}>
<View style={style.scrollView}>
{this.renderClearAfterMenu()}
</View>
<View style={style.block}>
<ClearAfterMenuItem
handleItemClick={this.handleItemClick}
duration={DATE_AND_TIME}
theme={theme}
separator={false}
isSelected={duration === DATE_AND_TIME && expiresAt === ''}
showExpiryTime={showExpiryTime}
showDateTimePicker={duration === DATE_AND_TIME}
/>
</View>
</KeyboardAwareScrollView>
</SafeAreaView>
);
}
}
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,
},
};
});

View File

@@ -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(
<DateTimeSelector {...baseProps}/>,
);
expect(wrapper.toJSON()).toMatchSnapshot();
});
});

View File

@@ -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<Moment>(minimumDate);
const [mode, setMode] = useState<AndroidMode>('date');
const [show, setShow] = useState<boolean>(false);
const onChange = (_: React.ChangeEvent<HTMLInputElement>, 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 (
<View style={styles.container}>
<View style={styles.buttonContainer}>
<Button
testID={'clear_after.menu_item.date_and_time.button.date'}
onPress={showDatepicker}
title='Select Date'
color={theme.buttonBg}
/>
<Button
testID={'clear_after.menu_item.date_and_time.button.time'}
onPress={showTimepicker}
title='Select Time'
color={theme.buttonBg}
/>
</View>
{show && (
<DateTimePicker
testID='clear_after.date_time_picker'
value={date.toDate()}
mode={mode}
is24Hour={militaryTime}
display={Platform.OS === 'ios' ? 'spinner' : 'default'}
onChange={onChange}
textColor={theme.centerChannelColor}
minimumDate={minimumDate.toDate()}
minuteInterval={CUSTOM_STATUS_TIME_PICKER_INTERVALS_IN_MINUTES}
timeZoneOffsetInMinutes={timezoneOffSetInMinutes}
/>
)}
</View>
);
};
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;

View File

@@ -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;

View File

@@ -478,6 +478,7 @@ exports[`user_profile should match snapshot with custom status 1`] = `
}
>
Status
</Text>
<View
style={
@@ -490,6 +491,7 @@ exports[`user_profile should match snapshot with custom status 1`] = `
style={
Object {
"color": "#3d3c40",
"marginBottom": 3,
"marginRight": 5,
}
}
@@ -508,16 +510,44 @@ exports[`user_profile should match snapshot with custom status 1`] = `
}
}
>
<Text
style={
<CustomStatusText
text="In a meeting"
textStyle={
Object {
"color": "#3d3c40",
"fontSize": 15,
}
}
>
In a meeting
</Text>
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",
}
}
/>
</View>
</View>
</View>
@@ -694,6 +724,7 @@ exports[`user_profile should match snapshot with custom status and isMyUser true
}
>
Status
</Text>
<View
style={
@@ -706,6 +737,7 @@ exports[`user_profile should match snapshot with custom status and isMyUser true
style={
Object {
"color": "#3d3c40",
"marginBottom": 3,
"marginRight": 5,
}
}
@@ -724,16 +756,44 @@ exports[`user_profile should match snapshot with custom status and isMyUser true
}
}
>
<Text
style={
<CustomStatusText
text="In a meeting"
textStyle={
Object {
"color": "#3d3c40",
"fontSize": 15,
}
}
>
In a meeting
</Text>
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",
}
}
/>
</View>
<View
style={
@@ -866,3 +926,291 @@ exports[`user_profile should match snapshot with custom status and isMyUser true
</ScrollView>
</RNCSafeAreaView>
`;
exports[`user_profile should match snapshot with custom status expiry 1`] = `
<RNCSafeAreaView
style={
Object {
"flex": 1,
}
}
testID="user_profile.screen"
>
<Connect(StatusBar) />
<ScrollView
contentContainerStyle={
Object {
"paddingBottom": 48,
}
}
style={
Object {
"backgroundColor": "#ffffff",
"flex": 1,
}
}
testID="user_profile.scroll_view"
>
<View
style={
Object {
"alignItems": "center",
"justifyContent": "center",
"padding": 25,
}
}
>
<Connect(ProfilePicture)
iconSize={104}
size={153}
statusBorderWidth={6}
statusSize={36}
testID="user_profile.profile_picture"
userId="4hzdnk6mg7gepe7yze6m3domnc"
/>
<Text
style={
Object {
"color": "#3d3c40",
"fontSize": 15,
"marginTop": 15,
}
}
testID="user_profile.username"
>
@fred
</Text>
</View>
<View
style={
Object {
"backgroundColor": "#EBEBEC",
"height": 1,
"marginLeft": 16,
"marginRight": 22,
}
}
/>
<View
style={
Object {
"marginBottom": 25,
"marginHorizontal": 15,
}
}
>
<View
testID="user_profile.custom_status"
>
<Text
style={
Object {
"color": undefined,
"fontSize": 13,
"fontWeight": "600",
"marginBottom": 10,
"marginTop": 25,
"textTransform": "uppercase",
}
}
>
Status
<CustomStatusExpiry
showPrefix={true}
textStyles={
Object {
"color": undefined,
"fontSize": 13,
"fontWeight": "600",
"textTransform": "uppercase",
}
}
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",
}
}
time="2200-04-13T18:09:12.451Z"
withinBrackets={true}
/>
</Text>
<View
style={
Object {
"flexDirection": "row",
}
}
>
<Text
style={
Object {
"color": "#3d3c40",
"marginBottom": 3,
"marginRight": 5,
}
}
testID="custom_status.emoji.calendar"
>
<Connect(Emoji)
emojiName="calendar"
size={20}
/>
</Text>
<View
style={
Object {
"justifyContent": "center",
"width": "80%",
}
}
>
<CustomStatusText
text="In a meeting"
textStyle={
Object {
"color": "#3d3c40",
"fontSize": 15,
}
}
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",
}
}
/>
</View>
</View>
</View>
<View
testID="user_profile.display_block"
>
<Text
style={
Object {
"color": undefined,
"fontSize": 13,
"fontWeight": "600",
"marginBottom": 10,
"marginTop": 25,
"textTransform": "uppercase",
}
}
testID="user_profile.display_block.nickname.label"
>
Nickname
</Text>
<Text
style={
Object {
"color": "#3d3c40",
"fontSize": 15,
}
}
testID="user_profile.display_block.nickname.value"
>
nick
</Text>
</View>
</View>
<View
style={
Object {
"backgroundColor": "#EBEBEC",
"height": 1,
"marginLeft": 16,
"marginRight": 22,
}
}
/>
<userProfileRow
action={[Function]}
defaultMessage="Send Message"
icon="send"
iconColor="rgba(0, 0, 0, 0.7)"
iconSize={24}
testID="user_profile.send_message.action"
textColor="#000"
textId="mobile.routes.user_profile.send_message"
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",
}
}
togglable={false}
/>
</ScrollView>
</RNCSafeAreaView>
`;

View File

@@ -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,
};
};
}

View File

@@ -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 (
<View
testID='user_profile.custom_status'
>
<Text style={style.header}>{label}</Text>
<Text style={style.header}>
{label}
{' '}
{Boolean(customStatus?.duration && isCustomStatusExpirySupported) && (
<CustomStatusExpiry
time={customStatus?.expires_at}
theme={theme}
textStyles={style.customStatusExpiry}
showPrefix={true}
withinBrackets={true}
/>
)}
</Text>
<View style={style.customStatus}>
<Text
style={style.iconContainer}
@@ -257,9 +274,11 @@ export default class UserProfile extends PureComponent {
/>
</Text>
<View style={style.customStatusTextContainer}>
<Text style={style.text}>
{customStatus.text}
</Text>
<CustomStatusText
text={customStatus?.text}
theme={theme}
textStyle={style.text}
/>
</View>
{isMyUser && (
<View style={style.clearButton}>
@@ -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',

View File

@@ -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(
<UserProfile
{...customStatusProps}
customStatus={{
...customStatus,
duration: CustomStatusDuration.DATE_AND_TIME,
expires_at: '2200-04-13T18:09:12.451Z',
}}
isCustomStatusExpirySupported={true}
/>,
{context: {intl: {formatMessage: jest.fn()}}},
);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should contain bot tag', () => {
const botUser = {
email: 'test@test.com',

View File

@@ -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);
}

View File

@@ -16,3 +16,6 @@ export function getUtcOffsetForTimeZone(timezone) {
return moment.tz(timezone).utcOffset();
}
export function getCurrentMomentForTimezone(timezone) {
return timezone ? moment.tz(timezone) : moment();
}

View File

@@ -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",

View File

@@ -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;

View File

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

View File

@@ -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();

View File

@@ -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;

View File

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

View File

@@ -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();
});
});

View File

@@ -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
end

17
package-lock.json generated
View File

@@ -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",

View File

@@ -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",