diff --git a/app/screens/custom_status/custom_status.tsx b/app/screens/custom_status/custom_status.tsx new file mode 100644 index 0000000000..d5f1c55b35 --- /dev/null +++ b/app/screens/custom_status/custom_status.tsx @@ -0,0 +1,396 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import moment from 'moment-timezone'; +import React, {useCallback, useEffect, useMemo, useReducer} from 'react'; +import {useIntl} from 'react-intl'; +import {DeviceEventEmitter, Keyboard, KeyboardAvoidingView, Platform, ScrollView, View} from 'react-native'; +import {Edge, SafeAreaView} from 'react-native-safe-area-context'; + +import {updateLocalCustomStatus} from '@actions/local/user'; +import {removeRecentCustomStatus, updateCustomStatus, unsetCustomStatus} from '@actions/remote/user'; +import CompassIcon from '@components/compass_icon'; +import TabletTitle from '@components/tablet_title'; +import {Events, Screens} from '@constants'; +import {CustomStatusDurationEnum, SET_CUSTOM_STATUS_FAILURE} from '@constants/custom_status'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; +import {useIsTablet} from '@hooks/device'; +import useNavButtonPressed from '@hooks/navigation_button_pressed'; +import {dismissModal, goToScreen, showModal} from '@screens/navigation'; +import {getCurrentMomentForTimezone, getRoundedTime} from '@utils/helpers'; +import {logDebug} from '@utils/log'; +import {mergeNavigationOptions} from '@utils/navigation'; +import {preventDoubleTap} from '@utils/tap'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import { + getTimezone, + getUserCustomStatus, + isCustomStatusExpired as verifyExpiredStatus, +} from '@utils/user'; + +import ClearAfter from './components/clear_after'; +import CustomStatusInput from './components/custom_status_input'; +import CustomStatusSuggestions from './components/custom_status_suggestions'; +import RecentCustomStatuses from './components/recent_custom_statuses'; + +import type UserModel from '@typings/database/models/servers/user'; + +type NewStatusType = { + emoji?: string; + text?: string; + duration: CustomStatusDuration; + expiresAt: moment.Moment; +} + +type Props = { + customStatusExpirySupported: boolean; + currentUser: UserModel; + recentCustomStatuses: UserCustomStatus[]; + componentId: string; +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + flex: 1, + backgroundColor: changeOpacity(theme.centerChannelColor, 0.03), + }, + contentContainerStyle: { + height: '99%', + }, + scrollView: { + flex: 1, + paddingTop: 32, + }, + separator: { + marginTop: 32, + }, + block: { + borderBottomColor: changeOpacity(theme.centerChannelColor, 0.1), + borderBottomWidth: 1, + borderTopColor: changeOpacity(theme.centerChannelColor, 0.1), + borderTopWidth: 1, + }, + }; +}); + +const DEFAULT_DURATION: CustomStatusDuration = 'today'; +const BTN_UPDATE_STATUS = 'update-custom-status'; +const edges: Edge[] = ['bottom', 'left', 'right']; + +const calculateExpiryTime = (duration: CustomStatusDuration, currentUser: UserModel, expiresAt: moment.Moment): string => { + const userTimezone = getTimezone(currentUser.timezone); + const currentTime = getCurrentMomentForTimezone(userTimezone); + + 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 expiresAt.toISOString(); + case CustomStatusDurationEnum.DONT_CLEAR: + default: + return ''; + } +}; + +function reducer(state: NewStatusType, action: { + type: 'clear' | 'fromUserCustomStatus' | 'fromUserCustomStatusIgnoringExpire' | 'text' | 'emoji' | 'duration'; + status?: UserCustomStatus; + value?: string; + duration?: CustomStatusDuration; + expiresAt?: string; +}): NewStatusType { + switch (action.type) { + case 'clear': + return {emoji: '', text: '', duration: DEFAULT_DURATION, expiresAt: state.expiresAt}; + case 'fromUserCustomStatus': { + const status = action.status; + if (status) { + return {emoji: status.emoji, text: status.text, duration: status.duration!, expiresAt: moment(status.expires_at)}; + } + return state; + } + case 'fromUserCustomStatusIgnoringExpire': { + const status = action.status; + if (status) { + return {emoji: status.emoji, text: status.text, duration: status.duration!, expiresAt: state.expiresAt}; + } + return state; + } + case 'text': + return {...state, text: action.value}; + case 'emoji': + return {...state, emoji: action.value}; + case 'duration': + if (action.duration != null) { + return { + ...state, + duration: action.duration, + expiresAt: action.duration === 'date_and_time' && action.expiresAt ? moment(action.expiresAt) : state.expiresAt, + }; + } + return state; + default: + return state; + } +} + +const CustomStatus = ({ + customStatusExpirySupported, + currentUser, + recentCustomStatuses, + componentId, +}: Props) => { + const intl = useIntl(); + const isTablet = useIsTablet(); + const theme = useTheme(); + const style = getStyleSheet(theme); + const serverUrl = useServerUrl(); + + const storedStatus = useMemo(() => { + return getUserCustomStatus(currentUser); + }, [currentUser]); + + const initialStatus = useMemo(() => { + const userTimezone = getTimezone(currentUser.timezone); + + // May be a ref + const isCustomStatusExpired = verifyExpiredStatus(currentUser); + + const currentTime = getCurrentMomentForTimezone(userTimezone ?? ''); + + let initialCustomExpiryTime = getRoundedTime(currentTime); + const isCurrentCustomStatusSet = !isCustomStatusExpired && (storedStatus?.text || storedStatus?.emoji); + if (isCurrentCustomStatusSet && storedStatus?.duration === 'date_and_time' && storedStatus?.expires_at) { + initialCustomExpiryTime = moment(storedStatus?.expires_at); + } + + return { + duration: isCurrentCustomStatusSet ? storedStatus?.duration ?? CustomStatusDurationEnum.DONT_CLEAR : DEFAULT_DURATION, + emoji: isCurrentCustomStatusSet ? storedStatus?.emoji : '', + expiresAt: initialCustomExpiryTime, + text: isCurrentCustomStatusSet ? storedStatus?.text : '', + }; + }, []); + + const [newStatus, dispatchStatus] = useReducer(reducer, initialStatus); + + const isStatusSet = Boolean(newStatus.emoji || newStatus.text); + + const handleClear = useCallback(() => { + dispatchStatus({type: 'clear'}); + }, []); + + const handleTextChange = useCallback((value: string) => { + dispatchStatus({type: 'text', value}); + }, []); + + const handleEmojiClick = useCallback((value: string) => { + dispatchStatus({type: 'emoji', value}); + }, []); + + const handleClearAfterClick = useCallback((duration: CustomStatusDuration, expiresAt: string) => { + dispatchStatus({type: 'duration', duration, expiresAt}); + }, []); + + const handleRecentCustomStatusClear = useCallback((status: UserCustomStatus) => removeRecentCustomStatus(serverUrl, status), [serverUrl]); + + const handleCustomStatusSuggestionClick = useCallback((status: UserCustomStatus) => { + if (!status.duration) { + // This should never happen, but we add a safeguard here + logDebug('clicked on a custom status with no duration'); + return; + } + dispatchStatus({type: 'fromUserCustomStatusIgnoringExpire', status}); + }, []); + + const openClearAfterModal = useCallback(() => { + const screen = Screens.CUSTOM_STATUS_CLEAR_AFTER; + const title = intl.formatMessage({id: 'mobile.custom_status.clear_after.title', defaultMessage: 'Clear Custom Status After'}); + const passProps = { + handleClearAfterClick, + initialDuration: newStatus.duration, + intl, + theme, + }; + + if (isTablet) { + showModal(screen, title, passProps); + } else { + goToScreen(screen, title, passProps); + } + }, [intl, theme, isTablet, newStatus.duration, handleClearAfterClick]); + + const handleRecentCustomStatusSuggestionClick = useCallback((status: UserCustomStatus) => { + dispatchStatus({type: 'fromUserCustomStatusIgnoringExpire', status: {...status, duration: status.duration || CustomStatusDurationEnum.DONT_CLEAR}}); + if (status.duration === 'date_and_time') { + openClearAfterModal(); + } + }, [openClearAfterModal]); + + const handleSetStatus = useCallback(async () => { + if (isStatusSet) { + let isStatusSame = + storedStatus?.emoji === newStatus.emoji && + storedStatus?.text === newStatus.text && + storedStatus?.duration === newStatus.duration; + const newExpiresAt = calculateExpiryTime(newStatus.duration!, currentUser, newStatus.expiresAt); + if (isStatusSame && newStatus.duration === 'date_and_time') { + isStatusSame = storedStatus?.expires_at === newExpiresAt; + } + + if (!isStatusSame) { + const status: UserCustomStatus = { + emoji: newStatus.emoji || 'speech_balloon', + text: newStatus.text?.trim(), + duration: CustomStatusDurationEnum.DONT_CLEAR, + }; + + if (customStatusExpirySupported) { + status.duration = newStatus.duration; + status.expires_at = newExpiresAt; + } + const {error} = await updateCustomStatus(serverUrl, status); + if (error) { + DeviceEventEmitter.emit(SET_CUSTOM_STATUS_FAILURE); + return; + } + + updateLocalCustomStatus(serverUrl, currentUser, status); + dispatchStatus({type: 'fromUserCustomStatus', status}); + } + } else if (storedStatus?.emoji) { + const unsetResponse = await unsetCustomStatus(serverUrl); + + if (unsetResponse?.data) { + updateLocalCustomStatus(serverUrl, currentUser, undefined); + } + } + Keyboard.dismiss(); + if (isTablet) { + DeviceEventEmitter.emit(Events.ACCOUNT_SELECT_TABLET_VIEW, ''); + } else { + dismissModal(); + } + }, [newStatus, isStatusSet, storedStatus, currentUser]); + + const openEmojiPicker = useCallback(preventDoubleTap(() => { + CompassIcon.getImageSource('close', 24, theme.sidebarHeaderTextColor).then((source) => { + const screen = Screens.EMOJI_PICKER; + const title = intl.formatMessage({id: 'mobile.custom_status.choose_emoji', defaultMessage: 'Choose an emoji'}); + const passProps = {closeButton: source, onEmojiPress: handleEmojiClick}; + + showModal(screen, title, passProps); + }); + }), [theme, intl, handleEmojiClick]); + + const handleBackButton = useCallback(() => { + if (isTablet) { + DeviceEventEmitter.emit(Events.ACCOUNT_SELECT_TABLET_VIEW, ''); + } else { + dismissModal({componentId}); + } + }, [isTablet]); + + useAndroidHardwareBackHandler(componentId, handleBackButton); + useNavButtonPressed(BTN_UPDATE_STATUS, componentId, handleSetStatus, [handleSetStatus]); + + useEffect(() => { + mergeNavigationOptions(componentId, { + topBar: { + rightButtons: [ + { + enabled: true, + id: BTN_UPDATE_STATUS, + showAsAction: 'always', + testID: 'custom_status.done.button', + text: intl.formatMessage({id: 'mobile.custom_status.modal_confirm', defaultMessage: 'Done'}), + color: theme.sidebarHeaderTextColor, + }, + ], + }, + }); + }, []); + + return ( + <> + {isTablet && + + } + + + + + + + {isStatusSet && customStatusExpirySupported && ( + + )} + + {recentCustomStatuses.length > 0 && ( + + ) + } + + + + + + + + ); +}; + +export default CustomStatus; diff --git a/app/screens/custom_status/index.tsx b/app/screens/custom_status/index.tsx index 54243ffa70..08fe9785e0 100644 --- a/app/screens/custom_status/index.tsx +++ b/app/screens/custom_status/index.tsx @@ -3,401 +3,13 @@ import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; import withObservables from '@nozbe/with-observables'; -import moment, {Moment} from 'moment-timezone'; -import React from 'react'; -import {injectIntl, IntlShape} from 'react-intl'; -import {BackHandler, DeviceEventEmitter, Keyboard, KeyboardAvoidingView, Platform, ScrollView, View} from 'react-native'; -import {EventSubscription, Navigation, NavigationButtonPressedEvent, NavigationComponent, NavigationComponentProps} from 'react-native-navigation'; -import {Edge, SafeAreaView} from 'react-native-safe-area-context'; -import {updateLocalCustomStatus} from '@actions/local/user'; -import {removeRecentCustomStatus, updateCustomStatus, unsetCustomStatus} from '@actions/remote/user'; -import CompassIcon from '@components/compass_icon'; -import TabletTitle from '@components/tablet_title'; -import {Events, Screens} from '@constants'; -import {CustomStatusDurationEnum, SET_CUSTOM_STATUS_FAILURE} from '@constants/custom_status'; -import {withServerUrl} from '@context/server'; -import {withTheme} from '@context/theme'; import {observeIsCustomStatusExpirySupported, observeRecentCustomStatus} from '@queries/servers/system'; import {observeCurrentUser} from '@queries/servers/user'; -import {dismissModal, goToScreen, showModal} from '@screens/navigation'; -import NavigationStore from '@store/navigation_store'; -import {getCurrentMomentForTimezone, getRoundedTime} from '@utils/helpers'; -import {logDebug} from '@utils/log'; -import {mergeNavigationOptions} from '@utils/navigation'; -import {preventDoubleTap} from '@utils/tap'; -import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; -import { - getTimezone, - getUserCustomStatus, - isCustomStatusExpired as verifyExpiredStatus, -} from '@utils/user'; -import ClearAfter from './components/clear_after'; -import CustomStatusInput from './components/custom_status_input'; -import CustomStatusSuggestions from './components/custom_status_suggestions'; -import RecentCustomStatuses from './components/recent_custom_statuses'; +import CustomStatus from './custom_status'; import type {WithDatabaseArgs} from '@typings/database/database'; -import type UserModel from '@typings/database/models/servers/user'; - -interface Props extends NavigationComponentProps { - customStatusExpirySupported: boolean; - currentUser: UserModel; - intl: IntlShape; - isModal?: boolean; - isTablet?: boolean; - recentCustomStatuses: UserCustomStatus[]; - serverUrl: string; - theme: Theme; -} - -type State = { - emoji?: string; - text?: string; - duration: CustomStatusDuration; - expires_at: Moment; -}; - -const DEFAULT_DURATION: CustomStatusDuration = 'today'; -const BTN_UPDATE_STATUS = 'update-custom-status'; -const edges: Edge[] = ['bottom', 'left', 'right']; - -const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { - return { - container: { - flex: 1, - backgroundColor: changeOpacity(theme.centerChannelColor, 0.03), - }, - contentContainerStyle: { - height: '99%', - }, - scrollView: { - flex: 1, - paddingTop: 32, - }, - separator: { - marginTop: 32, - }, - block: { - borderBottomColor: changeOpacity(theme.centerChannelColor, 0.1), - borderBottomWidth: 1, - borderTopColor: changeOpacity(theme.centerChannelColor, 0.1), - borderTopWidth: 1, - }, - }; -}); - -class CustomStatusModal extends NavigationComponent { - private navigationEventListener: EventSubscription | undefined; - private isCustomStatusExpired: boolean | undefined; - private backListener: EventSubscription | undefined; - - constructor(props: Props) { - super(props); - const {intl, theme, componentId} = props; - - mergeNavigationOptions(componentId, { - topBar: { - rightButtons: [ - { - enabled: true, - id: BTN_UPDATE_STATUS, - showAsAction: 'always', - testID: 'custom_status.done.button', - text: intl.formatMessage({id: 'mobile.custom_status.modal_confirm', defaultMessage: 'Done'}), - color: theme.sidebarHeaderTextColor, - }, - ], - }, - }); - - this.setUp(); - } - - setUp = () => { - const {currentUser} = this.props; - const userTimezone = getTimezone(currentUser.timezone); - - const customStatus = this.getCustomStatus(); - - this.isCustomStatusExpired = verifyExpiredStatus(currentUser); - - const currentTime = getCurrentMomentForTimezone(userTimezone ?? ''); - - let initialCustomExpiryTime: Moment = getRoundedTime(currentTime); - const isCurrentCustomStatusSet = !this.isCustomStatusExpired && (customStatus?.text || customStatus?.emoji); - if (isCurrentCustomStatusSet && customStatus?.duration === 'date_and_time' && customStatus?.expires_at) { - initialCustomExpiryTime = moment(customStatus?.expires_at); - } - - this.state = { - duration: isCurrentCustomStatusSet ? customStatus?.duration ?? CustomStatusDurationEnum.DONT_CLEAR : DEFAULT_DURATION, - emoji: isCurrentCustomStatusSet ? customStatus?.emoji : '', - expires_at: initialCustomExpiryTime, - text: isCurrentCustomStatusSet ? customStatus?.text : '', - }; - }; - - getCustomStatus = () => { - const {currentUser} = this.props; - return getUserCustomStatus(currentUser); - }; - - componentDidMount() { - this.navigationEventListener = Navigation.events().bindComponent(this); - this.backListener = BackHandler.addEventListener('hardwareBackPress', this.onBackPress); - } - - componentWillUnmount() { - this.navigationEventListener?.remove(); - this.backListener?.remove(); - } - - navigationButtonPressed({buttonId}: NavigationButtonPressedEvent) { - switch (buttonId) { - case BTN_UPDATE_STATUS: - this.handleSetStatus(); - break; - } - } - - onBackPress = () => { - const {componentId} = this.props; - if (NavigationStore.getVisibleScreen() === componentId) { - if (this.props.isTablet) { - DeviceEventEmitter.emit(Events.ACCOUNT_SELECT_TABLET_VIEW, ''); - } else { - dismissModal({componentId}); - } - return true; - } - return false; - }; - - handleSetStatus = async () => { - const {customStatusExpirySupported, currentUser, serverUrl} = this.props; - const {emoji, text, duration} = this.state; - const customStatus = this.getCustomStatus(); - - const isStatusSet = emoji || text; - if (isStatusSet) { - let isStatusSame = customStatus?.emoji === emoji && customStatus?.text === text && customStatus?.duration === duration; - const expiresAt = this.calculateExpiryTime(duration); - if (isStatusSame && duration === 'date_and_time') { - isStatusSame = customStatus?.expires_at === expiresAt; - } - - if (!isStatusSame) { - const status: UserCustomStatus = { - emoji: emoji || 'speech_balloon', - text: text?.trim(), - duration: CustomStatusDurationEnum.DONT_CLEAR, - }; - - if (customStatusExpirySupported) { - status.duration = duration; - status.expires_at = expiresAt; - } - const {error} = await updateCustomStatus(serverUrl, status); - if (error) { - DeviceEventEmitter.emit(SET_CUSTOM_STATUS_FAILURE); - return; - } - - updateLocalCustomStatus(serverUrl, currentUser, status); - - this.setState({ - duration: status.duration!, - emoji: status.emoji, - expires_at: moment(status.expires_at), - text: status.text, - }); - } - } else if (customStatus?.emoji) { - const unsetResponse = await unsetCustomStatus(serverUrl); - - if (unsetResponse?.data) { - updateLocalCustomStatus(serverUrl, currentUser, undefined); - } - } - Keyboard.dismiss(); - if (this.props.isTablet) { - DeviceEventEmitter.emit(Events.ACCOUNT_SELECT_TABLET_VIEW, ''); - } else { - dismissModal(); - } - }; - - calculateExpiryTime = (duration: CustomStatusDuration): string => { - const {currentUser} = this.props; - const userTimezone = getTimezone(currentUser.timezone); - 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 CustomStatusDurationEnum.DONT_CLEAR: - default: - return ''; - } - }; - - handleTextChange = (text: string) => { - this.setState({text}); - }; - - handleRecentCustomStatusClear = (status: UserCustomStatus) => removeRecentCustomStatus(this.props.serverUrl, status); - - clearHandle = () => this.setState({emoji: '', text: '', duration: DEFAULT_DURATION}); - - handleCustomStatusSuggestionClick = (status: UserCustomStatus) => { - const {emoji, text, duration} = status; - if (!duration) { - // This should never happen, but we add a safeguard here - logDebug('clicked on a custom status with no duration'); - return; - } - this.setState({emoji, text, duration}); - }; - - handleRecentCustomStatusSuggestionClick = (status: UserCustomStatus) => { - const {emoji, text, duration} = status; - this.setState({emoji, text, duration: duration || CustomStatusDurationEnum.DONT_CLEAR}); - if (duration === 'date_and_time') { - this.openClearAfterModal(); - } - }; - - openEmojiPicker = preventDoubleTap(() => { - const {theme, intl} = this.props; - CompassIcon.getImageSource('close', 24, theme.sidebarHeaderTextColor).then((source) => { - const screen = Screens.EMOJI_PICKER; - const title = intl.formatMessage({id: 'mobile.custom_status.choose_emoji', defaultMessage: 'Choose an emoji'}); - const passProps = {closeButton: source, onEmojiPress: this.handleEmojiClick}; - - showModal(screen, title, passProps); - }); - }); - - handleEmojiClick = (emoji: string) => { - this.setState({emoji}); - }; - - handleClearAfterClick = (duration: CustomStatusDuration, expires_at: string) => - this.setState({ - duration, - expires_at: duration === 'date_and_time' && expires_at ? moment(expires_at) : this.state.expires_at, - }); - - openClearAfterModal = async () => { - const {intl, theme} = this.props; - const screen = Screens.CUSTOM_STATUS_CLEAR_AFTER; - const title = intl.formatMessage({id: 'mobile.custom_status.clear_after.title', defaultMessage: 'Clear Custom Status After'}); - const passProps = { - handleClearAfterClick: this.handleClearAfterClick, - initialDuration: this.state.duration, - intl, - theme, - }; - - if (this.props.isTablet) { - showModal(screen, title, passProps); - } else { - goToScreen(screen, title, passProps); - } - }; - - render() { - const {duration, emoji, expires_at, text} = this.state; - const {customStatusExpirySupported, intl, recentCustomStatuses, theme} = this.props; - const isStatusSet = Boolean(emoji || text); - const style = getStyleSheet(theme); - - return ( - <> - {this.props.isTablet && - - } - - - - - - - {isStatusSet && customStatusExpirySupported && ( - - )} - - {recentCustomStatuses.length > 0 && ( - - ) - } - - - - - - - - ); - } -} - -const augmentCSM = injectIntl(withTheme(withServerUrl(CustomStatusModal))); const enhancedCSM = withObservables([], ({database}: WithDatabaseArgs) => { return { @@ -407,4 +19,4 @@ const enhancedCSM = withObservables([], ({database}: WithDatabaseArgs) => { }; }); -export default withDatabase(enhancedCSM(augmentCSM)); +export default withDatabase(enhancedCSM(CustomStatus));