diff --git a/native/chat/chat-input-bar.react.js b/native/chat/chat-input-bar.react.js index 4ef1a073e..bedb7af0e 100644 --- a/native/chat/chat-input-bar.react.js +++ b/native/chat/chat-input-bar.react.js @@ -1,994 +1,1002 @@ // @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, { EasingNode } from 'react-native-reanimated'; import Icon from 'react-native-vector-icons/Ionicons'; import { useDispatch } from 'react-redux'; +import { + moveDraftActionType, + updateDraftActionType, +} from 'lib/actions/draft-actions'; import { joinThreadActionTypes, joinThread, newThreadActionTypes, } from 'lib/actions/thread-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { localIDPrefix, trimMessage } from 'lib/shared/message-utils'; import { threadHasPermission, viewerIsMember, threadFrozenDueToViewerBlock, threadActualMembers, checkIfDefaultMembersAreVoiced, draftKeyFromThreadID, colorIsDark, } 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 SWMansionIcon from '../components/swmansion-icon.react'; -import { type UpdateDraft, type MoveDraft, useDrafts } from '../data/core-data'; 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 { LayoutEvent } from '../types/react-native'; import { type AnimatedViewStyle, AnimatedView } from '../types/styles'; import { runTiming } from '../utils/animation-utils'; import { ChatContext } from './chat-context'; import type { ChatNavigationProp } from './chat.react'; /* eslint-disable import/no-named-as-default-member */ const { Value, Clock, block, set, cond, neq, sub, interpolateNode, stopClock, } = Animated; /* eslint-enable import/no-named-as-default-member */ const expandoButtonsAnimationConfig = { duration: 150, easing: EasingNode.inOut(EasingNode.ease), }; const sendButtonAnimationConfig = { duration: 150, easing: EasingNode.inOut(EasingNode.ease), }; type BaseProps = { +threadInfo: ThreadInfo, }; type Props = { ...BaseProps, // Redux state +viewerID: ?string, +draft: string, - +updateDraft: UpdateDraft, - +moveDraft: MoveDraft, +joinThreadLoadingStatus: LoadingStatus, +threadCreationInProgress: boolean, +calendarQuery: () => CalendarQuery, +nextLocalID: number, +userInfos: UserInfos, +colors: Colors, +styles: typeof unboundStyles, +onInputBarLayout?: (event: LayoutEvent) => mixed, +openCamera: () => mixed, // 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, +textEdited: boolean, +buttonsExpanded: boolean, }; class ChatInputBar extends React.PureComponent { textInput: ?React.ElementRef; clearableTextInput: ?ClearableTextInput; expandoButtonsOpen: Value; targetExpandoButtonsOpen: Value; expandoButtonsStyle: AnimatedViewStyle; cameraRollIconStyle: AnimatedViewStyle; cameraIconStyle: AnimatedViewStyle; expandIconStyle: AnimatedViewStyle; sendButtonContainerOpen: Value; targetSendButtonContainerOpen: Value; sendButtonContainerStyle: AnimatedViewStyle; constructor(props: Props) { super(props); this.state = { text: props.draft, textEdited: false, buttonsExpanded: true, }; this.setUpActionIconAnimations(); this.setUpSendIconAnimations(); } setUpActionIconAnimations() { 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 = interpolateNode(expandoButtonsOpen, { inputRange: [0, 1], outputRange: [26, 66], }); this.expandoButtonsStyle = { ...unboundStyles.expandoButtons, width: expandoButtonsWidth, }; const expandOpacity = sub(1, expandoButtonsOpen); this.expandIconStyle = { ...unboundStyles.expandIcon, opacity: expandOpacity, }; } setUpSendIconAnimations() { const initialSendButtonContainerOpen = trimMessage(this.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 = interpolateNode(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) { if (this.shouldShowTextInput) { this.targetSendButtonContainerOpen.setValue(currentText === '' ? 0 : 1); } else { this.setUpSendIconAnimations(); } } componentDidMount() { if (this.props.isActive) { this.addReplyListener(); } } componentWillUnmount() { if (this.props.isActive) { this.removeReplyListener(); } } componentDidUpdate(prevProps: Props, prevState: State) { if ( this.state.textEdited && this.state.text && this.props.threadInfo.id !== prevProps.threadInfo.id ) { - this.props.moveDraft( - draftKeyFromThreadID(prevProps.threadInfo.id), - draftKeyFromThreadID(this.props.threadInfo.id), - ); + this.props.dispatch({ + type: moveDraftActionType, + payload: { + oldKey: draftKeyFromThreadID(prevProps.threadInfo.id), + newKey: draftKeyFromThreadID(this.props.threadInfo.id), + }, + }); } else if (!this.state.textEdited && this.props.draft !== prevProps.draft) { this.setState({ text: this.props.draft }); } 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); } get shouldShowTextInput(): boolean { if (threadHasPermission(this.props.threadInfo, threadPermissions.VOICED)) { return true; } // If the thread is created by somebody else while the viewer is attempting // to create it, the threadInfo might be modified in-place // and won't list the viewer as a member, // which will end up hiding the input. // In this case, we will assume that our creation action // will get translated into a join, and as long // as members are voiced, we can show the input. if (!this.props.threadCreationInProgress) { return false; } return checkIfDefaultMembersAreVoiced(this.props.threadInfo); } render() { const isMember = viewerIsMember(this.props.threadInfo); const canJoin = threadHasPermission( this.props.threadInfo, threadPermissions.JOIN_THREAD, ); let joinButton = null; if (!isMember && canJoin && !this.props.threadCreationInProgress) { let buttonContent; if (this.props.joinThreadLoadingStatus === 'loading') { buttonContent = ( ); } else { const textStyle = colorIsDark(this.props.threadInfo.color) ? this.props.styles.joinButtonTextLight : this.props.styles.joinButtonTextDark; buttonContent = ( Join Chat ); } joinButton = ( ); } let content; const defaultMembersAreVoiced = checkIfDefaultMembersAreVoiced( this.props.threadInfo, ); if (this.shouldShowTextInput) { 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 if (defaultMembersAreVoiced && canJoin) { content = null; } 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, textEdited: true }); this.saveDraft(text); }; saveDraft = _throttle(text => { - this.props.updateDraft({ - key: draftKeyFromThreadID(this.props.threadInfo.id), - text, + this.props.dispatch({ + type: updateDraftActionType, + payload: { + key: draftKeyFromThreadID(this.props.threadInfo.id), + text, + }, }); }, 400); focusAndUpdateText = (text: string) => { const { textInput } = this; if (!textInput) { return; } const currentText = this.state.text; if (!currentText.startsWith(text)) { const prependedText = text.concat(currentText); this.updateText(prependedText); this.immediatelyShowSendButton(); this.immediatelyHideButtons(); } 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 = `${localIDPrefix}${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 }); } showMediaGallery = () => { const { keyboardState } = this.props; invariant(keyboardState, 'keyboardState should be initialized'); keyboardState.showMediaGallery(this.props.threadInfo); }; dismissKeyboard = () => { const { keyboardState } = this.props; keyboardState && keyboardState.dismissKeyboard(); }; } const unboundStyles = { cameraIcon: { paddingBottom: Platform.OS === 'android' ? 11 : 8, paddingRight: 5, }, cameraRollIcon: { paddingBottom: Platform.OS === 'android' ? 11 : 8, paddingRight: 5, }, container: { backgroundColor: 'listBackground', paddingLeft: Platform.OS === 'android' ? 10 : 5, }, expandButton: { bottom: 0, position: 'absolute', right: 0, }, expandIcon: { paddingBottom: Platform.OS === 'android' ? 13 : 11, paddingRight: 2, }, 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: { borderRadius: 8, flex: 1, justifyContent: 'center', marginHorizontal: 12, marginVertical: 3, }, joinButtonContainer: { flexDirection: 'row', height: 48, marginBottom: 8, }, joinButtonContent: { flexDirection: 'row', justifyContent: 'center', alignItems: 'center', }, joinButtonTextLight: { color: 'white', fontSize: 20, marginHorizontal: 4, }, joinButtonTextDark: { color: 'black', fontSize: 20, marginHorizontal: 4, }, joinThreadLoadingIndicator: { paddingVertical: 2, }, sendButton: { position: 'absolute', bottom: 4, left: 0, }, sendIcon: { paddingLeft: 9, paddingRight: 8, paddingVertical: 6, }, textInput: { backgroundColor: 'listInputBackground', borderRadius: 12, color: 'listForegroundLabel', fontSize: 16, marginLeft: 4, marginRight: 4, marginTop: 6, marginBottom: 8, maxHeight: 250, paddingHorizontal: 10, paddingVertical: 5, }, }; const joinThreadLoadingStatusSelector = createLoadingStatusSelector( joinThreadActionTypes, ); const createThreadLoadingStatusSelector = createLoadingStatusSelector( newThreadActionTypes, ); type ConnectedChatInputBarBaseProps = { ...BaseProps, +onInputBarLayout?: (event: LayoutEvent) => mixed, +openCamera: () => mixed, }; function ConnectedChatInputBarBase(props: ConnectedChatInputBarBaseProps) { const navContext = React.useContext(NavContext); const keyboardState = React.useContext(KeyboardContext); const inputState = React.useContext(InputStateContext); const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); - const { draft, updateDraft, moveDraft } = useDrafts(props.threadInfo.id); + const draft = useSelector( + state => + state.draftStore.drafts[draftKeyFromThreadID(props.threadInfo.id)] ?? '', + ); const joinThreadLoadingStatus = useSelector(joinThreadLoadingStatusSelector); const createThreadLoadingStatus = useSelector( createThreadLoadingStatusSelector, ); const threadCreationInProgress = createThreadLoadingStatus === 'loading'; const calendarQuery = useSelector(state => nonThreadCalendarQuery({ redux: state, navContext, }), ); const nextLocalID = useSelector(state => state.nextLocalID); const userInfos = useSelector(state => state.userStore.userInfos); const styles = useStyles(unboundStyles); const colors = useColors(); const isActive = React.useMemo( () => props.threadInfo.id === activeThreadSelector(navContext), [props.threadInfo.id, navContext], ); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const callJoinThread = useServerCall(joinThread); return ( ); } type DummyChatInputBarProps = { ...BaseProps, +onHeightMeasured: (height: number) => mixed, }; const noop = () => {}; function DummyChatInputBar(props: DummyChatInputBarProps): React.Node { const { onHeightMeasured, ...restProps } = props; const onInputBarLayout = React.useCallback( (event: LayoutEvent) => { const { height } = event.nativeEvent.layout; onHeightMeasured(height); }, [onHeightMeasured], ); return ( ); } type ChatInputBarProps = { ...BaseProps, +navigation: ChatNavigationProp<'MessageList'>, +route: NavigationRoute<'MessageList'>, }; const ConnectedChatInputBar: React.ComponentType = React.memo( function ConnectedChatInputBar(props: ChatInputBarProps) { const { navigation, route, ...restProps } = props; const keyboardState = React.useContext(KeyboardContext); const { threadInfo } = props; const imagePastedCallback = React.useCallback( imagePastedEvent => { if (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, }; navigation.navigate<'ImagePasteModal'>({ name: ImagePasteModalRouteName, params: { imagePasteStagingInfo: pastedImage, thread: threadInfo, }, }); }, [navigation, threadInfo], ); React.useEffect(() => { const imagePasteListener = NativeAppEventEmitter.addListener( 'imagePasted', imagePastedCallback, ); return () => imagePasteListener.remove(); }, [imagePastedCallback]); const chatContext = React.useContext(ChatContext); invariant(chatContext, 'should be set'); const { setChatInputBarHeight, deleteChatInputBarHeight } = chatContext; const onInputBarLayout = React.useCallback( (event: LayoutEvent) => { const { height } = event.nativeEvent.layout; setChatInputBarHeight(threadInfo.id, height); }, [threadInfo.id, setChatInputBarHeight], ); React.useEffect(() => { return () => { deleteChatInputBarHeight(threadInfo.id); }; }, [deleteChatInputBarHeight, threadInfo.id]); const openCamera = React.useCallback(() => { keyboardState?.dismissKeyboard(); navigation.navigate<'CameraModal'>({ name: CameraModalRouteName, params: { presentedFrom: route.key, thread: threadInfo, }, }); }, [keyboardState, navigation, route.key, threadInfo]); return ( ); }, ); export { ConnectedChatInputBar as ChatInputBar, DummyChatInputBar }; diff --git a/native/chat/thread-draft-updater.react.js b/native/chat/thread-draft-updater.react.js index 555e702ae..9aedcaa01 100644 --- a/native/chat/thread-draft-updater.react.js +++ b/native/chat/thread-draft-updater.react.js @@ -1,49 +1,52 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; +import { useDispatch } from 'react-redux'; +import { moveDraftActionType } from 'lib/actions/draft-actions'; import { pendingToRealizedThreadIDsSelector } from 'lib/selectors/thread-selectors'; import { draftKeyFromThreadID } from 'lib/shared/thread-utils'; -import { useDrafts } from '../data/core-data'; import { useSelector } from '../redux/redux-utils'; import type { AppState } from '../redux/state-types'; const ThreadDraftUpdater: React.ComponentType<{}> = React.memo<{}>( function ThreadDraftUpdater() { const pendingToRealizedThreadIDs = useSelector((state: AppState) => pendingToRealizedThreadIDsSelector(state.threadStore.threadInfos), ); - const drafts = useDrafts(); + const dispatch = useDispatch(); const cachedThreadIDsRef = React.useRef(); if (!cachedThreadIDsRef.current) { const newCachedThreadIDs = new Set(); for (const realizedThreadID of pendingToRealizedThreadIDs.values()) { newCachedThreadIDs.add(realizedThreadID); } cachedThreadIDsRef.current = newCachedThreadIDs; } - const { moveDraft } = drafts; React.useEffect(() => { for (const [pendingThreadID, threadID] of pendingToRealizedThreadIDs) { const cachedThreadIDs = cachedThreadIDsRef.current; invariant(cachedThreadIDs, 'should be set'); if (cachedThreadIDs.has(threadID)) { continue; } - moveDraft( - draftKeyFromThreadID(pendingThreadID), - draftKeyFromThreadID(threadID), - ); + dispatch({ + type: moveDraftActionType, + payload: { + oldKey: draftKeyFromThreadID(pendingThreadID), + newKey: draftKeyFromThreadID(threadID), + }, + }); cachedThreadIDs.add(threadID); } - }, [pendingToRealizedThreadIDs, moveDraft]); + }, [pendingToRealizedThreadIDs, dispatch]); return null; }, ); ThreadDraftUpdater.displayName = 'ThreadDraftUpdater'; export default ThreadDraftUpdater; diff --git a/native/data/core-data-provider.react.js b/native/data/core-data-provider.react.js deleted file mode 100644 index 6a3ecdf58..000000000 --- a/native/data/core-data-provider.react.js +++ /dev/null @@ -1,157 +0,0 @@ -// @flow - -import * as React from 'react'; -import { useSelector } from 'react-redux'; - -import { commCoreModule } from '../native-modules'; -import { isTaskCancelledError } from '../utils/error-handling'; -import { type CoreData, defaultCoreData, CoreDataContext } from './core-data'; - -type Props = { - +children: React.Node, -}; -function CoreDataProvider(props: Props): React.Node { - const [draftCache, setDraftCache] = React.useState< - $PropertyType<$PropertyType, 'data'>, - >(defaultCoreData.drafts.data); - - React.useEffect(() => { - (async () => { - try { - const fetchedDrafts = await commCoreModule.getAllDrafts(); - setDraftCache(prevDrafts => { - const mergedDrafts = {}; - for (const draftObj of fetchedDrafts) { - mergedDrafts[draftObj.key] = draftObj.text; - } - for (const key in prevDrafts) { - const value = prevDrafts[key]; - if (!value) { - continue; - } - mergedDrafts[key] = value; - } - return mergedDrafts; - }); - } catch (e) { - if (!isTaskCancelledError(e)) { - throw e; - } - } - })(); - }, []); - - const removeAllDrafts = React.useCallback(async () => { - const oldDrafts = draftCache; - setDraftCache({}); - try { - return await commCoreModule.removeAllDrafts(); - } catch (e) { - setDraftCache(oldDrafts); - if (!isTaskCancelledError(e)) { - throw e; - } - } - }, [draftCache]); - - const viewerID = useSelector( - state => state.currentUserInfo && state.currentUserInfo.id, - ); - const prevViewerIDRef = React.useRef(); - React.useEffect(() => { - if (!viewerID) { - return; - } - if (prevViewerIDRef.current === viewerID) { - return; - } - if (prevViewerIDRef.current) { - removeAllDrafts(); - } - prevViewerIDRef.current = viewerID; - }, [viewerID, removeAllDrafts]); - - /** - * wrapper for updating the draft state receiving an array of drafts - * if you want to add/update the draft, pass the draft with non-empty text - * if you pass a draft with !!text == false - * it will remove this entry from the cache - */ - const setDrafts = React.useCallback( - (newDrafts: $ReadOnlyArray<{ +key: string, +text: ?string }>) => { - setDraftCache(prevDrafts => { - const result = { ...prevDrafts }; - newDrafts.forEach(draft => { - if (draft.text) { - result[draft.key] = draft.text; - } else { - delete result[draft.key]; - } - }); - return result; - }); - }, - [], - ); - const updateDraft = React.useCallback( - async (draft: { +key: string, +text: string }) => { - const prevDraftText = draftCache[draft.key]; - setDrafts([draft]); - try { - return await commCoreModule.updateDraft(draft); - } catch (e) { - setDrafts([{ key: draft.key, text: prevDraftText }]); - if (isTaskCancelledError(e)) { - return false; - } - throw e; - } - }, - [draftCache, setDrafts], - ); - - const moveDraft = React.useCallback( - async (prevKey: string, newKey: string) => { - const value = draftCache[prevKey]; - if (!value) { - return false; - } - setDrafts([ - { key: newKey, text: value }, - { key: prevKey, text: null }, - ]); - try { - return await commCoreModule.moveDraft(prevKey, newKey); - } catch (e) { - setDrafts([ - { key: newKey, text: null }, - { key: prevKey, text: value }, - ]); - if (isTaskCancelledError(e)) { - return false; - } - throw e; - } - }, - [draftCache, setDrafts], - ); - - const coreData = React.useMemo( - () => ({ - drafts: { - data: draftCache, - updateDraft, - moveDraft, - }, - }), - [draftCache, updateDraft, moveDraft], - ); - - return ( - - {props.children} - - ); -} - -export default CoreDataProvider; diff --git a/native/data/core-data.js b/native/data/core-data.js deleted file mode 100644 index 56be37ef9..000000000 --- a/native/data/core-data.js +++ /dev/null @@ -1,75 +0,0 @@ -// @flow - -import * as React from 'react'; - -import { draftKeyFromThreadID } from 'lib/shared/thread-utils'; - -import { commCoreModule } from '../native-modules'; -import { isTaskCancelledError } from '../utils/error-handling'; - -type DraftType = { - +key: string, - +text: string, -}; - -export type UpdateDraft = (draft: DraftType) => Promise; -export type MoveDraft = (prevKey: string, nextKey: string) => Promise; - -export type CoreData = { - +drafts: { - +data: { +[key: string]: string }, - +updateDraft: UpdateDraft, - +moveDraft: MoveDraft, - }, -}; - -const defaultCoreData = Object.freeze({ - drafts: { - data: ({}: { +[key: string]: string }), - updateDraft: async (draft: DraftType): Promise => { - try { - return commCoreModule.updateDraft(draft); - } catch (e) { - if (!isTaskCancelledError(e)) { - throw e; - } - } - return false; - }, - moveDraft: async (prevKey: string, nextKey: string): Promise => { - try { - return commCoreModule.moveDraft(prevKey, nextKey); - } catch (e) { - if (!isTaskCancelledError(e)) { - throw e; - } - } - return false; - }, - }, -}); - -const CoreDataContext: React.Context = React.createContext( - defaultCoreData, -); - -type ThreadDrafts = { - +draft: string, - +moveDraft: MoveDraft, - +updateDraft: UpdateDraft, -}; -const useDrafts = (threadID: ?string): ThreadDrafts => { - const coreData = React.useContext(CoreDataContext); - return React.useMemo( - () => ({ - draft: threadID - ? coreData.drafts.data[draftKeyFromThreadID(threadID)] ?? '' - : '', - updateDraft: coreData.drafts.updateDraft, - moveDraft: coreData.drafts.moveDraft, - }), - [coreData, threadID], - ); -}; - -export { defaultCoreData, CoreDataContext, useDrafts }; diff --git a/native/data/sqlite-context-provider.js b/native/data/sqlite-context-provider.js index a371095d1..4de6e5951 100644 --- a/native/data/sqlite-context-provider.js +++ b/native/data/sqlite-context-provider.js @@ -1,172 +1,178 @@ // @flow import * as React from 'react'; import { Alert } from 'react-native'; import ExitApp from 'react-native-exit-app'; import { useDispatch } from 'react-redux'; +import { setDraftStoreDrafts } from 'lib/actions/draft-actions'; import { setMessageStoreMessages } from 'lib/actions/message-actions.js'; import { setThreadStoreActionType } from 'lib/actions/thread-actions'; import { isLoggedIn } from 'lib/selectors/user-selectors'; import { logInActionSources } from 'lib/types/account-types'; import { fetchNewCookieFromNativeCredentials } from 'lib/utils/action-utils'; import { getMessageForException } from 'lib/utils/errors'; import { convertClientDBThreadInfosToRawThreadInfos } from 'lib/utils/thread-ops-utils'; import { commCoreModule } from '../native-modules'; import { useSelector } from '../redux/redux-utils'; import { StaffContext } from '../staff/staff-context'; import { isTaskCancelledError } from '../utils/error-handling'; import { useStaffCanSee } from '../utils/staff-utils'; import { SQLiteContext } from './sqlite-context'; type Props = { +children: React.Node, }; function SQLiteContextProvider(props: Props): React.Node { const [storeLoaded, setStoreLoaded] = React.useState(false); const dispatch = useDispatch(); const rehydrateConcluded = useSelector( state => !!(state._persist && state._persist.rehydrated), ); const cookie = useSelector(state => state.cookie); const urlPrefix = useSelector(state => state.urlPrefix); const staffCanSee = useStaffCanSee(); const { staffUserHasBeenLoggedIn } = React.useContext(StaffContext); const loggedIn = useSelector(isLoggedIn); const currentLoggedInUserID = useSelector(state => state.currentUserInfo?.anonymous ? undefined : state.currentUserInfo?.id, ); const handleSensitiveData = React.useCallback(async () => { try { const databaseCurrentUserInfoID = await commCoreModule.getCurrentUserID(); if ( databaseCurrentUserInfoID && databaseCurrentUserInfoID !== currentLoggedInUserID ) { if (staffCanSee || staffUserHasBeenLoggedIn) { Alert.alert('Starting SQLite database deletion process'); } await commCoreModule.clearSensitiveData(); if (staffCanSee || staffUserHasBeenLoggedIn) { Alert.alert( 'SQLite database successfully deleted', 'SQLite database deletion was triggered by change in logged-in user credentials', ); } } if (currentLoggedInUserID) { await commCoreModule.setCurrentUserID(currentLoggedInUserID); } const databaseDeviceID = await commCoreModule.getDeviceID(); if (!databaseDeviceID) { await commCoreModule.setDeviceID('MOBILE'); } } catch (e) { if (isTaskCancelledError(e)) { return; } if (__DEV__) { throw e; } else { console.log(e); ExitApp.exitApp(); } } }, [currentLoggedInUserID, staffCanSee, staffUserHasBeenLoggedIn]); React.useEffect(() => { if (!rehydrateConcluded) { return; } const sensitiveDataHandled = handleSensitiveData(); if (storeLoaded) { return; } if (!loggedIn) { setStoreLoaded(true); return; } (async () => { await sensitiveDataHandled; try { - const [threads, messages] = await Promise.all([ + const [threads, messages, drafts] = await Promise.all([ commCoreModule.getAllThreads(), commCoreModule.getAllMessages(), + commCoreModule.getAllDrafts(), ]); const threadInfosFromDB = convertClientDBThreadInfosToRawThreadInfos( threads, ); dispatch({ type: setThreadStoreActionType, payload: { threadInfos: threadInfosFromDB }, }); dispatch({ type: setMessageStoreMessages, payload: messages, }); + dispatch({ + type: setDraftStoreDrafts, + payload: drafts, + }); setStoreLoaded(true); } catch (setStoreException) { if (isTaskCancelledError(setStoreException)) { setStoreLoaded(true); return; } if (staffCanSee) { Alert.alert( `Error setting threadStore or messageStore: ${ getMessageForException(setStoreException) ?? '{no exception message}' }`, ); } try { await fetchNewCookieFromNativeCredentials( dispatch, cookie, urlPrefix, logInActionSources.sqliteLoadFailure, ); setStoreLoaded(true); } catch (fetchCookieException) { if (staffCanSee) { Alert.alert( `Error fetching new cookie from native credentials: ${ getMessageForException(fetchCookieException) ?? '{no exception message}' }. Please kill the app.`, ); } else { ExitApp.exitApp(); } } } })(); }, [ handleSensitiveData, loggedIn, cookie, dispatch, rehydrateConcluded, staffCanSee, storeLoaded, urlPrefix, ]); const contextValue = React.useMemo( () => ({ storeLoaded, }), [storeLoaded], ); return ( {props.children} ); } export { SQLiteContextProvider }; diff --git a/native/root.react.js b/native/root.react.js index 6e2c826e9..9ce076b7f 100644 --- a/native/root.react.js +++ b/native/root.react.js @@ -1,294 +1,291 @@ // @flow import { ActionSheetProvider } from '@expo/react-native-action-sheet'; import AsyncStorage from '@react-native-async-storage/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 as ReduxPersistGate } from 'redux-persist/integration/react'; import { actionLogger } from 'lib/utils/action-logger'; import ChatContextProvider from './chat/chat-context-provider.react'; import PersistedStateGate from './components/persisted-state-gate'; import ConnectedStatusBar from './connected-status-bar.react'; -import CoreDataProvider from './data/core-data-provider.react'; import { SQLiteContextProvider } from './data/sqlite-context-provider'; 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 { StaffContextProvider } from './staff/staff-context.provider.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); } const navInitAction = Object.freeze({ type: 'NAV/@@INIT' }); const navUnknownAction = Object.freeze({ type: 'NAV/@@UNKNOWN' }); SplashScreen.preventAutoHideAsync().catch(console.log); function Root() { const navStateRef = React.useRef(); const navDispatchRef = React.useRef(); const navStateInitializedRef = React.useRef(false); 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} - - - - - - - - - + + + + + + + + + + {gated} + + + + + {navigation} + + + + + + + + ); } const styles = StyleSheet.create({ app: { flex: 1, }, }); function AppRoot(): React.Node { return ( ); } export default AppRoot;