mirror of
https://github.com/mattermost/mattermost-mobile.git
synced 2026-11-06 10:50:13 +00:00
* Expo-Router partial migration * Full migration to ExpoRouter * i18n * add tests * Merge branch 'main' into expo-router * align package-lock.json * i18n * feedback review * feedback review * android 16kb patch * Refactored emoji picker keyboard using a state machine * set android launch_screen in drawables instead of layout * no conditional hooks * remove duplicate call in test * address feedback * fix tests and address more feedback * small corrections * small corrections * small corrections * fix draft test * fix draft test 2 * remove navigateToScreen in draff test * fix nit in draft test * remove doc file * Feedback review * fix snapshots * improve test coverage * fix i18n * Upgrade t o RN 0.83, other dependencies and new architecture (#9737) * library hardware-keyboard android new arch support fix * library rnshare android new arch support fix * library rnutils android new arch support fix * library secure-pdf-viewer add new arch support * Enable new arch on iOS and Android * update emm and network client with new-arch * update intune to use new-arch * performance improvements * fix tests * fixes post input on new-arch * fix android emoji picker * remove doc * run tests in parallel * upgrade to React Native 0.83.9, Expo 55, React 19, and New Architecture Bumps core dependencies to their latest versions: - React Native 0.77.3 → 0.83.9 (New Architecture enabled) - React 18.3.1 → 19.2.5 - Expo SDK 52 → 55 - TypeScript 5.8.3 → 6.0.3 - Node.js 22.16.0 → 24.15.0 - react-native-reanimated 3.17.3 → 4.3.0 - Various other dependency upgrades Migrates iOS AppDelegate from Objective-C to Swift, updates Android build tooling and Gradle wrapper, refreshes all patches and font assets, and adapts app code, tests, and CI workflows throughout for API changes introduced by these upgrades. * remove unecessary dismissKeyboard in notifications * feedback review * update intune to use new-arch * update @mattermost deps * missing i18n string * additional fixes * update fastlane
397 lines
16 KiB
TypeScript
397 lines
16 KiB
TypeScript
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
import {useNavigation} from 'expo-router';
|
|
import moment from 'moment-timezone';
|
|
import React, {useCallback, useEffect, useMemo, useReducer, useState} from 'react';
|
|
import {useIntl} from 'react-intl';
|
|
import {DeviceEventEmitter, Keyboard, ScrollView, View} from 'react-native';
|
|
import {KeyboardAwareScrollView} from 'react-native-keyboard-controller';
|
|
import {type Edge, SafeAreaView} from 'react-native-safe-area-context';
|
|
|
|
import {updateLocalCustomStatus} from '@actions/local/user';
|
|
import {removeRecentCustomStatus, updateCustomStatus, unsetCustomStatus} from '@actions/remote/user';
|
|
import NavigationButton from '@components/navigation_button';
|
|
import TabletTitle from '@components/tablet_title';
|
|
import {Events, Screens} from '@constants';
|
|
import {CUSTOM_STATUS_TIME_PICKER_INTERVALS_IN_MINUTES, 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 {usePreventDoubleTap} from '@hooks/utils';
|
|
import {navigateBack, navigateToScreen, navigateToScreenWithBaseRoute} from '@screens/navigation';
|
|
import CallbackStore from '@store/callback_store';
|
|
import {getCurrentMomentForTimezone, getRoundedTime} from '@utils/helpers';
|
|
import {logDebug} from '@utils/log';
|
|
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[];
|
|
}
|
|
|
|
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,
|
|
},
|
|
flex: {flex: 1},
|
|
};
|
|
});
|
|
|
|
const DEFAULT_DURATION: CustomStatusDuration = 'today';
|
|
const edges: Edge[] = ['left', 'right'];
|
|
|
|
const calculateExpiryTime = (duration: CustomStatusDuration, currentUser: UserModel | undefined, 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?.replace('\n', ' ')};
|
|
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 dismissModalAndKeyboard = async (isTablet: boolean) => {
|
|
if (Keyboard.isVisible()) {
|
|
Keyboard.dismiss();
|
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
}
|
|
|
|
if (isTablet) {
|
|
DeviceEventEmitter.emit(Events.ACCOUNT_SELECT_TABLET_VIEW, '');
|
|
} else {
|
|
navigateBack();
|
|
}
|
|
};
|
|
|
|
const CustomStatus = ({
|
|
customStatusExpirySupported,
|
|
currentUser,
|
|
recentCustomStatuses,
|
|
}: Props) => {
|
|
const navigation = useNavigation();
|
|
const intl = useIntl();
|
|
const isTablet = useIsTablet();
|
|
const theme = useTheme();
|
|
const style = getStyleSheet(theme);
|
|
const serverUrl = useServerUrl();
|
|
const [isBtnEnabled, setIsBtnEnabled] = useState(true);
|
|
const storedStatus = useMemo(() => {
|
|
return getUserCustomStatus(currentUser);
|
|
}, [currentUser]);
|
|
|
|
const [initialStatus] = useState(() => {
|
|
const userTimezone = getTimezone(currentUser?.timezone);
|
|
|
|
// May be a ref
|
|
const isCustomStatusExpired = verifyExpiredStatus(currentUser);
|
|
|
|
const currentTime = getCurrentMomentForTimezone(userTimezone ?? '');
|
|
|
|
let initialCustomExpiryTime = getRoundedTime(currentTime, CUSTOM_STATUS_TIME_PICKER_INTERVALS_IN_MINUTES);
|
|
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) => {
|
|
CallbackStore.removeCallback();
|
|
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;
|
|
CallbackStore.setCallback(handleClearAfterClick);
|
|
const passProps = {
|
|
initialDuration: newStatus.duration,
|
|
isModal: isTablet,
|
|
};
|
|
|
|
if (isTablet) {
|
|
navigateToScreen(screen, passProps);
|
|
} else {
|
|
navigateToScreenWithBaseRoute(`/(modals)/${Screens.CUSTOM_STATUS}/`, screen, passProps);
|
|
}
|
|
}, [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 (!currentUser) {
|
|
return;
|
|
}
|
|
setIsBtnEnabled(false);
|
|
|
|
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) {
|
|
dismissModalAndKeyboard(isTablet);
|
|
DeviceEventEmitter.emit(SET_CUSTOM_STATUS_FAILURE);
|
|
setIsBtnEnabled(true);
|
|
return;
|
|
}
|
|
setIsBtnEnabled(true);
|
|
updateLocalCustomStatus(serverUrl, currentUser, status);
|
|
dispatchStatus({type: 'fromUserCustomStatus', status});
|
|
}
|
|
} else if (storedStatus?.emoji) {
|
|
const {error} = await unsetCustomStatus(serverUrl);
|
|
|
|
if (!error) {
|
|
updateLocalCustomStatus(serverUrl, currentUser, undefined);
|
|
}
|
|
}
|
|
dismissModalAndKeyboard(isTablet);
|
|
}, [currentUser, isStatusSet, storedStatus, isTablet, newStatus, customStatusExpirySupported, serverUrl]);
|
|
|
|
const openEmojiPicker = usePreventDoubleTap(useCallback(() => {
|
|
CallbackStore.setCallback(handleEmojiClick);
|
|
navigateToScreen(Screens.EMOJI_PICKER);
|
|
}, [handleEmojiClick]));
|
|
|
|
const handleBackButton = useCallback(() => {
|
|
dismissModalAndKeyboard(isTablet);
|
|
}, [isTablet]);
|
|
|
|
useAndroidHardwareBackHandler(Screens.CUSTOM_STATUS, handleBackButton);
|
|
|
|
useEffect(() => {
|
|
navigation.setOptions({
|
|
headerRight: () => (
|
|
<NavigationButton
|
|
onPress={handleSetStatus}
|
|
text={intl.formatMessage({id: 'mobile.custom_status.modal_confirm', defaultMessage: 'Done'})}
|
|
testID='custom_status.done.button'
|
|
disabled={!isBtnEnabled}
|
|
/>
|
|
),
|
|
});
|
|
}, [handleSetStatus, intl, isBtnEnabled, navigation, theme.sidebarHeaderTextColor]);
|
|
|
|
return (
|
|
<View style={style.flex}>
|
|
{isTablet &&
|
|
<TabletTitle
|
|
action={intl.formatMessage({id: 'mobile.custom_status.modal_confirm', defaultMessage: 'Done'})}
|
|
onPress={handleSetStatus}
|
|
testID='custom_status'
|
|
title={intl.formatMessage({id: 'mobile.routes.custom_status', defaultMessage: 'Set a custom status'})}
|
|
/>
|
|
}
|
|
<SafeAreaView
|
|
edges={edges}
|
|
style={style.container}
|
|
testID='custom_status.screen'
|
|
>
|
|
<KeyboardAwareScrollView
|
|
contentContainerStyle={style.contentContainerStyle}
|
|
keyboardShouldPersistTaps='handled'
|
|
mode='layout'
|
|
>
|
|
<ScrollView
|
|
bounces={false}
|
|
keyboardDismissMode='none'
|
|
keyboardShouldPersistTaps='always'
|
|
testID='custom_status.scroll_view'
|
|
>
|
|
<View style={style.scrollView}>
|
|
<View style={style.block}>
|
|
<CustomStatusInput
|
|
emoji={newStatus.emoji}
|
|
isStatusSet={isStatusSet}
|
|
onChangeText={handleTextChange}
|
|
onClearHandle={handleClear}
|
|
onOpenEmojiPicker={openEmojiPicker}
|
|
text={newStatus.text}
|
|
theme={theme}
|
|
/>
|
|
{isStatusSet && customStatusExpirySupported && (
|
|
<ClearAfter
|
|
duration={newStatus.duration}
|
|
expiresAt={newStatus.expiresAt}
|
|
onOpenClearAfterModal={openClearAfterModal}
|
|
theme={theme}
|
|
/>
|
|
)}
|
|
</View>
|
|
{recentCustomStatuses.length > 0 && (
|
|
<RecentCustomStatuses
|
|
onHandleClear={handleRecentCustomStatusClear}
|
|
onHandleSuggestionClick={handleRecentCustomStatusSuggestionClick}
|
|
recentCustomStatuses={recentCustomStatuses}
|
|
theme={theme}
|
|
/>
|
|
)
|
|
}
|
|
<CustomStatusSuggestions
|
|
intl={intl}
|
|
onHandleCustomStatusSuggestionClick={handleCustomStatusSuggestionClick}
|
|
recentCustomStatuses={recentCustomStatuses}
|
|
theme={theme}
|
|
/>
|
|
</View>
|
|
<View style={style.separator}/>
|
|
</ScrollView>
|
|
</KeyboardAwareScrollView>
|
|
</SafeAreaView>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
export default CustomStatus;
|