From 68eff30eda703d6bdcd13bd718173eb1a893d763 Mon Sep 17 00:00:00 2001 From: Chetanya Kandhari Date: Wed, 14 Nov 2018 20:06:45 +0530 Subject: [PATCH] [#MM-12410] Custom Terms of Service (#2234) * [#MM-12410] Custom Terms of Service Modal. - Create a new screen for terms of service - Register it in app navigation - Open it as a modal when login succeeds or app is opened when already logged in. - Get terms of service and render it as markdown * Refactor service terms to terms of service * Show terms modal when launching channel instead of componentDidMount * Remove footer text * Close modal before logging out * Use dismissAllModals instead od this.close * Update header styles * Update the back icon * Add left icon conditinally based on os * Update en.json * - Integrate Accept/Reject API - Refactor show navigator to util - Remove title - Code refactoring * Handle Accept/Reject API errors * Update package-lock * - Show Alert to handle API error - Handle API error for GetTerms error, Accept/Reject error and Reject success - Changes in en.json - Remove error banner - Open terms modal in sso and experimental certificate * Enable navigator buttons after getting terms * Rename Ok to OK in Alert * Add retry for API error * Fix check-style * Fix FailedNetworkAction Error Message * - Use Close button instead of back button - Update en.json * Remove unused import * Disable hashtags and atmentions in terms modal * Enable close button when terms text fails to load * Minor changes * Fix show terms on sso login * Handle restartApp event * Add test cases * Revert change to app/components/formatted_markdown_text.js * Show the terms modal on the channel page * Remove remaining references to showTermsOfService * Disable Hashtags, AtMentions and ChannelLinks * Update the props to markdown component * Pass the text as literal to renderText * Return the generated renderTexts * Fix lint * Render ~, #, @ with Channel link, Hashtags and At Mentions * Fix failing tests Revert unwanted changes * Hide the channel page content before the terms are accepted * Fix lint and failing tests * Revert changes to hide channel while terms are not accepted * Open modal in componentDidMount instead of constructor Code refactoring Disable back button on android * Remove unused import * No need to await for showModal * No need to await for accept and reject * Update flag for updateTermsOfServiceStatus to updateMyTermsOfServiceStatus - as phase 2 redux PR was mered. * Fix check-style * Change opacity in terms of service text - Fix tests * Revert package-lock.json changes --- app/components/markdown/markdown.js | 20 +- app/screens/channel/channel.js | 34 + app/screens/channel/index.js | 2 + .../error_teams_list/error_teams_list.js | 3 + app/screens/index.js | 1 + app/screens/permalink/permalink.js | 3 + app/screens/select_team/select_team.js | 3 + .../terms_of_service.test.js.snap | 783 ++++++++++++++++++ app/screens/terms_of_service/index.js | 32 + .../terms_of_service/terms_of_service.js | 305 +++++++ .../terms_of_service/terms_of_service.test.js | 134 +++ app/screens/thread/thread.js | 3 + app/screens/thread/thread.test.js | 3 + app/screens/user_profile/user_profile.js | 3 + assets/base/i18n/en.json | 8 + 15 files changed, 1336 insertions(+), 1 deletion(-) create mode 100644 app/screens/terms_of_service/__snapshots__/terms_of_service.test.js.snap create mode 100644 app/screens/terms_of_service/index.js create mode 100644 app/screens/terms_of_service/terms_of_service.js create mode 100644 app/screens/terms_of_service/terms_of_service.test.js diff --git a/app/components/markdown/markdown.js b/app/components/markdown/markdown.js index 0ee784551..b37d68299 100644 --- a/app/components/markdown/markdown.js +++ b/app/components/markdown/markdown.js @@ -55,12 +55,18 @@ export default class Markdown extends PureComponent { textStyles: PropTypes.object, theme: PropTypes.object.isRequired, value: PropTypes.string.isRequired, + disableHashtags: PropTypes.bool, + disableAtMentions: PropTypes.bool, + disableChannelLink: PropTypes.bool, }; static defaultProps = { textStyles: {}, blockStyles: {}, onLongPress: () => true, + disableHashtags: false, + disableAtMentions: false, + disableChannelLink: false, }; constructor(props) { @@ -191,6 +197,10 @@ export default class Markdown extends PureComponent { } renderAtMention = ({context, mentionName}) => { + if (this.props.disableAtMentions) { + return this.renderText({context, literal: `@${mentionName}`}); + } + return ( { + if (this.props.disableChannelLink) { + return this.renderText({context, literal: `~${channelName}`}); + } + return ( { + renderHashtag = ({context, hashtag}) => { + if (this.props.disableHashtags) { + return this.renderText({context, literal: `#${hashtag}`}); + } + return ( { + const {navigator, theme} = this.props; + const closeButton = await MaterialIcon.getImageSource('close', 20, theme.sidebarHeaderTextColor); + navigator.showModal({ + screen: 'TermsOfService', + animationType: 'slide-up', + title: '', + backButtonTitle: '', + animated: true, + navigatorStyle: { + navBarTextColor: theme.centerChannelColor, + navBarBackgroundColor: theme.centerChannelBg, + navBarButtonColor: theme.buttonBg, + screenBackgroundColor: theme.centerChannelBg, + modalPresentationStyle: 'overCurrentContext', + }, + overrideBackPress: true, + passProps: { + closeButton, + }, + }); + }; + goToChannelInfo = preventDoubleTap(() => { const {intl} = this.context; const {navigator, theme} = this.props; diff --git a/app/screens/channel/index.js b/app/screens/channel/index.js index 05fb3c482..2b2fbe620 100644 --- a/app/screens/channel/index.js +++ b/app/screens/channel/index.js @@ -10,6 +10,7 @@ import {RequestStatus} from 'mattermost-redux/constants'; import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/channels'; import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; +import {shouldShowTermsOfService} from 'mattermost-redux/selectors/entities/users'; import { loadChannelsIfNecessary, @@ -32,6 +33,7 @@ function mapStateToProps(state) { currentChannelId: getCurrentChannelId(state), isLandscape: isLandscape(state), theme: getTheme(state), + showTermsOfService: shouldShowTermsOfService(state), }; } diff --git a/app/screens/error_teams_list/error_teams_list.js b/app/screens/error_teams_list/error_teams_list.js index 8c1fcd74c..7d303751b 100644 --- a/app/screens/error_teams_list/error_teams_list.js +++ b/app/screens/error_teams_list/error_teams_list.js @@ -63,6 +63,9 @@ export default class ErrorTeamsList extends PureComponent { statusBarHideWithNavBar: false, screenBackgroundColor: theme.centerChannelBg, }, + passProps: { + disableTermsModal: true, + }, }); }; diff --git a/app/screens/index.js b/app/screens/index.js index a24c63ee2..cab6f1f7b 100644 --- a/app/screens/index.js +++ b/app/screens/index.js @@ -57,6 +57,7 @@ export function registerScreens(store, Provider) { Navigation.registerComponent('SSO', () => wrapWithContextProvider(require('app/screens/sso').default), store, Provider); Navigation.registerComponent('Table', () => wrapWithContextProvider(require('app/screens/table').default), store, Provider); Navigation.registerComponent('TableImage', () => wrapWithContextProvider(require('app/screens/table_image').default), store, Provider); + Navigation.registerComponent('TermsOfService', () => wrapWithContextProvider(require('app/screens/terms_of_service').default), store, Provider); Navigation.registerComponent('TextPreview', () => wrapWithContextProvider(require('app/screens/text_preview').default), store, Provider); Navigation.registerComponent('Thread', () => wrapWithContextProvider(require('app/screens/thread').default), store, Provider); Navigation.registerComponent('TimezoneSettings', () => wrapWithContextProvider(require('app/screens/timezone').default), store, Provider); diff --git a/app/screens/permalink/permalink.js b/app/screens/permalink/permalink.js index 47b23717c..ee48cd4ba 100644 --- a/app/screens/permalink/permalink.js +++ b/app/screens/permalink/permalink.js @@ -253,6 +253,9 @@ export default class Permalink extends PureComponent { statusBarHideWithNavBar: false, screenBackgroundColor: theme.centerChannelBg, }, + passProps: { + disableTermsModal: true, + }, }); } diff --git a/app/screens/select_team/select_team.js b/app/screens/select_team/select_team.js index 153a4c116..a57117d3c 100644 --- a/app/screens/select_team/select_team.js +++ b/app/screens/select_team/select_team.js @@ -126,6 +126,9 @@ export default class SelectTeam extends PureComponent { statusBarHideWithNavBar: false, screenBackgroundColor: theme.centerChannelBg, }, + passProps: { + disableTermsModal: true, + }, }); }; diff --git a/app/screens/terms_of_service/__snapshots__/terms_of_service.test.js.snap b/app/screens/terms_of_service/__snapshots__/terms_of_service.test.js.snap new file mode 100644 index 000000000..a23194da5 --- /dev/null +++ b/app/screens/terms_of_service/__snapshots__/terms_of_service.test.js.snap @@ -0,0 +1,783 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TermsOfService should enable/disable navigator buttons on setNavigatorButtons true/false 1`] = ` + + + + + + +`; + +exports[`TermsOfService should enable/disable navigator buttons on setNavigatorButtons true/false 2`] = ` + + + + + + +`; + +exports[`TermsOfService should match snapshot 1`] = ` + +`; + +exports[`TermsOfService should match snapshot for fail of get terms 1`] = ` + + + + +`; + +exports[`TermsOfService should match snapshot for get terms 1`] = ` + +`; + +exports[`TermsOfService should match snapshot on enableNavigatorLogout 1`] = ` + + + + + + +`; diff --git a/app/screens/terms_of_service/index.js b/app/screens/terms_of_service/index.js new file mode 100644 index 000000000..a9c843bac --- /dev/null +++ b/app/screens/terms_of_service/index.js @@ -0,0 +1,32 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {bindActionCreators} from 'redux'; +import {connect} from 'react-redux'; + +import {getTermsOfService, logout, updateMyTermsOfServiceStatus} from 'mattermost-redux/actions/users'; +import {getConfig} from 'mattermost-redux/selectors/entities/general'; +import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; + +import TermsOfService from './terms_of_service.js'; + +function mapStateToProps(state) { + const config = getConfig(state); + + return { + siteName: config.SiteName, + theme: getTheme(state), + }; +} + +function mapDispatchToProps(dispatch) { + return { + actions: bindActionCreators({ + getTermsOfService, + logout, + updateMyTermsOfServiceStatus, + }, dispatch), + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(TermsOfService); diff --git a/app/screens/terms_of_service/terms_of_service.js b/app/screens/terms_of_service/terms_of_service.js new file mode 100644 index 000000000..543db1fc9 --- /dev/null +++ b/app/screens/terms_of_service/terms_of_service.js @@ -0,0 +1,305 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {PureComponent} from 'react'; +import PropTypes from 'prop-types'; +import { + Alert, + ScrollView, + View, +} from 'react-native'; +import {intlShape} from 'react-intl'; + +import FailedNetworkAction from 'app/components/failed_network_action'; +import Loading from 'app/components/loading'; +import Markdown from 'app/components/markdown'; +import StatusBar from 'app/components/status_bar'; + +import {t} from 'app/utils/i18n'; +import {getMarkdownTextStyles, getMarkdownBlockStyles} from 'app/utils/markdown'; +import {makeStyleSheetFromTheme, setNavigatorStyles} from 'app/utils/theme'; + +const errorTitle = { + id: t('mobile.terms_of_service.get_terms_error_title'), + defaultMessage: 'Unable to load terms of service.', +}; + +const errorDescription = { + id: t('mobile.terms_of_service.get_terms_error_description'), + defaultMessage: 'Make sure you have an active internet connection and try again. If this issue persists, contact your System Administrator.', +}; + +export default class TermsOfService extends PureComponent { + static propTypes = { + actions: PropTypes.shape({ + logout: PropTypes.func.isRequired, + getTermsOfService: PropTypes.func.isRequired, + updateMyTermsOfServiceStatus: PropTypes.func.isRequired, + }).isRequired, + closeButton: PropTypes.object, + navigator: PropTypes.object, + siteName: PropTypes.string, + theme: PropTypes.object, + }; + + static contextTypes = { + intl: intlShape, + }; + + static defaultProps = { + siteName: 'Mattermost', + }; + + leftButton = { + id: 'reject-terms-of-service', + }; + + rightButton = { + id: 'accept-terms-of-service', + showAsAction: 'always', + }; + + constructor(props, context) { + super(props); + + this.state = { + getTermsError: false, + loading: true, + termsId: '', + termsText: '', + }; + + this.rightButton.title = context.intl.formatMessage({id: 'terms_of_service.agreeButton', defaultMessage: 'I Agree'}); + this.leftButton.icon = props.closeButton; + + props.navigator.setOnNavigatorEvent(this.onNavigatorEvent); + this.setNavigatorButtons(false); + } + + componentDidMount() { + this.getTerms(); + } + + componentDidUpdate(prevProps) { + if (this.props.theme !== prevProps.theme) { + setNavigatorStyles(this.props.navigator, this.props.theme); + } + } + + onNavigatorEvent = (event) => { + if (event.type === 'NavBarButtonPress') { + switch (event.id) { + case 'close-terms-of-service': + this.closeTermsAndLogout(); + break; + + case 'reject-terms-of-service': + this.handleRejectTerms(); + break; + + case 'accept-terms-of-service': + this.handleAcceptTerms(); + break; + } + } + }; + + setNavigatorButtons = (enabled = true) => { + const buttons = { + leftButtons: [{...this.leftButton, disabled: !enabled}], + rightButtons: [{...this.rightButton, disabled: !enabled}], + }; + + this.props.navigator.setButtons(buttons); + }; + + enableNavigatorLogout = () => { + const buttons = { + leftButtons: [{...this.leftButton, id: 'close-terms-of-service', disabled: false}], + rightButtons: [{...this.rightButton, disabled: true}], + }; + + this.props.navigator.setButtons(buttons); + }; + + closeTermsAndLogout = () => { + const {actions} = this.props; + + this.props.navigator.dismissAllModals(); + actions.logout(); + }; + + getTerms = async () => { + const {actions} = this.props; + + this.setState({ + termsId: '', + termsText: '', + loading: true, + getTermsError: false, + }); + this.setNavigatorButtons(false); + + const {data} = await actions.getTermsOfService(); + if (data) { + this.setState({ + termsId: data.id, + termsText: data.text, + loading: false, + }, () => { + this.setNavigatorButtons(true); + }); + } else { + this.setState({ + loading: false, + getTermsError: true, + }); + this.enableNavigatorLogout(); + } + }; + + handleAcceptTerms = () => { + this.registerUserAction( + true, + () => { + this.props.navigator.dismissModal({ + animationType: 'slide-down', + }); + }, + this.handleAcceptTerms + ); + }; + + handleRejectTerms = () => { + const {siteName} = this.props; + const {intl} = this.context; + + this.registerUserAction( + false, + () => { + Alert.alert( + this.props.siteName, + intl.formatMessage({ + id: 'mobile.terms_of_service.terms_rejected', + defaultMessage: 'You must agree to the terms of service before accessing {siteName}. Please contact your System Administrator for more details.', + }, { + siteName, + }), + [{ + text: intl.formatMessage({id: 'mobile.terms_of_service.alert_ok', defaultMessage: 'Ok'}), + onPress: this.closeTermsAndLogout, + }], + ); + }, + this.handleRejectTerms + ); + }; + + registerUserAction = async (accepted, success, retry) => { + const {actions} = this.props; + const {intl} = this.context; + + this.setNavigatorButtons(false); + this.setState({ + loading: true, + }); + + const {data} = await actions.updateMyTermsOfServiceStatus(this.state.termsId, accepted); + + this.setState({ + loading: false, + }); + + if (data) { + success(data); + this.setNavigatorButtons(true); + } else { + Alert.alert( + this.props.siteName, + intl.formatMessage({ + id: 'terms_of_service.api_error', + defaultMessage: 'Unable to complete the request. If this issue persists, contact your System Administrator.', + }), + [{ + text: intl.formatMessage({id: 'mobile.terms_of_service.alert_retry', defaultMessage: 'Try Again'}), + onPress: retry, + }, { + text: intl.formatMessage({id: 'mobile.terms_of_service.alert_cancel', defaultMessage: 'Cancel'}), + onPress: this.closeTermsAndLogout, + }], + ); + } + }; + + render() { + const {navigator, theme} = this.props; + const styles = getStyleSheet(theme); + + const blockStyles = getMarkdownBlockStyles(theme); + const textStyles = getMarkdownTextStyles(theme); + + if (this.state.loading) { + return ; + } + + if (this.state.getTermsError) { + return ( + + + + + ); + } + + return ( + + + + + + + ); + } +} + +const getStyleSheet = makeStyleSheetFromTheme((theme) => { + return { + baseText: { + color: theme.centerChannelColor, + fontSize: 15, + lineHeight: 20, + }, + container: { + backgroundColor: theme.centerChannelBg, + flex: 1, + }, + linkText: { + color: theme.linkColor, + }, + scrollView: { + flex: 1, + backgroundColor: theme.centerChannelBg, + padding: 30, + }, + scrollViewContent: { + paddingBottom: 50, + }, + }; +}); diff --git a/app/screens/terms_of_service/terms_of_service.test.js b/app/screens/terms_of_service/terms_of_service.test.js new file mode 100644 index 000000000..33a544f55 --- /dev/null +++ b/app/screens/terms_of_service/terms_of_service.test.js @@ -0,0 +1,134 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {shallow} from 'enzyme'; + +import Preferences from 'mattermost-redux/constants/preferences'; + +import TermsOfService from './terms_of_service.js'; + +jest.mock('react-intl'); + +jest.mock('app/utils/theme', () => { + const original = require.requireActual('app/utils/theme'); + return { + ...original, + changeOpacity: jest.fn(), + }; +}); + +describe('TermsOfService', () => { + const actions = { + getTermsOfService: jest.fn(), + updateMyTermsOfServiceStatus: jest.fn(), + logout: jest.fn(), + }; + + const baseProps = { + actions, + navigator: { + dismissAllModals: jest.fn(), + dismissModal: jest.fn(), + setButtons: jest.fn(), + setOnNavigatorEvent: jest.fn(), + }, + theme: Preferences.THEMES.default, + closeButton: {}, + siteName: 'Mattermost', + }; + + test('should match snapshot', () => { + const wrapper = shallow( + , + {context: {intl: {formatMessage: jest.fn()}}}, + ); + + expect(wrapper.getElement()).toMatchSnapshot(); + }); + + test('should match snapshot for get terms', async () => { + const wrapper = shallow( + , + {context: {intl: {formatMessage: jest.fn()}}}, + ); + await actions.getTermsOfService(); + wrapper.update(); + expect(wrapper.getElement()).toMatchSnapshot(); + }); + + test('should match snapshot for fail of get terms', async () => { + const getTermsOfService = async () => { + return { + error: {}, + }; + }; + + const props = { + ...baseProps, + actions: { + ...actions, + getTermsOfService, + }, + }; + + const wrapper = shallow( + , + {context: {intl: {formatMessage: jest.fn()}}}, + ); + expect(wrapper.state('loading')).toEqual(true); + await getTermsOfService(); + expect(wrapper.state('loading')).toEqual(false); + wrapper.update(); + expect(wrapper.getElement()).toMatchSnapshot(); + }); + + test('should call props.navigator.setButtons on setNavigatorButtons', async () => { + const wrapper = shallow( + , + {context: {intl: {formatMessage: jest.fn()}}}, + ); + wrapper.setState({loading: false, termsId: 1, termsText: 'Terms Text'}); + + expect(baseProps.navigator.setButtons).toHaveBeenCalledTimes(2); + wrapper.instance().setNavigatorButtons(true); + expect(baseProps.navigator.setButtons).toHaveBeenCalledTimes(3); + wrapper.instance().setNavigatorButtons(false); + expect(baseProps.navigator.setButtons).toHaveBeenCalledTimes(4); + }); + + test('should enable/disable navigator buttons on setNavigatorButtons true/false', () => { + const wrapper = shallow( + , + {context: {intl: {formatMessage: jest.fn()}}}, + ); + wrapper.setState({loading: false, termsId: 1, termsText: 'Terms Text'}); + + wrapper.instance().setNavigatorButtons(true); + expect(wrapper.getElement()).toMatchSnapshot(); + wrapper.instance().setNavigatorButtons(false); + expect(wrapper.getElement()).toMatchSnapshot(); + }); + + test('should match snapshot on enableNavigatorLogout', () => { + const wrapper = shallow( + , + {context: {intl: {formatMessage: jest.fn()}}}, + ); + + wrapper.setState({loading: false, termsId: 1, termsText: 'Terms Text'}); + wrapper.instance().enableNavigatorLogout(); + expect(wrapper.getElement()).toMatchSnapshot(); + }); + + test('should call dismissAllModals on closeTermsAndLogout', () => { + const wrapper = shallow( + , + {context: {intl: {formatMessage: jest.fn()}}}, + ); + + wrapper.setState({loading: false, termsId: 1, termsText: 'Terms Text'}); + wrapper.instance().closeTermsAndLogout(); + expect(baseProps.navigator.dismissAllModals).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/screens/thread/thread.js b/app/screens/thread/thread.js index 1f8dc0445..9c8b57022 100644 --- a/app/screens/thread/thread.js +++ b/app/screens/thread/thread.js @@ -120,6 +120,9 @@ export default class Thread extends PureComponent { statusBarHideWithNavBar: false, screenBackgroundColor: 'transparent', }, + passProps: { + disableTermsModal: true, + }, }); }; diff --git a/app/screens/thread/thread.test.js b/app/screens/thread/thread.test.js index d12e55f9e..bad3a0235 100644 --- a/app/screens/thread/thread.test.js +++ b/app/screens/thread/thread.test.js @@ -69,6 +69,9 @@ describe('thread', () => { statusBarHideWithNavBar: false, screenBackgroundColor: 'transparent', }, + passProps: { + disableTermsModal: true, + }, }; const newNavigator = {...navigator}; const wrapper = shallow( diff --git a/app/screens/user_profile/user_profile.js b/app/screens/user_profile/user_profile.js index 80e5bc683..c12bf5fd1 100644 --- a/app/screens/user_profile/user_profile.js +++ b/app/screens/user_profile/user_profile.js @@ -65,6 +65,9 @@ export default class UserProfile extends PureComponent { statusBarHideWithNavBar: false, screenBackgroundColor: theme.centerChannelBg, }, + passProps: { + disableTermsModal: true, + }, }); }; diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index a58f21225..4e97e6c76 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -415,6 +415,12 @@ "mobile.share_extension.send": "Send", "mobile.share_extension.team": "Team", "mobile.suggestion.members": "Members", + "mobile.terms_of_service.alert_cancel": "Cancel", + "mobile.terms_of_service.alert_ok": "OK", + "mobile.terms_of_service.alert_retry": "Try Again", + "mobile.terms_of_service.get_terms_error_description": "Make sure you have an active internet connection and try again. If this issue persists, contact your System Administrator.", + "mobile.terms_of_service.get_terms_error_title": "Unable to load terms of service.", + "mobile.terms_of_service.terms_rejected": "You must agree to the terms of service before accessing {siteName}. Please contact your System Administrator for more details.", "mobile.timezone_settings.automatically": "Set automatically", "mobile.timezone_settings.manual": "Change timezone", "mobile.timezone_settings.select": "Select Timezone", @@ -499,6 +505,8 @@ "suggestion.search.direct": "Direct Messages", "suggestion.search.private": "Private Channels", "suggestion.search.public": "Public Channels", + "terms_of_service.agreeButton": "I Agree", + "terms_of_service.api_error": "Unable to complete the request. If this issue persists, contact your System Administrator.", "user.settings.display.clockDisplay": "Clock Display", "user.settings.display.militaryClock": "24-hour clock (example: 16:00)", "user.settings.display.normalClock": "12-hour clock (example: 4:00 PM)",