diff --git a/lib/shared/lifecycle-utils.js b/lib/shared/lifecycle-utils.js new file mode 100644 index 000000000..30ab7b676 --- /dev/null +++ b/lib/shared/lifecycle-utils.js @@ -0,0 +1,7 @@ +import { useSelector } from 'react-redux'; + +function useIsAppForegrounded() { + return useSelector((state) => state.foreground); +} + +export { useIsAppForegrounded }; diff --git a/lib/socket/calendar-query-handler.react.js b/lib/socket/calendar-query-handler.react.js index f23539f37..74c1d143a 100644 --- a/lib/socket/calendar-query-handler.react.js +++ b/lib/socket/calendar-query-handler.react.js @@ -1,144 +1,145 @@ // @flow import _isEqual from 'lodash/fp/isEqual'; import * as React from 'react'; import { updateCalendarQueryActionTypes, updateCalendarQuery, } from '../actions/entry-actions'; import { timeUntilCalendarRangeExpiration } from '../selectors/nav-selectors'; +import { useIsAppForegrounded } from '../shared/lifecycle-utils'; import type { CalendarQuery, CalendarQueryUpdateResult, CalendarQueryUpdateStartingPayload, } from '../types/entry-types'; import { type ConnectionInfo } from '../types/socket-types'; import { type DispatchActionPromise, useDispatchActionPromise, useServerCall, } from '../utils/action-utils'; import { useSelector } from '../utils/redux-utils'; type BaseProps = {| +currentCalendarQuery: () => CalendarQuery, +frozen: boolean, |}; type Props = {| ...BaseProps, +connection: ConnectionInfo, +lastUserInteractionCalendar: number, +foreground: boolean, +dispatchActionPromise: DispatchActionPromise, +updateCalendarQuery: ( calendarQuery: CalendarQuery, reduxAlreadyUpdated?: boolean, ) => Promise, |}; class CalendarQueryHandler extends React.PureComponent { serverCalendarQuery: CalendarQuery; expirationTimeoutID: ?TimeoutID; constructor(props: Props) { super(props); this.serverCalendarQuery = this.props.connection.actualizedCalendarQuery; } componentDidMount() { if (this.props.connection.status === 'connected') { this.possiblyUpdateCalendarQuery(); } } componentDidUpdate(prevProps: Props) { const { actualizedCalendarQuery } = this.props.connection; if (this.props.connection.status !== 'connected') { if (!_isEqual(this.serverCalendarQuery)(actualizedCalendarQuery)) { this.serverCalendarQuery = actualizedCalendarQuery; } return; } if ( !_isEqual(this.serverCalendarQuery)(actualizedCalendarQuery) && _isEqual(this.props.currentCalendarQuery())(actualizedCalendarQuery) ) { this.serverCalendarQuery = actualizedCalendarQuery; } const shouldUpdate = (this.isExpired || prevProps.connection.status !== 'connected' || this.props.currentCalendarQuery !== prevProps.currentCalendarQuery) && this.shouldUpdateCalendarQuery; if (shouldUpdate) { this.updateCalendarQuery(); } } render() { return null; } get isExpired() { const timeUntilExpiration = timeUntilCalendarRangeExpiration( this.props.lastUserInteractionCalendar, ); return ( timeUntilExpiration !== null && timeUntilExpiration !== undefined && timeUntilExpiration <= 0 ); } get shouldUpdateCalendarQuery() { if (this.props.connection.status !== 'connected' || this.props.frozen) { return false; } const calendarQuery = this.props.currentCalendarQuery(); return !_isEqual(calendarQuery)(this.serverCalendarQuery); } updateCalendarQuery() { const calendarQuery = this.props.currentCalendarQuery(); this.serverCalendarQuery = calendarQuery; this.props.dispatchActionPromise( updateCalendarQueryActionTypes, this.props.updateCalendarQuery(calendarQuery, true), undefined, ({ calendarQuery }: CalendarQueryUpdateStartingPayload), ); } possiblyUpdateCalendarQuery = () => { if (this.shouldUpdateCalendarQuery) { this.updateCalendarQuery(); } }; } export default React.memo(function ConnectedCalendarQueryHandler( props: BaseProps, ) { const connection = useSelector((state) => state.connection); const lastUserInteractionCalendar = useSelector( (state) => state.entryStore.lastUserInteractionCalendar, ); // We include this so that componentDidUpdate will be called on foreground - const foreground = useSelector((state) => state.foreground); + const foreground = useIsAppForegrounded(); const callUpdateCalendarQuery = useServerCall(updateCalendarQuery); const dispatchActionPromise = useDispatchActionPromise(); return ( ); }); diff --git a/native/chat/message-store-pruner.react.js b/native/chat/message-store-pruner.react.js index 347a6bb75..02550a758 100644 --- a/native/chat/message-store-pruner.react.js +++ b/native/chat/message-store-pruner.react.js @@ -1,68 +1,69 @@ // @flow import * as React from 'react'; import { useDispatch } from 'react-redux'; import { messageStorePruneActionType } from 'lib/actions/message-actions'; +import { useIsAppForegrounded } from 'lib/shared/lifecycle-utils'; import { NavContext } from '../navigation/navigation-context'; import { useSelector } from '../redux/redux-utils'; import { nextMessagePruneTimeSelector, pruneThreadIDsSelector, } from '../selectors/message-selectors'; function MessageStorePruner() { const nextMessagePruneTime = useSelector(nextMessagePruneTimeSelector); const prevNextMessagePruneTimeRef = React.useRef(nextMessagePruneTime); - const foreground = useSelector((state) => state.foreground); + const foreground = useIsAppForegrounded(); const frozen = useSelector((state) => state.frozen); const navContext = React.useContext(NavContext); const pruneThreadIDs = useSelector((state) => pruneThreadIDsSelector({ redux: state, navContext, }), ); const prunedRef = React.useRef(false); const dispatch = useDispatch(); React.useEffect(() => { if ( prunedRef.current && nextMessagePruneTime !== prevNextMessagePruneTimeRef.current ) { prunedRef.current = false; } prevNextMessagePruneTimeRef.current = nextMessagePruneTime; if (frozen || prunedRef.current) { return; } if (nextMessagePruneTime === null || nextMessagePruneTime === undefined) { return; } const timeUntilExpiration = nextMessagePruneTime - Date.now(); if (timeUntilExpiration > 0) { return; } const threadIDs = pruneThreadIDs(); if (threadIDs.length === 0) { return; } prunedRef.current = true; dispatch({ type: messageStorePruneActionType, payload: { threadIDs }, }); // We include foreground so this effect will be called on foreground }, [nextMessagePruneTime, frozen, foreground, pruneThreadIDs, dispatch]); return null; } export default MessageStorePruner; diff --git a/native/media/camera-modal.react.js b/native/media/camera-modal.react.js index 40a04b804..71ac1b4e3 100644 --- a/native/media/camera-modal.react.js +++ b/native/media/camera-modal.react.js @@ -1,1247 +1,1248 @@ // @flow import invariant from 'invariant'; import PropTypes from 'prop-types'; import * as React from 'react'; import { View, Text, StyleSheet, TouchableOpacity, Platform, Image, Animated, Easing, Alert, } from 'react-native'; import { RNCamera } from 'react-native-camera'; import filesystem from 'react-native-fs'; import { PinchGestureHandler, TapGestureHandler, State as GestureState, } from 'react-native-gesture-handler'; import Orientation from 'react-native-orientation-locker'; import type { Orientations } from 'react-native-orientation-locker'; import Reanimated, { Easing as ReanimatedEasing, } from 'react-native-reanimated'; import Icon from 'react-native-vector-icons/Ionicons'; import { useDispatch } from 'react-redux'; import { pathFromURI, filenameFromPathOrURI } from 'lib/media/file-utils'; +import { useIsAppForegrounded } from 'lib/shared/lifecycle-utils'; import { useRealThreadCreator } from 'lib/shared/thread-utils'; import type { PhotoCapture } from 'lib/types/media-types'; import type { Dispatch } from 'lib/types/redux-types'; import type { OptimisticThreadInfo } from 'lib/types/thread-types'; import { optimisticThreadInfoPropType } from 'lib/types/thread-types'; import ContentLoading from '../components/content-loading.react'; import ConnectedStatusBar from '../connected-status-bar.react'; import { type InputState, inputStatePropType, InputStateContext, } from '../input/input-state'; import type { AppNavigationProp } from '../navigation/app-navigator.react'; import { OverlayContext, type OverlayContextType, overlayContextPropType, } from '../navigation/overlay-context'; import type { NavigationRoute } from '../navigation/route-names'; import { updateDeviceCameraInfoActionType } from '../redux/action-types'; import { type DimensionsInfo, dimensionsInfoPropType, } from '../redux/dimensions-updater.react'; import { useSelector } from '../redux/redux-utils'; import { colors } from '../themes/colors'; import { type DeviceCameraInfo, deviceCameraInfoPropType, } from '../types/camera'; import type { NativeMethods } from '../types/react-native'; import type { ViewStyle } from '../types/styles'; import { clamp, gestureJustEnded } from '../utils/animation-utils'; import SendMediaButton from './send-media-button.react'; /* eslint-disable import/no-named-as-default-member */ const { Value, Clock, event, Extrapolate, block, set, call, cond, not, and, or, eq, greaterThan, lessThan, add, sub, multiply, divide, abs, interpolate, startClock, stopClock, clockRunning, timing, spring, SpringUtils, } = Reanimated; /* eslint-enable import/no-named-as-default-member */ const maxZoom = 16; const zoomUpdateFactor = (() => { if (Platform.OS === 'ios') { return 0.002; } if (Platform.OS === 'android' && Platform.Version > 26) { return 0.005; } if (Platform.OS === 'android' && Platform.Version > 23) { return 0.01; } return 0.03; })(); const stagingModeAnimationConfig = { duration: 150, easing: ReanimatedEasing.inOut(ReanimatedEasing.ease), }; const sendButtonAnimationConfig = { duration: 150, easing: Easing.inOut(Easing.ease), useNativeDriver: true, }; const indicatorSpringConfig = { ...SpringUtils.makeDefaultConfig(), damping: 0, mass: 0.6, toValue: 1, }; const indicatorTimingConfig = { duration: 500, easing: ReanimatedEasing.out(ReanimatedEasing.ease), toValue: 0, }; function runIndicatorAnimation( // Inputs springClock: Clock, delayClock: Clock, timingClock: Clock, animationRunning: Value, // Outputs scale: Value, opacity: Value, ): Value { const delayStart = new Value(0); const springScale = new Value(0.75); const delayScale = new Value(0); const timingScale = new Value(0.75); const animatedScale = cond( clockRunning(springClock), springScale, cond(clockRunning(delayClock), delayScale, timingScale), ); const lastAnimatedScale = new Value(0.75); const numScaleLoops = new Value(0); const springState = { finished: new Value(1), velocity: new Value(0), time: new Value(0), position: springScale, }; const timingState = { finished: new Value(1), frameTime: new Value(0), time: new Value(0), position: timingScale, }; return block([ cond(not(animationRunning), [ set(springState.finished, 0), set(springState.velocity, 0), set(springState.time, 0), set(springScale, 0.75), set(lastAnimatedScale, 0.75), set(numScaleLoops, 0), set(opacity, 1), startClock(springClock), ]), [ cond( clockRunning(springClock), spring(springClock, springState, indicatorSpringConfig), ), timing(timingClock, timingState, indicatorTimingConfig), ], [ cond( and( greaterThan(animatedScale, 1.2), not(greaterThan(lastAnimatedScale, 1.2)), ), [ set(numScaleLoops, add(numScaleLoops, 1)), cond(greaterThan(numScaleLoops, 1), [ set(springState.finished, 1), stopClock(springClock), set(delayScale, springScale), set(delayStart, delayClock), startClock(delayClock), ]), ], ), set(lastAnimatedScale, animatedScale), ], cond( and( clockRunning(delayClock), greaterThan(delayClock, add(delayStart, 400)), ), [ stopClock(delayClock), set(timingState.finished, 0), set(timingState.frameTime, 0), set(timingState.time, 0), set(timingScale, delayScale), startClock(timingClock), ], ), cond( and(springState.finished, timingState.finished), stopClock(timingClock), ), set(scale, animatedScale), cond(clockRunning(timingClock), set(opacity, clamp(animatedScale, 0, 1))), ]); } export type CameraModalParams = {| +presentedFrom: string, +thread: OptimisticThreadInfo, |}; type TouchableOpacityInstance = React.AbstractComponent< React.ElementConfig, NativeMethods, >; type BaseProps = {| +navigation: AppNavigationProp<'CameraModal'>, +route: NavigationRoute<'CameraModal'>, |}; type Props = {| ...BaseProps, // Redux state +dimensions: DimensionsInfo, +deviceCameraInfo: DeviceCameraInfo, +deviceOrientation: Orientations, +foreground: boolean, // Redux dispatch functions +dispatch: Dispatch, +getServerThreadID: () => Promise, // withInputState +inputState: ?InputState, // withOverlayContext +overlayContext: ?OverlayContextType, |}; type State = {| +zoom: number, +useFrontCamera: boolean, +hasCamerasOnBothSides: boolean, +flashMode: number, +autoFocusPointOfInterest: ?{| x: number, y: number, autoExposure?: boolean, |}, +stagingMode: boolean, +pendingPhotoCapture: ?PhotoCapture, |}; class CameraModal extends React.PureComponent { static propTypes = { navigation: PropTypes.shape({ goBackOnce: PropTypes.func.isRequired, }).isRequired, route: PropTypes.shape({ params: PropTypes.shape({ thread: optimisticThreadInfoPropType.isRequired, }).isRequired, }).isRequired, dimensions: dimensionsInfoPropType.isRequired, deviceCameraInfo: deviceCameraInfoPropType.isRequired, deviceOrientation: PropTypes.string.isRequired, foreground: PropTypes.bool.isRequired, dispatch: PropTypes.func.isRequired, getServerThreadID: PropTypes.func.isRequired, inputState: inputStatePropType, overlayContext: overlayContextPropType, }; camera: ?RNCamera; pinchEvent; pinchHandler = React.createRef(); tapEvent; tapHandler = React.createRef(); animationCode: Value; closeButton: ?React.ElementRef; closeButtonX = new Value(-1); closeButtonY = new Value(-1); closeButtonWidth = new Value(0); closeButtonHeight = new Value(0); photoButton: ?React.ElementRef; photoButtonX = new Value(-1); photoButtonY = new Value(-1); photoButtonWidth = new Value(0); photoButtonHeight = new Value(0); switchCameraButton: ?React.ElementRef; switchCameraButtonX = new Value(-1); switchCameraButtonY = new Value(-1); switchCameraButtonWidth = new Value(0); switchCameraButtonHeight = new Value(0); flashButton: ?React.ElementRef; flashButtonX = new Value(-1); flashButtonY = new Value(-1); flashButtonWidth = new Value(0); flashButtonHeight = new Value(0); focusIndicatorX = new Value(-1); focusIndicatorY = new Value(-1); focusIndicatorScale = new Value(0); focusIndicatorOpacity = new Value(0); cancelIndicatorAnimation = new Value(0); cameraIDsFetched = false; stagingModeProgress = new Value(0); sendButtonProgress = new Animated.Value(0); sendButtonStyle: ViewStyle; overlayStyle: ViewStyle; constructor(props: Props) { super(props); this.state = { zoom: 0, useFrontCamera: props.deviceCameraInfo.defaultUseFrontCamera, hasCamerasOnBothSides: props.deviceCameraInfo.hasCamerasOnBothSides, flashMode: RNCamera.Constants.FlashMode.off, autoFocusPointOfInterest: undefined, stagingMode: false, pendingPhotoCapture: undefined, }; const sendButtonScale = this.sendButtonProgress.interpolate({ inputRange: [0, 1], outputRange: ([1.1, 1]: number[]), // Flow... }); this.sendButtonStyle = { opacity: this.sendButtonProgress, transform: [{ scale: sendButtonScale }], }; const overlayOpacity = interpolate(this.stagingModeProgress, { inputRange: [0, 0.01, 1], outputRange: [0, 0.5, 0], extrapolate: Extrapolate.CLAMP, }); this.overlayStyle = { ...styles.overlay, opacity: overlayOpacity, }; const pinchState = new Value(-1); const pinchScale = new Value(1); this.pinchEvent = event([ { nativeEvent: { state: pinchState, scale: pinchScale, }, }, ]); const tapState = new Value(-1); const tapX = new Value(0); const tapY = new Value(0); this.tapEvent = event([ { nativeEvent: { state: tapState, x: tapX, y: tapY, }, }, ]); this.animationCode = block([ this.zoomAnimationCode(pinchState, pinchScale), this.focusAnimationCode(tapState, tapX, tapY), ]); } zoomAnimationCode(pinchState: Value, pinchScale: Value): Value { const pinchJustEnded = gestureJustEnded(pinchState); const zoomBase = new Value(1); const zoomReported = new Value(1); const currentZoom = interpolate(multiply(zoomBase, pinchScale), { inputRange: [1, 8], outputRange: [1, 8], extrapolate: Extrapolate.CLAMP, }); const cameraZoomFactor = interpolate(zoomReported, { inputRange: [1, 8], outputRange: [0, 1], extrapolate: Extrapolate.CLAMP, }); const resolvedZoom = cond( eq(pinchState, GestureState.ACTIVE), currentZoom, zoomBase, ); return [ cond(pinchJustEnded, set(zoomBase, currentZoom)), cond( or( pinchJustEnded, greaterThan( abs(sub(divide(resolvedZoom, zoomReported), 1)), zoomUpdateFactor, ), ), [ set(zoomReported, resolvedZoom), call([cameraZoomFactor], this.updateZoom), ], ), ]; } focusAnimationCode(tapState: Value, tapX: Value, tapY: Value): Value { const lastTapX = new Value(0); const lastTapY = new Value(0); const fingerJustReleased = and( gestureJustEnded(tapState), this.outsideButtons(lastTapX, lastTapY), ); const indicatorSpringClock = new Clock(); const indicatorDelayClock = new Clock(); const indicatorTimingClock = new Clock(); const indicatorAnimationRunning = or( clockRunning(indicatorSpringClock), clockRunning(indicatorDelayClock), clockRunning(indicatorTimingClock), ); return [ cond(fingerJustReleased, [ call([tapX, tapY], this.focusOnPoint), set(this.focusIndicatorX, tapX), set(this.focusIndicatorY, tapY), stopClock(indicatorSpringClock), stopClock(indicatorDelayClock), stopClock(indicatorTimingClock), ]), cond(this.cancelIndicatorAnimation, [ set(this.cancelIndicatorAnimation, 0), stopClock(indicatorSpringClock), stopClock(indicatorDelayClock), stopClock(indicatorTimingClock), set(this.focusIndicatorOpacity, 0), ]), cond( or(fingerJustReleased, indicatorAnimationRunning), runIndicatorAnimation( indicatorSpringClock, indicatorDelayClock, indicatorTimingClock, indicatorAnimationRunning, this.focusIndicatorScale, this.focusIndicatorOpacity, ), ), set(lastTapX, tapX), set(lastTapY, tapY), ]; } outsideButtons(x: Value, y: Value) { const { closeButtonX, closeButtonY, closeButtonWidth, closeButtonHeight, photoButtonX, photoButtonY, photoButtonWidth, photoButtonHeight, switchCameraButtonX, switchCameraButtonY, switchCameraButtonWidth, switchCameraButtonHeight, flashButtonX, flashButtonY, flashButtonWidth, flashButtonHeight, } = this; return and( or( lessThan(x, closeButtonX), greaterThan(x, add(closeButtonX, closeButtonWidth)), lessThan(y, closeButtonY), greaterThan(y, add(closeButtonY, closeButtonHeight)), ), or( lessThan(x, photoButtonX), greaterThan(x, add(photoButtonX, photoButtonWidth)), lessThan(y, photoButtonY), greaterThan(y, add(photoButtonY, photoButtonHeight)), ), or( lessThan(x, switchCameraButtonX), greaterThan(x, add(switchCameraButtonX, switchCameraButtonWidth)), lessThan(y, switchCameraButtonY), greaterThan(y, add(switchCameraButtonY, switchCameraButtonHeight)), ), or( lessThan(x, flashButtonX), greaterThan(x, add(flashButtonX, flashButtonWidth)), lessThan(y, flashButtonY), greaterThan(y, add(flashButtonY, flashButtonHeight)), ), ); } static isActive(props) { const { overlayContext } = props; invariant(overlayContext, 'CameraModal should have OverlayContext'); return !overlayContext.isDismissing; } componentDidMount() { if (CameraModal.isActive(this.props)) { Orientation.unlockAllOrientations(); } } componentWillUnmount() { if (CameraModal.isActive(this.props)) { Orientation.lockToPortrait(); } } componentDidUpdate(prevProps: Props, prevState: State) { const isActive = CameraModal.isActive(this.props); const wasActive = CameraModal.isActive(prevProps); if (isActive && !wasActive) { Orientation.unlockAllOrientations(); } else if (!isActive && wasActive) { Orientation.lockToPortrait(); } if (!this.state.hasCamerasOnBothSides && prevState.hasCamerasOnBothSides) { this.switchCameraButtonX.setValue(-1); this.switchCameraButtonY.setValue(-1); this.switchCameraButtonWidth.setValue(0); this.switchCameraButtonHeight.setValue(0); } if (this.props.deviceOrientation !== prevProps.deviceOrientation) { this.setState({ autoFocusPointOfInterest: null }); this.cancelIndicatorAnimation.setValue(1); } if (this.props.foreground && !prevProps.foreground && this.camera) { this.camera.refreshAuthorizationStatus(); } if (this.state.stagingMode && !prevState.stagingMode) { this.cancelIndicatorAnimation.setValue(1); this.focusIndicatorOpacity.setValue(0); timing(this.stagingModeProgress, { ...stagingModeAnimationConfig, toValue: 1, }).start(); } else if (!this.state.stagingMode && prevState.stagingMode) { this.stagingModeProgress.setValue(0); } if (this.state.pendingPhotoCapture && !prevState.pendingPhotoCapture) { Animated.timing(this.sendButtonProgress, { ...sendButtonAnimationConfig, toValue: 1, }).start(); } else if ( !this.state.pendingPhotoCapture && prevState.pendingPhotoCapture ) { CameraModal.cleanUpPendingPhotoCapture(prevState.pendingPhotoCapture); this.sendButtonProgress.setValue(0); } } static async cleanUpPendingPhotoCapture(pendingPhotoCapture: PhotoCapture) { const path = pathFromURI(pendingPhotoCapture.uri); if (!path) { return; } try { await filesystem.unlink(path); } catch (e) {} } get containerStyle() { const { overlayContext } = this.props; invariant(overlayContext, 'CameraModal should have OverlayContext'); return { ...styles.container, opacity: overlayContext.position, }; } get focusIndicatorStyle() { return { ...styles.focusIndicator, opacity: this.focusIndicatorOpacity, transform: [ { translateX: this.focusIndicatorX }, { translateY: this.focusIndicatorY }, { scale: this.focusIndicatorScale }, ], }; } renderCamera = ({ camera, status }) => { if (camera && camera._cameraHandle) { this.fetchCameraIDs(camera); } if (this.state.stagingMode) { return this.renderStagingView(); } const topButtonStyle = { top: Math.max(this.props.dimensions.topInset, 6), }; return ( <> {this.renderCameraContent(status)} × ); }; renderStagingView() { let image = null; const { pendingPhotoCapture } = this.state; if (pendingPhotoCapture) { const imageSource = { uri: pendingPhotoCapture.uri }; image = ; } else { image = ; } const topButtonStyle = { top: Math.max(this.props.dimensions.topInset - 3, 3), }; const sendButtonContainerStyle = { bottom: this.props.dimensions.bottomInset + 22, }; return ( <> {image} ); } renderCameraContent(status) { if (status === 'PENDING_AUTHORIZATION') { return ; } else if (status === 'NOT_AUTHORIZED') { return ( {"don't have permission :("} ); } let switchCameraButton = null; if (this.state.hasCamerasOnBothSides) { switchCameraButton = ( ); } let flashIcon; if (this.state.flashMode === RNCamera.Constants.FlashMode.on) { flashIcon = ; } else if (this.state.flashMode === RNCamera.Constants.FlashMode.off) { flashIcon = ; } else { flashIcon = ( <> A ); } const topButtonStyle = { top: Math.max(this.props.dimensions.topInset - 3, 3), }; const bottomButtonsContainerStyle = { bottom: this.props.dimensions.bottomInset + 20, }; return ( {flashIcon} {switchCameraButton} ); } render() { const statusBar = CameraModal.isActive(this.props) ? (