diff --git a/native/chat/chat-input-bar.react.js b/native/chat/chat-input-bar.react.js index cf0cc0f3b..593b06916 100644 --- a/native/chat/chat-input-bar.react.js +++ b/native/chat/chat-input-bar.react.js @@ -1,861 +1,861 @@ // @flow import invariant from 'invariant'; import _throttle from 'lodash/throttle'; import * as React from 'react'; import { View, TextInput, TouchableOpacity, Platform, Text, ActivityIndicator, TouchableWithoutFeedback, NativeAppEventEmitter, } from 'react-native'; import { TextInputKeyboardMangerIOS } from 'react-native-keyboard-input'; import Animated, { Easing } from 'react-native-reanimated'; import FAIcon from 'react-native-vector-icons/FontAwesome'; import Icon from 'react-native-vector-icons/Ionicons'; import { useDispatch } from 'react-redux'; -import { saveDraftActionType } from 'lib/actions/miscellaneous-action-types'; import { joinThreadActionTypes, joinThread } from 'lib/actions/thread-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { trimMessage } from 'lib/shared/message-utils'; import { threadHasPermission, viewerIsMember, threadFrozenDueToViewerBlock, threadActualMembers, } from 'lib/shared/thread-utils'; import type { CalendarQuery } from 'lib/types/entry-types'; import type { LoadingStatus } from 'lib/types/loading-types'; import type { PhotoPaste } from 'lib/types/media-types'; import { messageTypes } from 'lib/types/message-types'; import type { Dispatch } from 'lib/types/redux-types'; import { type ThreadInfo, threadPermissions, type ClientThreadJoinRequest, type ThreadJoinPayload, } from 'lib/types/thread-types'; import { type UserInfos } from 'lib/types/user-types'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; import Button from '../components/button.react'; import ClearableTextInput from '../components/clearable-text-input.react'; import { type InputState, InputStateContext } from '../input/input-state'; import { getKeyboardHeight } from '../keyboard/keyboard'; import KeyboardInputHost from '../keyboard/keyboard-input-host.react'; import { type KeyboardState, KeyboardContext, } from '../keyboard/keyboard-state'; import { nonThreadCalendarQuery, activeThreadSelector, } from '../navigation/nav-selectors'; import { NavContext } from '../navigation/navigation-context'; import { type NavigationRoute, CameraModalRouteName, ImagePasteModalRouteName, } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils'; import { type Colors, useStyles, useColors } from '../themes/colors'; import type { ViewStyle } from '../types/styles'; import { runTiming } from '../utils/animation-utils'; import type { ChatNavigationProp } from './chat.react'; /* eslint-disable import/no-named-as-default-member */ const { Value, Clock, block, set, cond, neq, sub, interpolate, stopClock, } = Animated; /* eslint-enable import/no-named-as-default-member */ const expandoButtonsAnimationConfig = { duration: 500, easing: Easing.inOut(Easing.ease), }; const sendButtonAnimationConfig = { duration: 150, easing: Easing.inOut(Easing.ease), }; const draftKeyFromThreadID = (threadID: string) => `${threadID}/message_composer`; type BaseProps = {| +threadInfo: ThreadInfo, +navigation: ChatNavigationProp<'MessageList'>, +route: NavigationRoute<'MessageList'>, |}; type Props = {| ...BaseProps, // Redux state +viewerID: ?string, +draft: string, +joinThreadLoadingStatus: LoadingStatus, +calendarQuery: () => CalendarQuery, +nextLocalID: number, +userInfos: UserInfos, +colors: Colors, +styles: typeof unboundStyles, // connectNav +isActive: boolean, // withKeyboardState +keyboardState: ?KeyboardState, // Redux dispatch functions +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +joinThread: (request: ClientThreadJoinRequest) => Promise, // withInputState +inputState: ?InputState, |}; type State = {| +text: string, +buttonsExpanded: boolean, |}; class ChatInputBar extends React.PureComponent { textInput: ?React.ElementRef; clearableTextInput: ?ClearableTextInput; expandoButtonsOpen: Value; targetExpandoButtonsOpen: Value; expandoButtonsStyle: ViewStyle; cameraRollIconStyle: ViewStyle; cameraIconStyle: ViewStyle; expandIconStyle: ViewStyle; sendButtonContainerOpen: Value; targetSendButtonContainerOpen: Value; sendButtonContainerStyle: ViewStyle; constructor(props: Props) { super(props); this.state = { text: props.draft, buttonsExpanded: true, }; this.expandoButtonsOpen = new Value(1); this.targetExpandoButtonsOpen = new Value(1); const prevTargetExpandoButtonsOpen = new Value(1); const expandoButtonClock = new Clock(); const expandoButtonsOpen = block([ cond(neq(this.targetExpandoButtonsOpen, prevTargetExpandoButtonsOpen), [ stopClock(expandoButtonClock), set(prevTargetExpandoButtonsOpen, this.targetExpandoButtonsOpen), ]), cond( neq(this.expandoButtonsOpen, this.targetExpandoButtonsOpen), set( this.expandoButtonsOpen, runTiming( expandoButtonClock, this.expandoButtonsOpen, this.targetExpandoButtonsOpen, true, expandoButtonsAnimationConfig, ), ), ), this.expandoButtonsOpen, ]); this.cameraRollIconStyle = { ...unboundStyles.cameraRollIcon, opacity: expandoButtonsOpen, }; this.cameraIconStyle = { ...unboundStyles.cameraIcon, opacity: expandoButtonsOpen, }; const expandoButtonsWidth = interpolate(expandoButtonsOpen, { inputRange: [0, 1], outputRange: [22, 60], }); this.expandoButtonsStyle = { ...unboundStyles.expandoButtons, width: expandoButtonsWidth, }; const expandOpacity = sub(1, expandoButtonsOpen); this.expandIconStyle = { ...unboundStyles.expandIcon, opacity: expandOpacity, }; const initialSendButtonContainerOpen = trimMessage(props.draft) ? 1 : 0; this.sendButtonContainerOpen = new Value(initialSendButtonContainerOpen); this.targetSendButtonContainerOpen = new Value( initialSendButtonContainerOpen, ); const prevTargetSendButtonContainerOpen = new Value( initialSendButtonContainerOpen, ); const sendButtonClock = new Clock(); const sendButtonContainerOpen = block([ cond( neq( this.targetSendButtonContainerOpen, prevTargetSendButtonContainerOpen, ), [ stopClock(sendButtonClock), set( prevTargetSendButtonContainerOpen, this.targetSendButtonContainerOpen, ), ], ), cond( neq(this.sendButtonContainerOpen, this.targetSendButtonContainerOpen), set( this.sendButtonContainerOpen, runTiming( sendButtonClock, this.sendButtonContainerOpen, this.targetSendButtonContainerOpen, true, sendButtonAnimationConfig, ), ), ), this.sendButtonContainerOpen, ]); const sendButtonContainerWidth = interpolate(sendButtonContainerOpen, { inputRange: [0, 1], outputRange: [4, 38], }); this.sendButtonContainerStyle = { width: sendButtonContainerWidth }; } static mediaGalleryOpen(props: Props) { const { keyboardState } = props; return !!(keyboardState && keyboardState.mediaGalleryOpen); } static systemKeyboardShowing(props: Props) { const { keyboardState } = props; return !!(keyboardState && keyboardState.systemKeyboardShowing); } get systemKeyboardShowing() { return ChatInputBar.systemKeyboardShowing(this.props); } immediatelyShowSendButton() { this.sendButtonContainerOpen.setValue(1); this.targetSendButtonContainerOpen.setValue(1); } updateSendButton(currentText: string) { this.targetSendButtonContainerOpen.setValue(currentText === '' ? 0 : 1); } componentDidMount() { if (this.props.isActive) { this.addReplyListener(); } } componentWillUnmount() { if (this.props.isActive) { this.removeReplyListener(); } } componentDidUpdate(prevProps: Props, prevState: State) { if (this.props.isActive && !prevProps.isActive) { this.addReplyListener(); } else if (!this.props.isActive && prevProps.isActive) { this.removeReplyListener(); } const currentText = trimMessage(this.state.text); const prevText = trimMessage(prevState.text); if ( (currentText === '' && prevText !== '') || (currentText !== '' && prevText === '') ) { this.updateSendButton(currentText); } const systemKeyboardIsShowing = ChatInputBar.systemKeyboardShowing( this.props, ); const systemKeyboardWasShowing = ChatInputBar.systemKeyboardShowing( prevProps, ); if (systemKeyboardIsShowing && !systemKeyboardWasShowing) { this.hideButtons(); } else if (!systemKeyboardIsShowing && systemKeyboardWasShowing) { this.expandButtons(); } const imageGalleryIsOpen = ChatInputBar.mediaGalleryOpen(this.props); const imageGalleryWasOpen = ChatInputBar.mediaGalleryOpen(prevProps); if (!imageGalleryIsOpen && imageGalleryWasOpen) { this.hideButtons(); } else if (imageGalleryIsOpen && !imageGalleryWasOpen) { this.expandButtons(); this.setIOSKeyboardHeight(); } } addReplyListener() { invariant( this.props.inputState, 'inputState should be set in addReplyListener', ); this.props.inputState.addReplyListener(this.focusAndUpdateText); } removeReplyListener() { invariant( this.props.inputState, 'inputState should be set in removeReplyListener', ); this.props.inputState.removeReplyListener(this.focusAndUpdateText); } setIOSKeyboardHeight() { if (Platform.OS !== 'ios') { return; } const { textInput } = this; if (!textInput) { return; } const keyboardHeight = getKeyboardHeight(); if (keyboardHeight === null || keyboardHeight === undefined) { return; } TextInputKeyboardMangerIOS.setKeyboardHeight(textInput, keyboardHeight); } render() { const isMember = viewerIsMember(this.props.threadInfo); const canJoin = threadHasPermission( this.props.threadInfo, threadPermissions.JOIN_THREAD, ); let joinButton = null; if (!isMember && canJoin) { let buttonContent; if (this.props.joinThreadLoadingStatus === 'loading') { buttonContent = ( ); } else { buttonContent = ( Join Thread ); } joinButton = ( ); } let content; if (threadHasPermission(this.props.threadInfo, threadPermissions.VOICED)) { content = this.renderInput(); } else if ( threadFrozenDueToViewerBlock( this.props.threadInfo, this.props.viewerID, this.props.userInfos, ) && threadActualMembers(this.props.threadInfo.members).length === 2 ) { content = ( You can't send messages to a user that you've blocked. ); } else if (isMember) { content = ( You don't have permission to send messages. ); } else { const defaultRoleID = Object.keys(this.props.threadInfo.roles).find( (roleID) => this.props.threadInfo.roles[roleID].isDefault, ); invariant( defaultRoleID !== undefined, 'all threads should have a default role', ); const defaultRole = this.props.threadInfo.roles[defaultRoleID]; const membersAreVoiced = !!defaultRole.permissions[ threadPermissions.VOICED ]; if (membersAreVoiced && canJoin) { content = ( Join this thread to send messages. ); } else { content = ( You don't have permission to send messages. ); } } const keyboardInputHost = Platform.OS === 'android' ? null : ( ); return ( {joinButton} {content} {keyboardInputHost} ); } renderInput() { const expandoButton = ( ); const threadColor = `#${this.props.threadInfo.color}`; return ( {this.state.buttonsExpanded ? expandoButton : null} {this.state.buttonsExpanded ? null : expandoButton} ); } textInputRef = (textInput: ?React.ElementRef) => { this.textInput = textInput; }; clearableTextInputRef = (clearableTextInput: ?ClearableTextInput) => { this.clearableTextInput = clearableTextInput; }; updateText = (text: string) => { this.setState({ text }); this.saveDraft(text); }; - saveDraft = _throttle((text: string) => { - this.props.dispatch({ - type: saveDraftActionType, - payload: { - key: draftKeyFromThreadID(this.props.threadInfo.id), - draft: text, - }, + saveDraft = _throttle((text) => { + global.CommCoreModule.updateDraft({ + threadID: draftKeyFromThreadID(this.props.threadInfo.id), + text, }); }, 400); focusAndUpdateText = (text: string) => { const currentText = this.state.text; if (!currentText.startsWith(text)) { const prependedText = text.concat(currentText); this.updateText(prependedText); this.immediatelyShowSendButton(); this.immediatelyHideButtons(); } invariant(this.textInput, 'textInput should be set in focusAndUpdateText'); this.textInput.focus(); }; onSend = async () => { if (!trimMessage(this.state.text)) { return; } this.updateSendButton(''); const { clearableTextInput } = this; invariant( clearableTextInput, 'clearableTextInput should be sent in onSend', ); let text = await clearableTextInput.getValueAndReset(); text = trimMessage(text); if (!text) { return; } const localID = `local${this.props.nextLocalID}`; const creatorID = this.props.viewerID; invariant(creatorID, 'should have viewer ID in order to send a message'); invariant( this.props.inputState, 'inputState should be set in ChatInputBar.onSend', ); this.props.inputState.sendTextMessage( { type: messageTypes.TEXT, localID, threadID: this.props.threadInfo.id, text, creatorID, time: Date.now(), }, this.props.threadInfo, ); }; onPressJoin = () => { this.props.dispatchActionPromise(joinThreadActionTypes, this.joinAction()); }; async joinAction() { const query = this.props.calendarQuery(); return await this.props.joinThread({ threadID: this.props.threadInfo.id, calendarQuery: { startDate: query.startDate, endDate: query.endDate, filters: [ ...query.filters, { type: 'threads', threadIDs: [this.props.threadInfo.id] }, ], }, }); } expandButtons = () => { if (this.state.buttonsExpanded) { return; } this.targetExpandoButtonsOpen.setValue(1); this.setState({ buttonsExpanded: true }); }; hideButtons() { if ( ChatInputBar.mediaGalleryOpen(this.props) || !this.systemKeyboardShowing || !this.state.buttonsExpanded ) { return; } this.targetExpandoButtonsOpen.setValue(0); this.setState({ buttonsExpanded: false }); } immediatelyHideButtons() { this.expandoButtonsOpen.setValue(0); this.targetExpandoButtonsOpen.setValue(0); this.setState({ buttonsExpanded: false }); } openCamera = async () => { this.dismissKeyboard(); this.props.navigation.navigate({ name: CameraModalRouteName, params: { presentedFrom: this.props.route.key, thread: { threadInfo: this.props.threadInfo, sourceMessageID: this.props.route.params.sourceMessageID, }, }, }); }; showMediaGallery = () => { const { keyboardState } = this.props; invariant(keyboardState, 'keyboardState should be initialized'); keyboardState.showMediaGallery({ threadInfo: this.props.threadInfo, sourceMessageID: this.props.route.params.sourceMessageID, }); }; dismissKeyboard = () => { const { keyboardState } = this.props; keyboardState && keyboardState.dismissKeyboard(); }; } const unboundStyles = { cameraIcon: { paddingBottom: Platform.OS === 'android' ? 11 : 10, paddingRight: 3, }, cameraRollIcon: { paddingBottom: Platform.OS === 'android' ? 8 : 7, paddingRight: 8, }, container: { backgroundColor: 'listBackground', }, expandButton: { bottom: 0, position: 'absolute', right: 0, }, expandIcon: { paddingBottom: Platform.OS === 'android' ? 12 : 10, }, expandoButtons: { alignSelf: 'flex-end', }, explanation: { color: 'listBackgroundSecondaryLabel', paddingBottom: 4, paddingTop: 1, textAlign: 'center', }, innerExpandoButtons: { alignItems: 'flex-end', alignSelf: 'flex-end', flexDirection: 'row', }, inputContainer: { flexDirection: 'row', }, joinButton: { backgroundColor: 'mintButton', borderRadius: 5, flex: 1, justifyContent: 'center', marginHorizontal: 12, marginVertical: 3, paddingBottom: 5, paddingTop: 3, }, joinButtonContainer: { flexDirection: 'row', height: 36, }, joinButtonText: { color: 'listBackground', fontSize: 20, textAlign: 'center', }, joinThreadLoadingIndicator: { paddingVertical: 2, }, sendButton: { position: 'absolute', bottom: Platform.OS === 'android' ? 4 : 3, left: 0, }, sendIcon: { paddingLeft: 9, paddingRight: 8, paddingVertical: 5, }, textInput: { backgroundColor: 'listInputBackground', borderRadius: 10, color: 'listForegroundLabel', fontSize: 16, marginLeft: 4, marginVertical: 5, maxHeight: 250, paddingHorizontal: 10, paddingVertical: 5, }, }; const joinThreadLoadingStatusSelector = createLoadingStatusSelector( joinThreadActionTypes, ); export default React.memo(function ConnectedChatInputBar( props: BaseProps, ) { const inputState = React.useContext(InputStateContext); const keyboardState = React.useContext(KeyboardContext); const navContext = React.useContext(NavContext); const styles = useStyles(unboundStyles); const colors = useColors(); const isActive = React.useMemo( () => props.threadInfo.id === activeThreadSelector(navContext), [props.threadInfo.id, navContext], ); const draftKey = draftKeyFromThreadID(props.threadInfo.id); - const draft = useSelector((state) => state.drafts[draftKey] || ''); + const draft = React.useMemo( + () => global.CommCoreModule.getDraft(draftKey), + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); const viewerID = useSelector( (state) => state.currentUserInfo && state.currentUserInfo.id, ); const joinThreadLoadingStatus = useSelector(joinThreadLoadingStatusSelector); const calendarQuery = useSelector((state) => nonThreadCalendarQuery({ redux: state, navContext, }), ); const nextLocalID = useSelector((state) => state.nextLocalID); const userInfos = useSelector((state) => state.userStore.userInfos); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const callJoinThread = useServerCall(joinThread); const imagePastedCallback = React.useCallback( (imagePastedEvent) => { if (props.threadInfo.id !== imagePastedEvent['threadID']) { return; } const pastedImage: PhotoPaste = { step: 'photo_paste', dimensions: { height: imagePastedEvent.height, width: imagePastedEvent.width, }, filename: imagePastedEvent.fileName, uri: 'file://' + imagePastedEvent.filePath, selectTime: 0, sendTime: 0, retries: 0, }; props.navigation.navigate({ name: ImagePasteModalRouteName, params: { imagePasteStagingInfo: pastedImage, thread: { threadInfo: props.threadInfo, sourceMessageID: props.route.params.sourceMessageID, }, }, }); }, [props.navigation, props.route.params.sourceMessageID, props.threadInfo], ); React.useEffect(() => { const imagePasteListener = NativeAppEventEmitter.addListener( 'imagePasted', imagePastedCallback, ); return () => imagePasteListener.remove(); }, [imagePastedCallback]); return ( ); }); diff --git a/native/reducers/draft-reducer.js b/native/reducers/draft-reducer.js deleted file mode 100644 index c0d368483..000000000 --- a/native/reducers/draft-reducer.js +++ /dev/null @@ -1,31 +0,0 @@ -// @flow - -import { saveDraftActionType } from 'lib/actions/miscellaneous-action-types'; -import { - logOutActionTypes, - deleteAccountActionTypes, -} from 'lib/actions/user-actions'; -import { setNewSessionActionType } from 'lib/utils/action-utils'; - -export default function reduceDrafts( - state: ?{ [key: string]: string }, - action: *, -): { [key: string]: string } { - if (!state) { - state = {}; - } - if ( - action.type === logOutActionTypes.success || - action.type === deleteAccountActionTypes.success || - (action.type === setNewSessionActionType && - action.payload.sessionChange.cookieInvalidated) - ) { - return {}; - } else if (action.type === saveDraftActionType) { - return { - ...state, - [action.payload.key]: action.payload.draft, - }; - } - return state; -} diff --git a/native/redux/persist.js b/native/redux/persist.js index 584ff06e7..d3d4f19c3 100644 --- a/native/redux/persist.js +++ b/native/redux/persist.js @@ -1,233 +1,232 @@ // @flow import AsyncStorage from '@react-native-community/async-storage'; import invariant from 'invariant'; import { Platform } from 'react-native'; import Orientation from 'react-native-orientation-locker'; import { createMigrate } from 'redux-persist'; import { highestLocalIDSelector } from 'lib/selectors/local-id-selectors'; import { inconsistencyResponsesToReports } from 'lib/shared/report-utils'; import { unshimMessageStore } from 'lib/shared/unshim-utils'; import { defaultCalendarFilters } from 'lib/types/filter-types'; import { messageTypes } from 'lib/types/message-types'; import { defaultConnectionInfo } from 'lib/types/socket-types'; import { defaultNotifPermissionAlertInfo } from '../push/alerts'; import { defaultDeviceCameraInfo } from '../types/camera'; import { defaultGlobalThemeInfo } from '../types/themes'; import type { AppState } from './redux-setup'; const migrations = { [1]: (state: AppState) => ({ ...state, notifPermissionAlertInfo: defaultNotifPermissionAlertInfo, }), [2]: (state: AppState) => ({ ...state, messageSentFromRoute: [], }), [3]: (state) => ({ currentUserInfo: state.currentUserInfo, entryStore: state.entryStore, threadInfos: state.threadInfos, userInfos: state.userInfos, messageStore: { ...state.messageStore, currentAsOf: state.currentAsOf, }, - drafts: state.drafts, updatesCurrentAsOf: state.currentAsOf, cookie: state.cookie, deviceToken: state.deviceToken, urlPrefix: state.urlPrefix, customServer: state.customServer, threadIDsToNotifIDs: state.threadIDsToNotifIDs, notifPermissionAlertInfo: state.notifPermissionAlertInfo, messageSentFromRoute: state.messageSentFromRoute, _persist: state._persist, }), [4]: (state: AppState) => ({ ...state, pingTimestamps: undefined, activeServerRequests: undefined, }), [5]: (state: AppState) => ({ ...state, calendarFilters: defaultCalendarFilters, }), [6]: (state) => ({ ...state, threadInfos: undefined, threadStore: { threadInfos: state.threadInfos, inconsistencyResponses: [], }, }), [7]: (state) => ({ ...state, lastUserInteraction: undefined, sessionID: undefined, entryStore: { ...state.entryStore, inconsistencyResponses: [], }, }), [8]: (state: AppState) => ({ ...state, pingTimestamps: undefined, activeServerRequests: undefined, connection: defaultConnectionInfo(Platform.OS), watchedThreadIDs: [], entryStore: { ...state.entryStore, actualizedCalendarQuery: undefined, }, }), [9]: (state: AppState) => ({ ...state, connection: { ...state.connection, lateResponses: [], }, }), [10]: (state: AppState) => ({ ...state, nextLocalID: highestLocalIDSelector(state) + 1, connection: { ...state.connection, showDisconnectedBar: false, }, messageStore: { ...state.messageStore, local: {}, }, }), [11]: (state: AppState) => ({ ...state, messageStore: unshimMessageStore(state.messageStore, [messageTypes.IMAGES]), }), [12]: (state: AppState) => ({ ...state, globalThemeInfo: defaultGlobalThemeInfo, }), [13]: (state: AppState) => ({ ...state, deviceCameraInfo: defaultDeviceCameraInfo, deviceOrientation: Orientation.getInitialOrientation(), }), [14]: (state: AppState) => ({ ...state, messageStore: unshimMessageStore(state.messageStore, [ messageTypes.MULTIMEDIA, ]), }), [15]: (state) => ({ ...state, threadStore: { ...state.threadStore, inconsistencyReports: inconsistencyResponsesToReports( state.threadStore.inconsistencyResponses, ), inconsistencyResponses: undefined, }, entryStore: { ...state.entryStore, inconsistencyReports: inconsistencyResponsesToReports( state.entryStore.inconsistencyResponses, ), inconsistencyResponses: undefined, }, queuedReports: [], }), [16]: (state) => { const result = { ...state, messageSentFromRoute: undefined, dataLoaded: !!state.currentUserInfo && !state.currentUserInfo.anonymous, }; if (state.navInfo) { result.navInfo = { ...state.navInfo, navigationState: undefined, }; } return result; }, [17]: (state) => ({ ...state, userInfos: undefined, userStore: { userInfos: state.userInfos, inconsistencyResponses: [], }, }), [18]: (state) => ({ ...state, userStore: { userInfos: state.userStore.userInfos, inconsistencyReports: [], }, }), [19]: (state) => { const threadInfos = {}; for (const threadID in state.threadStore.threadInfos) { const threadInfo = state.threadStore.threadInfos[threadID]; const { visibilityRules, ...rest } = threadInfo; threadInfos[threadID] = rest; } return { ...state, threadStore: { ...state.threadStore, threadInfos, }, }; }, [20]: (state: AppState) => ({ ...state, messageStore: unshimMessageStore(state.messageStore, [ messageTypes.UPDATE_RELATIONSHIP, ]), }), [21]: (state: AppState) => ({ ...state, messageStore: unshimMessageStore(state.messageStore, [ messageTypes.CREATE_SIDEBAR, messageTypes.SIDEBAR_SOURCE, ]), }), }; const persistConfig = { key: 'root', storage: AsyncStorage, blacklist: [ 'loadingStatuses', 'lifecycleState', 'dimensions', 'connectivity', 'deviceOrientation', 'frozen', ], debug: __DEV__, version: 21, migrate: createMigrate(migrations, { debug: __DEV__ }), timeout: __DEV__ ? 0 : undefined, }; const codeVersion = 81; // This local exists to avoid a circular dependency where redux-setup needs to // import all the navigation and screen stuff, but some of those screens want to // access the persistor to purge its state. let storedPersistor = null; function setPersistor(persistor: *) { storedPersistor = persistor; } function getPersistor() { invariant(storedPersistor, 'should be set'); return storedPersistor; } export { persistConfig, codeVersion, setPersistor, getPersistor }; diff --git a/native/redux/redux-setup.js b/native/redux/redux-setup.js index 74e2aca85..abb88b75d 100644 --- a/native/redux/redux-setup.js +++ b/native/redux/redux-setup.js @@ -1,461 +1,455 @@ // @flow import { AppState as NativeAppState, Platform, Alert } from 'react-native'; import type { Orientations } from 'react-native-orientation-locker'; import Orientation from 'react-native-orientation-locker'; import { createStore, applyMiddleware, type Store, compose } from 'redux'; import { persistStore, persistReducer } from 'redux-persist'; import type { PersistState } from 'redux-persist/src/types'; import thunk from 'redux-thunk'; import { setDeviceTokenActionTypes } from 'lib/actions/device-actions'; import { logOutActionTypes, deleteAccountActionTypes, logInActionTypes, } from 'lib/actions/user-actions'; import baseReducer from 'lib/reducers/master-reducer'; import { invalidSessionDowngrade, invalidSessionRecovery, } from 'lib/shared/account-utils'; import { type EntryStore } from 'lib/types/entry-types'; import { type CalendarFilter, defaultCalendarFilters, } from 'lib/types/filter-types'; import type { LifecycleState } from 'lib/types/lifecycle-state-types'; import type { LoadingStatus } from 'lib/types/loading-types'; import type { MessageStore } from 'lib/types/message-types'; import type { Dispatch } from 'lib/types/redux-types'; import type { ClientReportCreationRequest } from 'lib/types/report-types'; import type { SetSessionPayload } from 'lib/types/session-types'; import { type ConnectionInfo, defaultConnectionInfo, incrementalStateSyncActionType, } from 'lib/types/socket-types'; import type { ThreadStore } from 'lib/types/thread-types'; import { updateTypes } from 'lib/types/update-types'; import type { CurrentUserInfo, UserStore } from 'lib/types/user-types'; import { reduxLoggerMiddleware } from 'lib/utils/action-logger'; import { setNewSessionActionType } from 'lib/utils/action-utils'; import { type NavInfo, defaultNavInfo } from '../navigation/default-state'; import { getGlobalNavContext } from '../navigation/icky-global'; import { activeMessageListSelector } from '../navigation/nav-selectors'; import { type NotifPermissionAlertInfo, defaultNotifPermissionAlertInfo, } from '../push/alerts'; import { reduceThreadIDsToNotifIDs } from '../push/reducer'; import reactotron from '../reactotron'; -import reduceDrafts from '../reducers/draft-reducer'; import { type DeviceCameraInfo, defaultDeviceCameraInfo, } from '../types/camera'; import { type ConnectivityInfo, defaultConnectivityInfo, } from '../types/connectivity'; import { type GlobalThemeInfo, defaultGlobalThemeInfo } from '../types/themes'; import { defaultURLPrefix, natNodeServer, setCustomServer, fetchDevServerHostname, updateURLPrefixAfterCheckingIfEmulator, } from '../utils/url-utils'; import { resetUserStateActionType, recordNotifPermissionAlertActionType, recordAndroidNotificationActionType, clearAndroidNotificationsActionType, rescindAndroidNotificationActionType, updateDimensionsActiveType, updateConnectivityActiveType, updateThemeInfoActionType, updateDeviceCameraInfoActionType, updateDeviceOrientationActionType, updateThreadLastNavigatedActionType, backgroundActionTypes, setReduxStateActionType, } from './action-types'; import { remoteReduxDevServerConfig } from './dev-tools'; import { defaultDimensionsInfo, type DimensionsInfo, } from './dimensions-updater.react'; import { persistConfig, setPersistor } from './persist'; export type AppState = {| navInfo: NavInfo, currentUserInfo: ?CurrentUserInfo, entryStore: EntryStore, threadStore: ThreadStore, userStore: UserStore, messageStore: MessageStore, - drafts: { [key: string]: string }, updatesCurrentAsOf: number, loadingStatuses: { [key: string]: { [idx: number]: LoadingStatus } }, calendarFilters: $ReadOnlyArray, cookie: ?string, deviceToken: ?string, dataLoaded: boolean, urlPrefix: string, customServer: ?string, threadIDsToNotifIDs: { [threadID: string]: string[] }, notifPermissionAlertInfo: NotifPermissionAlertInfo, connection: ConnectionInfo, watchedThreadIDs: $ReadOnlyArray, lifecycleState: LifecycleState, nextLocalID: number, queuedReports: $ReadOnlyArray, _persist: ?PersistState, sessionID?: void, dimensions: DimensionsInfo, connectivity: ConnectivityInfo, globalThemeInfo: GlobalThemeInfo, deviceCameraInfo: DeviceCameraInfo, deviceOrientation: Orientations, frozen: boolean, |}; const defaultState = ({ navInfo: defaultNavInfo, currentUserInfo: null, entryStore: { entryInfos: {}, daysToEntries: {}, lastUserInteractionCalendar: 0, inconsistencyReports: [], }, threadStore: { threadInfos: {}, inconsistencyReports: [], }, userStore: { userInfos: {}, inconsistencyReports: [], }, messageStore: { messages: {}, threads: {}, local: {}, currentAsOf: 0, }, - drafts: {}, updatesCurrentAsOf: 0, loadingStatuses: {}, calendarFilters: defaultCalendarFilters, cookie: null, deviceToken: null, dataLoaded: false, urlPrefix: defaultURLPrefix, customServer: natNodeServer, threadIDsToNotifIDs: {}, notifPermissionAlertInfo: defaultNotifPermissionAlertInfo, connection: defaultConnectionInfo(Platform.OS), watchedThreadIDs: [], lifecycleState: 'active', nextLocalID: 0, queuedReports: [], _persist: null, dimensions: defaultDimensionsInfo, connectivity: defaultConnectivityInfo, globalThemeInfo: defaultGlobalThemeInfo, deviceCameraInfo: defaultDeviceCameraInfo, deviceOrientation: Orientation.getInitialOrientation(), frozen: false, }: AppState); function reducer(state: AppState = defaultState, action: *) { if (action.type === setReduxStateActionType) { return action.state; } if ( (action.type === setNewSessionActionType && invalidSessionDowngrade( state, action.payload.sessionChange.currentUserInfo, action.payload.preRequestUserState, )) || (action.type === logOutActionTypes.success && invalidSessionDowngrade( state, action.payload.currentUserInfo, action.payload.preRequestUserState, )) || (action.type === deleteAccountActionTypes.success && invalidSessionDowngrade( state, action.payload.currentUserInfo, action.payload.preRequestUserState, )) ) { return state; } if ( (action.type === setNewSessionActionType && invalidSessionRecovery( state, action.payload.sessionChange.currentUserInfo, action.payload.source, )) || (action.type === logInActionTypes.success && invalidSessionRecovery( state, action.payload.currentUserInfo, action.payload.source, )) ) { return state; } if ( action.type === recordAndroidNotificationActionType || action.type === clearAndroidNotificationsActionType || action.type === rescindAndroidNotificationActionType ) { return { ...state, threadIDsToNotifIDs: reduceThreadIDsToNotifIDs( state.threadIDsToNotifIDs, action, ), }; } else if (action.type === setCustomServer) { return { ...state, customServer: action.payload, }; } else if (action.type === recordNotifPermissionAlertActionType) { return { ...state, notifPermissionAlertInfo: { totalAlerts: state.notifPermissionAlertInfo.totalAlerts + 1, lastAlertTime: action.payload.time, }, }; } else if (action.type === resetUserStateActionType) { const cookie = state.cookie && state.cookie.startsWith('anonymous=') ? state.cookie : null; const currentUserInfo = state.currentUserInfo && state.currentUserInfo.anonymous ? state.currentUserInfo : null; return { ...state, currentUserInfo, cookie, }; } else if (action.type === updateDimensionsActiveType) { return { ...state, dimensions: { ...state.dimensions, ...action.payload, }, }; } else if (action.type === updateConnectivityActiveType) { return { ...state, connectivity: action.payload, }; } else if (action.type === updateThemeInfoActionType) { return { ...state, globalThemeInfo: { ...state.globalThemeInfo, ...action.payload, }, }; } else if (action.type === updateDeviceCameraInfoActionType) { return { ...state, deviceCameraInfo: { ...state.deviceCameraInfo, ...action.payload, }, }; } else if (action.type === updateDeviceOrientationActionType) { return { ...state, deviceOrientation: action.payload, }; } else if (action.type === setDeviceTokenActionTypes.started) { return { ...state, deviceToken: action.payload, }; } else if (action.type === updateThreadLastNavigatedActionType) { const { threadID, time } = action.payload; if (state.messageStore.threads[threadID]) { state = { ...state, messageStore: { ...state.messageStore, threads: { ...state.messageStore.threads, [threadID]: { ...state.messageStore.threads[threadID], lastNavigatedTo: time, }, }, }, }; } } if (action.type === setNewSessionActionType) { sessionInvalidationAlert(action.payload); state = { ...state, cookie: action.payload.sessionChange.cookie, }; } else if (action.type === incrementalStateSyncActionType) { let wipeDeviceToken = false; for (const update of action.payload.updatesResult.newUpdates) { if ( update.type === updateTypes.BAD_DEVICE_TOKEN && update.deviceToken === state.deviceToken ) { wipeDeviceToken = true; break; } } if (wipeDeviceToken) { state = { ...state, deviceToken: null, }; } } - state = { - ...baseReducer(state, action), - drafts: reduceDrafts(state.drafts, action), - }; + state = baseReducer(state, action); return fixUnreadActiveThread(state, action); } function sessionInvalidationAlert(payload: SetSessionPayload) { if ( !payload.sessionChange.cookieInvalidated || !payload.preRequestUserState || !payload.preRequestUserState.currentUserInfo || payload.preRequestUserState.currentUserInfo.anonymous ) { return; } if (payload.error === 'client_version_unsupported') { const app = Platform.select({ ios: 'Testflight', android: 'Play Store', }); Alert.alert( 'App out of date', "Your app version is pretty old, and the server doesn't know how to " + `speak to it anymore. Please use the ${app} app to update!`, [{ text: 'OK' }], { cancelable: true }, ); } else { Alert.alert( 'Session invalidated', "We're sorry, but your session was invalidated by the server. " + 'Please log in again.', [{ text: 'OK' }], { cancelable: true }, ); } } // Makes sure a currently focused thread is never unread. Note that we consider // a backgrounded NativeAppState to actually be active if it last changed to // inactive more than 10 seconds ago. This is because there is a delay when // NativeAppState is updating in response to a foreground, and actions don't get // processed more than 10 seconds after a backgrounding anyways. However we // don't consider this for action types that can be expected to happen while the // app is backgrounded. function fixUnreadActiveThread(state: AppState, action: *): AppState { const navContext = getGlobalNavContext(); const activeThread = activeMessageListSelector(navContext); if ( activeThread && (NativeAppState.currentState === 'active' || (appLastBecameInactive + 10000 < Date.now() && !backgroundActionTypes.has(action.type))) && state.threadStore.threadInfos[activeThread] && state.threadStore.threadInfos[activeThread].currentUser.unread ) { state = { ...state, threadStore: { ...state.threadStore, threadInfos: { ...state.threadStore.threadInfos, [activeThread]: { ...state.threadStore.threadInfos[activeThread], currentUser: { ...state.threadStore.threadInfos[activeThread].currentUser, unread: false, }, }, }, }, }; } return state; } let appLastBecameInactive = 0; function appBecameInactive() { appLastBecameInactive = Date.now(); } const middlewares = [thunk, reduxLoggerMiddleware]; if (__DEV__) { const createDebugger = require('redux-flipper').default; middlewares.push(createDebugger()); } const middleware = applyMiddleware(...middlewares); let composeFunc = compose; if (__DEV__ && global.HermesInternal) { const { composeWithDevTools } = require('remote-redux-devtools/src'); composeFunc = composeWithDevTools(fetchDevServerHostname(), { name: 'Redux', ...remoteReduxDevServerConfig, }); } else if (global.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) { composeFunc = global.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({ name: 'Redux', }); } let enhancers; if (reactotron) { enhancers = composeFunc(middleware, reactotron.createEnhancer()); } else { enhancers = composeFunc(middleware); } const store: Store = createStore( persistReducer(persistConfig, reducer), defaultState, enhancers, ); const persistor = persistStore(store); setPersistor(persistor); const unsafeDispatch: any = store.dispatch; const dispatch: Dispatch = unsafeDispatch; updateURLPrefixAfterCheckingIfEmulator(store); export { store, dispatch, appBecameInactive }; diff --git a/native/root.react.js b/native/root.react.js index 9bb74075d..2fa0b4012 100644 --- a/native/root.react.js +++ b/native/root.react.js @@ -1,276 +1,283 @@ // @flow import AsyncStorage from '@react-native-community/async-storage'; import { useReduxDevToolsExtension } from '@react-navigation/devtools'; import { NavigationContainer } from '@react-navigation/native'; import type { PossiblyStaleNavigationState } from '@react-navigation/native'; import * as SplashScreen from 'expo-splash-screen'; import invariant from 'invariant'; import * as React from 'react'; import { Platform, UIManager, View, StyleSheet, LogBox } from 'react-native'; import Orientation from 'react-native-orientation-locker'; import { SafeAreaProvider, initialWindowMetrics, } from 'react-native-safe-area-context'; import { Provider } from 'react-redux'; import { PersistGate } from 'redux-persist/integration/react'; import { actionLogger } from 'lib/utils/action-logger'; import ConnectedStatusBar from './connected-status-bar.react'; import ErrorBoundary from './error-boundary.react'; import InputStateContainer from './input/input-state-container.react'; import LifecycleHandler from './lifecycle/lifecycle-handler.react'; import { defaultNavigationState } from './navigation/default-state'; import DisconnectedBarVisibilityHandler from './navigation/disconnected-bar-visibility-handler.react'; import { setGlobalNavContext } from './navigation/icky-global'; import { NavContext } from './navigation/navigation-context'; import NavigationHandler from './navigation/navigation-handler.react'; import { validNavState } from './navigation/navigation-utils'; import OrientationHandler from './navigation/orientation-handler.react'; import { navStateAsyncStorageKey } from './navigation/persistance'; import RootNavigator from './navigation/root-navigator.react'; import ConnectivityUpdater from './redux/connectivity-updater.react'; import { DimensionsUpdater } from './redux/dimensions-updater.react'; import { getPersistor } from './redux/persist'; import { store } from './redux/redux-setup'; import { useSelector } from './redux/redux-utils'; import { RootContext } from './root-context'; import Socket from './socket.react'; import { DarkTheme, LightTheme } from './themes/navigation'; import ThemeHandler from './themes/theme-handler.react'; import './themes/fonts'; LogBox.ignoreLogs([ // react-native-reanimated 'Please report: Excessive number of pending callbacks', ]); if (Platform.OS === 'android') { UIManager.setLayoutAnimationEnabledExperimental && UIManager.setLayoutAnimationEnabledExperimental(true); } +if (!global.CommCoreModule) { + global.CommCoreModule = { + getDraft: () => '', + updateDraft: () => {}, + }; +} + const navInitAction = Object.freeze({ type: 'NAV/@@INIT' }); const navUnknownAction = Object.freeze({ type: 'NAV/@@UNKNOWN' }); function Root() { const navStateRef = React.useRef(); const navDispatchRef = React.useRef(); const navStateInitializedRef = React.useRef(false); React.useEffect(() => { (async () => { try { await SplashScreen.preventAutoHideAsync(); } catch {} })(); }, []); const [navContext, setNavContext] = React.useState(null); const updateNavContext = React.useCallback(() => { if ( !navStateRef.current || !navDispatchRef.current || !navStateInitializedRef.current ) { return; } const updatedNavContext = { state: navStateRef.current, dispatch: navDispatchRef.current, }; setNavContext(updatedNavContext); setGlobalNavContext(updatedNavContext); }, []); const [initialState, setInitialState] = React.useState( __DEV__ ? undefined : defaultNavigationState, ); React.useEffect(() => { Orientation.lockToPortrait(); (async () => { let loadedState = initialState; if (__DEV__) { try { const navStateString = await AsyncStorage.getItem( navStateAsyncStorageKey, ); if (navStateString) { const savedState = JSON.parse(navStateString); if (validNavState(savedState)) { loadedState = savedState; } } } catch {} } if (!loadedState) { loadedState = defaultNavigationState; } if (loadedState !== initialState) { setInitialState(loadedState); } navStateRef.current = loadedState; updateNavContext(); actionLogger.addOtherAction('navState', navInitAction, null, loadedState); })(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [updateNavContext]); const setNavStateInitialized = React.useCallback(() => { navStateInitializedRef.current = true; updateNavContext(); }, [updateNavContext]); const [rootContext, setRootContext] = React.useState(() => ({ setNavStateInitialized, })); const detectUnsupervisedBackgroundRef = React.useCallback( (detectUnsupervisedBackground: ?(alreadyClosed: boolean) => boolean) => { setRootContext((prevRootContext) => ({ ...prevRootContext, detectUnsupervisedBackground, })); }, [], ); const frozen = useSelector((state) => state.frozen); const queuedActionsRef = React.useRef([]); const onNavigationStateChange = React.useCallback( (state: ?PossiblyStaleNavigationState) => { invariant(state, 'nav state should be non-null'); const prevState = navStateRef.current; navStateRef.current = state; updateNavContext(); const queuedActions = queuedActionsRef.current; queuedActionsRef.current = []; if (queuedActions.length === 0) { queuedActions.push(navUnknownAction); } for (const action of queuedActions) { actionLogger.addOtherAction('navState', action, prevState, state); } if (!__DEV__ || frozen) { return; } (async () => { try { await AsyncStorage.setItem( navStateAsyncStorageKey, JSON.stringify(state), ); } catch (e) { console.log('AsyncStorage threw while trying to persist navState', e); } })(); }, [updateNavContext, frozen], ); const navContainerRef = React.useRef(); const containerRef = React.useCallback( (navContainer: ?React.ElementRef) => { navContainerRef.current = navContainer; if (navContainer && !navDispatchRef.current) { navDispatchRef.current = navContainer.dispatch; updateNavContext(); } }, [updateNavContext], ); useReduxDevToolsExtension(navContainerRef); const navContainer = navContainerRef.current; React.useEffect(() => { if (!navContainer) { return; } return navContainer.addListener('__unsafe_action__', (event) => { const { action, noop } = event.data; const navState = navStateRef.current; if (noop) { actionLogger.addOtherAction('navState', action, navState, navState); return; } queuedActionsRef.current.push({ ...action, type: `NAV/${action.type}`, }); }); }, [navContainer]); const activeTheme = useSelector((state) => state.globalThemeInfo.activeTheme); const theme = (() => { if (activeTheme === 'light') { return LightTheme; } else if (activeTheme === 'dark') { return DarkTheme; } return undefined; })(); const gated: React.Node = ( <> ); let navigation; if (initialState) { navigation = ( ); } return ( {gated} {navigation} ); } const styles = StyleSheet.create({ app: { flex: 1, }, }); const AppRoot = () => ( ); export default AppRoot;