From 6dde1f9c11c2b458e8841e93c9e749d6200b933d Mon Sep 17 00:00:00 2001 From: Angie Date: Thu, 8 Feb 2024 11:51:11 -0800 Subject: [PATCH 01/24] Fix obsdetail carousel photos cut off (#1120) --- src/components/ObsDetails/ObsDetails.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/components/ObsDetails/ObsDetails.js b/src/components/ObsDetails/ObsDetails.js index a71335e83..a12b62d8f 100644 --- a/src/components/ObsDetails/ObsDetails.js +++ b/src/components/ObsDetails/ObsDetails.js @@ -164,11 +164,7 @@ const ObsDetails = ( { belongsToCurrentUser={belongsToCurrentUser} observation={observation} /> - + { currentUser && ( Date: Thu, 8 Feb 2024 13:22:05 -0800 Subject: [PATCH 02/24] Unread notifications indicator in tab bar (#1101) * Shows indicator in tab bar when you have new updates * Should update every minute * Make unread updates indicator go away when you view the updates Closes #900 --------- Co-authored-by: Ken-ichi Ueda --- src/api/observations.js | 16 ++++ .../ObsDetails/ObsDetailsContainer.js | 4 + .../BottomTabNavigator/NavButton.js | 22 +++++ .../BottomTabNavigator/NotificationsIcon.js | 84 ++++++++++++++++++ .../NotificationsIconContainer.js | 86 +++++++++++++++++++ src/sharedHooks/index.js | 1 + src/sharedHooks/useInterval.js | 28 ++++++ src/stores/useStore.js | 6 ++ .../BottomTabNavigator/CustomTabBar.test.js | 36 +++++--- .../__snapshots__/CustomTabBar.test.js.snap | 10 ++- 10 files changed, 279 insertions(+), 14 deletions(-) create mode 100644 src/navigation/BottomTabNavigator/NotificationsIcon.js create mode 100644 src/navigation/BottomTabNavigator/NotificationsIconContainer.js create mode 100644 src/sharedHooks/useInterval.js diff --git a/src/api/observations.js b/src/api/observations.js index 74c4a8c9a..ab70a9ada 100644 --- a/src/api/observations.js +++ b/src/api/observations.js @@ -130,6 +130,21 @@ const fetchObservationUpdates = async ( } }; +const fetchUnviewedObservationUpdatesCount = async ( + opts: Object +): Promise => { + try { + const { total_results: updatesCount } = await inatjs.observations.updates( { + observations_by: "owner", + viewed: false, + per_page: 0 + }, opts ); + return updatesCount; + } catch ( e ) { + return handleError( e ); + } +}; + const deleteRemoteObservation = async ( params: Object = {}, opts: Object = {} @@ -187,6 +202,7 @@ export { fetchObservers, fetchRemoteObservation, fetchSpeciesCounts, + fetchUnviewedObservationUpdatesCount, markAsReviewed, markObservationUpdatesViewed, searchObservations, diff --git a/src/components/ObsDetails/ObsDetailsContainer.js b/src/components/ObsDetails/ObsDetailsContainer.js index 4493156e7..2864fb51e 100644 --- a/src/components/ObsDetails/ObsDetailsContainer.js +++ b/src/components/ObsDetails/ObsDetailsContainer.js @@ -103,6 +103,9 @@ const reducer = ( state, action ) => { const ObsDetailsContainer = ( ): Node => { const setObservations = useStore( state => state.setObservations ); + const setObservationMarkedAsViewedAt = useStore( + state => state.setObservationMarkedAsViewedAt + ); const currentUser = useCurrentUser( ); const { params } = useRoute(); const { @@ -245,6 +248,7 @@ const ObsDetailsContainer = ( ): Node => { queryClient.invalidateQueries( [fetchObservationUpdatesKey] ); refetchRemoteObservation( ); refetchObservationUpdates( ); + setObservationMarkedAsViewedAt( new Date( ) ); } } ); diff --git a/src/navigation/BottomTabNavigator/NavButton.js b/src/navigation/BottomTabNavigator/NavButton.js index 4f5471df3..e2801fe83 100644 --- a/src/navigation/BottomTabNavigator/NavButton.js +++ b/src/navigation/BottomTabNavigator/NavButton.js @@ -1,6 +1,7 @@ // @flow import { INatIconButton, UserIcon } from "components/SharedComponents"; import { Pressable } from "components/styledComponents"; +import NotificationsIconContainer from "navigation/BottomTabNavigator/NotificationsIconContainer"; import * as React from "react"; import colors from "styles/tailwindColors"; @@ -47,6 +48,16 @@ const NavButton = ( { height }; + const notificationProps = { + testID, + onPress, + accessibilityRole, + accessibilityLabel, + accessibilityHint, + width, + height + }; + if ( userIconUri ) { return ( + ); + } + return ( { + /* eslint-disable react/jsx-props-no-spreading */ + const sharedProps = { + testID, + onPress, + accessibilityRole, + accessibilityLabel, + accessibilityHint, + accessibilityState: { + selected: active, + expanded: active, + disabled: false + }, + width, + height + }; + + if ( unread ) { + return ( + + + + + + ); + } + + return ( + + ); +}; + +export default NotificationsIcon; diff --git a/src/navigation/BottomTabNavigator/NotificationsIconContainer.js b/src/navigation/BottomTabNavigator/NotificationsIconContainer.js new file mode 100644 index 000000000..0d29825cd --- /dev/null +++ b/src/navigation/BottomTabNavigator/NotificationsIconContainer.js @@ -0,0 +1,86 @@ +// @flow +import { fetchUnviewedObservationUpdatesCount } from "api/observations"; +import NotificationsIcon from "navigation/BottomTabNavigator/NotificationsIcon"; +import type { Node } from "react"; +import React, { useEffect, useState } from "react"; +import { + useAuthenticatedQuery, + useCurrentUser, + useInterval +} from "sharedHooks"; +import useStore from "stores/useStore"; + +type Props = { + testID: string, + icon: any, + onPress: any, + active:boolean, + accessibilityLabel: string, + accessibilityRole?: string, + accessibilityHint?: string, + size: number, + width?: number, + height?: number +}; + +const NotificationsIconContainer = ( { + testID, + size, + icon, + onPress, + active, + accessibilityLabel, + accessibilityHint, + accessibilityRole = "tab", + width, + height +}: Props ): Node => { + const [hasUnread, setHasUnread] = useState( false ); + const [numFetchIntervals, setNumFetchIntervals] = useState( 0 ); + const currentUser = useCurrentUser(); + const observationMarkedAsViewedAt = useStore( state => state.observationMarkedAsViewedAt ); + + const { data: unviewedUpdatesCount } = useAuthenticatedQuery( + [ + "notificationsCount", + // We want to check for notifications at a set interval, so this gets + // bumped at that interval + numFetchIntervals, + // We want to check for notifications when the user views an + // observation, because that might make the indicator go away + observationMarkedAsViewedAt + ], + optsWithAuth => fetchUnviewedObservationUpdatesCount( optsWithAuth ), + { + enabled: !!currentUser + } + ); + + // Show icon when there are unread updates + useEffect( () => { + setHasUnread( unviewedUpdatesCount > 0 ); + }, [unviewedUpdatesCount] ); + + // Fetch new updates count every minute by changing the request key + useInterval( () => { + setNumFetchIntervals( numFetchIntervals + 1 ); + }, 60_000 ); + + return ( + + ); +}; + +export default NotificationsIconContainer; diff --git a/src/sharedHooks/index.js b/src/sharedHooks/index.js index 5e4a2c378..54523c18a 100644 --- a/src/sharedHooks/index.js +++ b/src/sharedHooks/index.js @@ -7,6 +7,7 @@ export { default as useIconicTaxa } from "./useIconicTaxa"; export { default as useInfiniteNotificationsScroll } from "./useInfiniteNotificationsScroll"; export { default as useInfiniteObservationsScroll } from "./useInfiniteObservationsScroll"; export { default as useInfiniteScroll } from "./useInfiniteScroll"; +export { default as useInterval } from "./useInterval"; export { default as useIsConnected } from "./useIsConnected"; export { default as useLocalObservation } from "./useLocalObservation"; export { default as useLocalObservations } from "./useLocalObservations"; diff --git a/src/sharedHooks/useInterval.js b/src/sharedHooks/useInterval.js new file mode 100644 index 000000000..b07ee74e0 --- /dev/null +++ b/src/sharedHooks/useInterval.js @@ -0,0 +1,28 @@ +// @flow + +import { useEffect, useRef } from "react"; + +function useInterval( callback:Function, delay: number | null ) { + const savedCallback = useRef( null ); + + // Remember the latest callback function + useEffect( () => { + if ( delay === null ) return; + savedCallback.current = callback; + }, [callback, delay] ); + + // Set up the interval + useEffect( () => { + function tick() { + savedCallback.current(); + } + if ( delay === null ) { + return; + } + const id = setInterval( tick, delay ); + // eslint-disable-next-line consistent-return + return () => clearInterval( id ); + }, [delay] ); +} + +export default useInterval; diff --git a/src/stores/useStore.js b/src/stores/useStore.js index 4006dd759..b17792758 100644 --- a/src/stores/useStore.js +++ b/src/stores/useStore.js @@ -54,6 +54,9 @@ const useStore = create( set => ( { galleryUris: [], groupedPhotos: [], observations: [], + // Track when any obs was last marked as viewed so we know when to update + // the notifications indicator + observationMarkedAsViewedAt: null, originalCameraUrisMap: {}, photoEvidenceUris: [], savingPhoto: false, @@ -99,6 +102,9 @@ const useStore = create( set => ( { setGroupedPhotos: photos => set( { groupedPhotos: photos } ), + setObservationMarkedAsViewedAt: date => set( { + observationMarkedAsViewedAt: date + } ), setObservations: updatedObservations => set( state => ( { observations: updatedObservations, currentObservation: observationToJSON( updatedObservations[state.currentObservationIndex] ) diff --git a/tests/unit/components/BottomTabNavigator/CustomTabBar.test.js b/tests/unit/components/BottomTabNavigator/CustomTabBar.test.js index 1c6b95a36..31eb28e52 100644 --- a/tests/unit/components/BottomTabNavigator/CustomTabBar.test.js +++ b/tests/unit/components/BottomTabNavigator/CustomTabBar.test.js @@ -1,5 +1,6 @@ import { faker } from "@faker-js/faker"; import { screen } from "@testing-library/react-native"; +import initI18next from "i18n/initI18next"; import CustomTabBarContainer from "navigation/BottomTabNavigator/CustomTabBarContainer"; import React from "react"; import * as useCurrentUser from "sharedHooks/useCurrentUser"; @@ -17,39 +18,54 @@ jest.mock( "sharedHooks/useCurrentUser", ( ) => ( { default: () => undefined } ) ); +jest.mock( "sharedHooks/useAuthenticatedQuery", () => ( { + __esModule: true, + default: () => ( { + data: 0 + } ) +} ) ); + describe( "CustomTabBar", () => { - it( "should render correctly", () => { + beforeAll( async ( ) => { + await initI18next( ); + } ); + + beforeEach( ( ) => { + jest.useFakeTimers(); + } ); + + it( "should render correctly", async () => { renderComponent( ); - expect( screen ).toMatchSnapshot(); + await expect( screen ).toMatchSnapshot(); } ); - it( "should not have accessibility errors", () => { + it( "should not have accessibility errors", async () => { const tabBar = ; - expect( tabBar ).toBeAccessible(); + await expect( tabBar ).toBeAccessible(); } ); - it( "should display person icon while user is logged out", () => { + it( "should display person icon while user is logged out", async () => { renderComponent( ); const personIcon = screen.getByTestId( "NavButton.personIcon" ); - expect( personIcon ).toBeVisible( ); + await expect( personIcon ).toBeVisible( ); } ); - it( "should display avatar while user is logged in", () => { + it( "should display avatar while user is logged in", async () => { jest.spyOn( useCurrentUser, "default" ).mockImplementation( () => mockUser ); renderComponent( ); const avatar = screen.getByTestId( "UserIcon.photo" ); - expect( avatar ).toBeVisible( ); + await expect( avatar ).toBeVisible( ); } ); - it( "should display person icon when connectivity is low", ( ) => { + it( "should display person icon when connectivity is low", async ( ) => { jest.spyOn( useIsConnected, "default" ).mockImplementation( () => false ); renderComponent( ); const personIcon = screen.getByTestId( "NavButton.personIcon" ); - expect( personIcon ).toBeVisible( ); + await expect( personIcon ).toBeVisible( ); } ); } ); diff --git a/tests/unit/components/BottomTabNavigator/__snapshots__/CustomTabBar.test.js.snap b/tests/unit/components/BottomTabNavigator/__snapshots__/CustomTabBar.test.js.snap index d57355328..8bc63fa83 100644 --- a/tests/unit/components/BottomTabNavigator/__snapshots__/CustomTabBar.test.js.snap +++ b/tests/unit/components/BottomTabNavigator/__snapshots__/CustomTabBar.test.js.snap @@ -57,8 +57,8 @@ exports[`CustomTabBar should render correctly 1`] = ` } > Date: Thu, 8 Feb 2024 15:22:59 -0600 Subject: [PATCH 03/24] Refactor StickyView (#1119) * Prevent gap at the bottom of StickyView * Renamed StickyView to ScrollableWithStickyHeader * Refactored ScrollableWithStickyHeader so that it manages the height it sticks at and the scroll state * ScrollableWithStickyHeader explicitly takes two rendering functions as props, hopefully making what it does a little clearer * Added some comments attempting to explain what this component does and why we're not taking other approaches to sticky headers Closes #997 --- .../MyObservations/MyObservations.js | 128 ++++++--------- .../ScrollableWithStickyHeader.js | 154 ++++++++++++++++++ src/components/SharedComponents/StickyView.js | 85 ---------- src/components/SharedComponents/index.js | 2 +- 4 files changed, 204 insertions(+), 165 deletions(-) create mode 100644 src/components/SharedComponents/ScrollableWithStickyHeader.js delete mode 100644 src/components/SharedComponents/StickyView.js diff --git a/src/components/MyObservations/MyObservations.js b/src/components/MyObservations/MyObservations.js index 4b7a79172..4b0163b38 100644 --- a/src/components/MyObservations/MyObservations.js +++ b/src/components/MyObservations/MyObservations.js @@ -1,10 +1,12 @@ // @flow import Header from "components/MyObservations/Header"; -import { ObservationsFlashList, StickyView, ViewWrapper } from "components/SharedComponents"; -import { View } from "components/styledComponents"; +import { + ObservationsFlashList, + ScrollableWithStickyHeader, + ViewWrapper +} from "components/SharedComponents"; import type { Node } from "react"; -import React, { useRef, useState } from "react"; -import { Animated, Platform } from "react-native"; +import React from "react"; import Announcements from "./Announcements"; import LoginSheet from "./LoginSheet"; @@ -45,80 +47,48 @@ const MyObservations = ( { uploadMultipleObservations, uploadSingleObservation, uploadState -}: Props ): Node => { - const [heightAboveToolbar, setHeightAboveToolbar] = useState( 0 ); - - const [hideHeaderCard, setHideHeaderCard] = useState( false ); - const [yValue, setYValue] = useState( 0 ); - // basing collapsible sticky header code off the example in this article - // https://medium.com/swlh/making-a-collapsible-sticky-header-animations-with-react-native-6ad7763875c3 - const scrollY = useRef( new Animated.Value( 0 ) ); - - const handleScroll = Animated.event( - [ - { - nativeEvent: { - contentOffset: { y: scrollY.current } - } - } - ], - { - listener: ( { nativeEvent } ) => { - const { y } = nativeEvent.contentOffset; - const hide = yValue < y; - // there's likely a better way to do this, but for now fading out - // the content that goes under the status bar / safe area notch on iOS - if ( Platform.OS !== "ios" ) { return; } - if ( hide !== hideHeaderCard ) { - setHideHeaderCard( hide ); - setYValue( y ); - } - }, - useNativeDriver: true - } - ); - - return ( - <> - - - -
- o.isValid() )} - handleScroll={handleScroll} - hideLoadingWheel={!isFetchingNextPage || !currentUser} - isFetchingNextPage={isFetchingNextPage} - isOnline={isOnline} - layout={layout} - onEndReached={onEndReached} - showObservationsEmptyScreen - status={status} - testID="MyObservationsAnimatedList" - uploadSingleObservation={uploadSingleObservation} - uploadState={uploadState} - renderHeader={( - - )} - /> - - - - {showLoginSheet && } - - ); -}; +}: Props ): Node => ( + <> + + ( +
+ )} + renderScrollable={onSroll => ( + o.isValid() )} + handleScroll={onSroll} + hideLoadingWheel={!isFetchingNextPage || !currentUser} + isFetchingNextPage={isFetchingNextPage} + isOnline={isOnline} + layout={layout} + onEndReached={onEndReached} + showObservationsEmptyScreen + status={status} + testID="MyObservationsAnimatedList" + uploadSingleObservation={uploadSingleObservation} + uploadState={uploadState} + renderHeader={( + + )} + /> + )} + /> + + {showLoginSheet && } + +); export default MyObservations; diff --git a/src/components/SharedComponents/ScrollableWithStickyHeader.js b/src/components/SharedComponents/ScrollableWithStickyHeader.js new file mode 100644 index 000000000..3e9c457e2 --- /dev/null +++ b/src/components/SharedComponents/ScrollableWithStickyHeader.js @@ -0,0 +1,154 @@ +// @flow + +// ScrollableWithStickyHeader renders a scrollable view (e.g. ScrollView or +// FlashList) with a header component above it. The header component will +// stick to the top of the screen when scrolled to a particular y value +// (stickyAt) +// +// To use this, you need to give it two functions, one to render the header +// and the other to render the scrollable. renderHeader takes a single +// argument, the setStickyAt function, that sets the scroll offset at which +// the header sticks (this is probably dependent on the height of the +// rendered layout). +// +// renderScrollable takes a single argument, the onScroll callback, which +// should be passed to the scrollable's onScroll prop, and/or get called with +// the same event +// +// Some background: the easiest way to set a sticky header header with a +// ScrollView is the stickyHeaderIndices prop, but that doesn't work quite as +// expected with FlashList because the only children of the underlying +// ScrollView in FlashList are the items themselves. You can render the +// header as the first item and then make that stick, but you run into +// problems when you try to show multiple columns and your header component +// gets confined to the column width. The solution here uses an offset +// transform to achive something similar. It also assumes it occupies full +// height +// +// In case git loses some of the history, this approach was original authored +// by @albullington, with modifications by @budowski to deal with overscroll +// problems + +import { View } from "components/styledComponents"; +import type { Node } from "react"; +import React, { + useEffect, + useMemo, + useRef, + useState +} from "react"; +import { Animated } from "react-native"; +import { useDeviceOrientation } from "sharedHooks"; + +const { diffClamp } = Animated; + +type Props = { + renderHeader: Function, + renderScrollable: Function +}; + +const ScrollableWithStickyHeader = ( { + renderHeader, + renderScrollable +}: Props ): Node => { + const { + isTablet, + screenHeight, + screenWidth + } = useDeviceOrientation( ); + // eslint-disable-next-line no-unused-vars + const [scrollPosition, setScrollPosition] = useState( 0 ); + + const [stickyAt, setStickyAt] = useState( 0 ); + + // basing collapsible sticky header code off the example in this article + // https://medium.com/swlh/making-a-collapsible-sticky-header-animations-with-react-native-6ad7763875c3 + const scrollY = useRef( new Animated.Value( 0 ) ); + + const onScroll = Animated.event( + [ + { + nativeEvent: { + contentOffset: { y: scrollY.current } + } + } + ], + { + useNativeDriver: true + } + ); + + // On Android, the scroll view offset is a double (not an integer), and interpolation shouldn't be + // one-to-one, which causes a jittery header while slow scrolling (see issue #634). + // See here as well: https://stackoverflow.com/a/60898411/1233767 + const scrollYClamped = diffClamp( + scrollY.current, + 0, + stickyAt * 2 + ); + + // Same as comment above (see here: https://stackoverflow.com/a/60898411/1233767) + const offsetForHeader = scrollYClamped.interpolate( { + inputRange: [0, stickyAt * 2], + // $FlowIgnore + outputRange: [0, -stickyAt] + } ); + + useEffect( () => { + const currentScrollY = scrollY.current; + + if ( scrollY.current ) { + // #560 - We use a state variable to force rendering of the component - since on iOS, + // you can over scroll a list when scrolling it to the top (creating a bounce effect), + // and sometimes, even though offsetForHeader gets updated correctly, it doesn't cause + // a re-render of the component, and then the Animated.View's translateY property doesn't + // get updated with the latest value of offsetForHeader (this causes a weird view, where the + // top header if semi cut off, even though the user scrolled the list all the way to the top). + // So by changing a state variable of the component, every time the user scroll the list -> we + // make sure the component always gets re-rendered. + currentScrollY.addListener( ( { value } ) => { + if ( value <= 0 ) { + // Only force refresh of the state in case of an over-scroll (bounce effect) + setScrollPosition( value ); + } + } ); + } + + return () => { + currentScrollY.removeAllListeners(); + }; + }, [scrollY] ); + + const contentHeight = useMemo( + ( ) => ( + isTablet + ? screenHeight + : Math.max( screenWidth, screenHeight ) + ), + [isTablet, screenHeight, screenWidth] + ); + + return ( + // Note that we want to occupy full height but hide the overflow because + // we are intentionally setting the height of the Animated.View to exceed + // the height of this parent view. We want the parent view to be laid out + // nicely with its peers, not flow off the screen. + + + {renderHeader( setStickyAt )} + {renderScrollable( onScroll )} + + + ); +}; + +export default ScrollableWithStickyHeader; diff --git a/src/components/SharedComponents/StickyView.js b/src/components/SharedComponents/StickyView.js deleted file mode 100644 index 8dc04e317..000000000 --- a/src/components/SharedComponents/StickyView.js +++ /dev/null @@ -1,85 +0,0 @@ -// @flow -import type { Node } from "react"; -import React, { useEffect, useState } from "react"; -import { Animated } from "react-native"; -import { useDeviceOrientation } from "sharedHooks"; - -const { diffClamp } = Animated; - -type Props = { - children: any, - scrollY: any, - heightAboveView: number -}; - -const StickyView = ( { - children, - scrollY, - heightAboveView -}: Props ): Node => { - const { - isTablet, - screenHeight, - screenWidth - } = useDeviceOrientation( ); - // eslint-disable-next-line no-unused-vars - const [scrollPosition, setScrollPosition] = useState( 0 ); - - // On Android, the scroll view offset is a double (not an integer), and interpolation shouldn't be - // one-to-one, which causes a jittery header while slow scrolling (see issue #634). - // See here as well: https://stackoverflow.com/a/60898411/1233767 - const scrollYClamped = diffClamp( - scrollY.current, - 0, - heightAboveView * 2 - ); - - // Same as comment above (see here: https://stackoverflow.com/a/60898411/1233767) - const offsetForHeader = scrollYClamped.interpolate( { - inputRange: [0, heightAboveView * 2], - // $FlowIgnore - outputRange: [0, -heightAboveView] - } ); - - useEffect( () => { - const currentScrollY = scrollY.current; - - if ( scrollY.current ) { - // #560 - We use a state variable to force rendering of the component - since on iOS, - // you can over scroll a list when scrolling it to the top (creating a bounce effect), - // and sometimes, even though offsetForHeader gets updated correctly, it doesn't cause - // a re-render of the component, and then the Animated.View's translateY property doesn't - // get updated with the latest value of offsetForHeader (this causes a weird view, where the - // top header if semi cut off, even though the user scrolled the list all the way to the top). - // So by changing a state variable of the component, every time the user scroll the list -> we - // make sure the component always gets re-rendered. - currentScrollY.addListener( ( { value } ) => { - if ( value <= 0 ) { - // Only force refresh of the state in case of an over-scroll (bounce effect) - setScrollPosition( value ); - } - } ); - } - - return () => { - currentScrollY.removeAllListeners(); - }; - }, [scrollY] ); - - return ( - - {children} - - ); -}; - -export default StickyView; diff --git a/src/components/SharedComponents/index.js b/src/components/SharedComponents/index.js index 8422bda3d..9950b99f3 100644 --- a/src/components/SharedComponents/index.js +++ b/src/components/SharedComponents/index.js @@ -36,6 +36,7 @@ export { default as PhotoCount } from "./PhotoCount"; export { default as ProjectListItem } from "./ProjectListItem"; export { default as QualityGradeStatus } from "./QualityGradeStatus/QualityGradeStatus"; export { default as RadioButtonRow } from "./RadioButtonRow"; +export { default as ScrollableWithStickyHeader } from "./ScrollableWithStickyHeader"; export { default as ScrollViewWrapper } from "./ScrollViewWrapper"; export { default as SearchBar } from "./SearchBar"; export { default as BottomSheet } from "./Sheets/BottomSheet"; @@ -45,7 +46,6 @@ export { default as TextInputSheet } from "./Sheets/TextInputSheet"; export { default as TextSheet } from "./Sheets/TextSheet"; export { default as WarningSheet } from "./Sheets/WarningSheet"; export { default as StickyToolbar } from "./StickyToolbar"; -export { default as StickyView } from "./StickyView"; export { default as Tabs } from "./Tabs/Tabs"; export { default as TaxonResult } from "./TaxonResult"; export { default as Body1 } from "./Typography/Body1"; From 824632040b292ddce1a07ebae36463842152c499 Mon Sep 17 00:00:00 2001 From: Ken-ichi Ueda Date: Thu, 8 Feb 2024 14:56:45 -0800 Subject: [PATCH 04/24] Bugfix: notifications page was erroring out while signed out --- .../Notifications/NotificationsContainer.js | 7 +-- .../Notifications/NotificationsList.js | 47 ++++++-------- .../NotificationsIconContainer.js | 8 ++- .../useInfiniteNotificationsScroll.js | 62 +++++++------------ src/sharedHooks/useIsConnected.js | 1 + 5 files changed, 51 insertions(+), 74 deletions(-) diff --git a/src/components/Notifications/NotificationsContainer.js b/src/components/Notifications/NotificationsContainer.js index ce1197cbc..e35e97d4a 100644 --- a/src/components/Notifications/NotificationsContainer.js +++ b/src/components/Notifications/NotificationsContainer.js @@ -14,8 +14,8 @@ const NotificationsContainer = (): Node => { notifications, isFetchingNextPage, fetchNextPage, - status, - refetch + refetch, + isInitialLoading } = useInfiniteNotificationsScroll( ); useEffect( ( ) => { @@ -30,9 +30,8 @@ const NotificationsContainer = (): Node => { ); }; diff --git a/src/components/Notifications/NotificationsList.js b/src/components/Notifications/NotificationsList.js index 916687b46..841c32057 100644 --- a/src/components/Notifications/NotificationsList.js +++ b/src/components/Notifications/NotificationsList.js @@ -3,10 +3,7 @@ import { FlashList } from "@shopify/flash-list"; import InfiniteScrollLoadingWheel from "components/MyObservations/InfiniteScrollLoadingWheel"; import NotificationsListItem from "components/Notifications/NotificationsListItem"; -import { - ActivityIndicator, - Body2 -} from "components/SharedComponents"; +import { Body2 } from "components/SharedComponents"; import { View } from "components/styledComponents"; import type { Node } from "react"; import React, { useCallback } from "react"; @@ -16,15 +13,17 @@ import { useTranslation } from "sharedHooks"; const AnimatedFlashList = Animated.createAnimatedComponent( FlashList ); type Props = { - data: Object, - isOnline: boolean, - status: string, - onEndReached: Function, - isFetchingNextPage?: boolean - }; + data: Object, + isLoading?: boolean, + isOnline: boolean, + onEndReached: Function +}; const NotificationsList = ( { - data, isOnline, status, onEndReached, isFetchingNextPage + data, + isOnline, + onEndReached, + isLoading }: Props ): Node => { const { t } = useTranslation( ); @@ -36,36 +35,26 @@ const NotificationsList = ( { const renderFooter = useCallback( ( ) => ( - ), [isFetchingNextPage, isOnline] ); + ), [isLoading, isOnline] ); const renderEmptyComponent = useCallback( ( ) => { - const showEmptyScreen = ( isOnline ) + if ( isLoading ) return null; + return isOnline ? ( - + {t( "No-Notifications-Found" )} ) : ( - + {t( "Offline-No-Notifications" )} ); - - return ( ( status === "loading" ) ) - ? ( - - - - ) - : showEmptyScreen; - }, [isOnline, status, t] ); + }, [isLoading, isOnline, t] ); return ( @@ -76,7 +65,7 @@ const NotificationsList = ( { ItemSeparatorComponent={renderItemSeparator} estimatedItemSize={20} onEndReached={onEndReached} - refreshing={isFetchingNextPage} + refreshing={isLoading} ListFooterComponent={renderFooter} ListEmptyComponent={renderEmptyComponent} /> diff --git a/src/navigation/BottomTabNavigator/NotificationsIconContainer.js b/src/navigation/BottomTabNavigator/NotificationsIconContainer.js index 0d29825cd..af66a9a3c 100644 --- a/src/navigation/BottomTabNavigator/NotificationsIconContainer.js +++ b/src/navigation/BottomTabNavigator/NotificationsIconContainer.js @@ -6,7 +6,8 @@ import React, { useEffect, useState } from "react"; import { useAuthenticatedQuery, useCurrentUser, - useInterval + useInterval, + useIsConnected } from "sharedHooks"; import useStore from "stores/useStore"; @@ -37,7 +38,8 @@ const NotificationsIconContainer = ( { }: Props ): Node => { const [hasUnread, setHasUnread] = useState( false ); const [numFetchIntervals, setNumFetchIntervals] = useState( 0 ); - const currentUser = useCurrentUser(); + const currentUser = useCurrentUser( ); + const isOnline = useIsConnected( ); const observationMarkedAsViewedAt = useStore( state => state.observationMarkedAsViewedAt ); const { data: unviewedUpdatesCount } = useAuthenticatedQuery( @@ -52,7 +54,7 @@ const NotificationsIconContainer = ( { ], optsWithAuth => fetchUnviewedObservationUpdatesCount( optsWithAuth ), { - enabled: !!currentUser + enabled: !!currentUser && !!isOnline } ); diff --git a/src/sharedHooks/useInfiniteNotificationsScroll.js b/src/sharedHooks/useInfiniteNotificationsScroll.js index 0d2afb8e7..8a22fdd1a 100644 --- a/src/sharedHooks/useInfiniteNotificationsScroll.js +++ b/src/sharedHooks/useInfiniteNotificationsScroll.js @@ -4,34 +4,22 @@ import { useInfiniteQuery } from "@tanstack/react-query"; import { fetchObservationUpdates } from "api/observations"; import { getJWT } from "components/LoginSignUp/AuthenticationService"; import { flatten } from "lodash"; -import { log } from "sharedHelpers/logger"; import { reactQueryRetry } from "sharedHelpers/logging"; import { useCurrentUser } from "sharedHooks"; -const logger = log.extend( "useInfiniteNotificationsScroll" ); +const BASE_PARAMS = { + observations_by: "owner", + fields: "all", + per_page: 30, + ttl: -1, + page: 1 +}; const useInfiniteNotificationsScroll = ( ): Object => { const currentUser = useCurrentUser( ); - // Request params for fetching unviewed updates - const baseParams = { - observations_by: "owner", - fields: "all", - per_page: 30, - ttl: -1 - }; - - const queryKey = ["useInfiniteNotificationsScroll", "fetchNotifications"]; - - const { - data: notifications, - isFetchingNextPage, - fetchNextPage, - status, - refetch - } = useInfiniteQuery( { - // eslint-disable-next-line - queryKey, + const infQueryResult = useInfiniteQuery( { + queryKey: ["useInfiniteNotificationsScroll"], keepPreviousData: false, queryFn: async ( { pageParam } ) => { const apiToken = await getJWT( ); @@ -39,35 +27,33 @@ const useInfiniteNotificationsScroll = ( ): Object => { api_token: apiToken }; + const params = { ...BASE_PARAMS }; + if ( pageParam ) { - // $FlowIgnore - baseParams.page = pageParam; + params.page = pageParam; } else { - // $FlowIgnore - baseParams.page = 0; + params.page = 1; } - const response = await fetchObservationUpdates( baseParams, options ); + const response = await fetchObservationUpdates( params, options ); return response; }, getNextPageParam: ( lastPage, allPages ) => ( lastPage.length > 0 ? allPages.length + 1 : undefined ), - enabled: true, - retry: ( failureCount, error ) => reactQueryRetry( failureCount, error, { - beforeRetry: ( ) => logger.error( error ) - } ) + enabled: !!currentUser, + retry: reactQueryRetry } ); - return currentUser - && { - isFetchingNextPage, - fetchNextPage, - notifications: flatten( notifications?.pages ), - status, - refetch - }; + return { + ...infQueryResult, + // Disable fetchNextPage if signed out + fetchNextPage: currentUser + ? infQueryResult.fetchNextPage + : ( ) => { }, + notifications: flatten( infQueryResult?.data?.pages ) + }; }; export default useInfiniteNotificationsScroll; diff --git a/src/sharedHooks/useIsConnected.js b/src/sharedHooks/useIsConnected.js index 8688296ed..858607731 100644 --- a/src/sharedHooks/useIsConnected.js +++ b/src/sharedHooks/useIsConnected.js @@ -1,6 +1,7 @@ // @flow import { useNetInfo } from "@react-native-community/netinfo"; +// Note that a return value of null means the state is unknown const useIsConnected = ( ): boolean => { const { isInternetReachable } = useNetInfo( ); return isInternetReachable; From 62343c2eb341ccd9d7ab79c06f6fa0be04bf8829 Mon Sep 17 00:00:00 2001 From: Ken-ichi Ueda Date: Thu, 8 Feb 2024 15:24:33 -0800 Subject: [PATCH 05/24] Bugfix: navigating to ObsDetails and back from Notifications took you to MyObs --- src/components/Notifications/NotificationsContainer.js | 6 +++--- src/components/Notifications/NotificationsList.js | 5 +---- src/i18n/l10n/en.ftl | 2 ++ src/i18n/l10n/en.ftl.json | 1 + src/i18n/strings.ftl | 2 ++ src/navigation/BottomTabNavigator/CustomTabBarContainer.js | 5 ++++- .../StackNavigators/ObservationsStackNavigator.js | 6 ++++++ 7 files changed, 19 insertions(+), 8 deletions(-) diff --git a/src/components/Notifications/NotificationsContainer.js b/src/components/Notifications/NotificationsContainer.js index e35e97d4a..6b1fbfdc0 100644 --- a/src/components/Notifications/NotificationsContainer.js +++ b/src/components/Notifications/NotificationsContainer.js @@ -12,10 +12,10 @@ const NotificationsContainer = (): Node => { const { notifications, - isFetchingNextPage, fetchNextPage, refetch, - isInitialLoading + isInitialLoading, + isFetching } = useInfiniteNotificationsScroll( ); useEffect( ( ) => { @@ -31,7 +31,7 @@ const NotificationsContainer = (): Node => { data={notifications} onEndReached={fetchNextPage} isOnline={isOnline} - isLoading={isInitialLoading || isFetchingNextPage} + isLoading={isInitialLoading || isFetching} /> ); }; diff --git a/src/components/Notifications/NotificationsList.js b/src/components/Notifications/NotificationsList.js index 841c32057..316adbb46 100644 --- a/src/components/Notifications/NotificationsList.js +++ b/src/components/Notifications/NotificationsList.js @@ -7,11 +7,8 @@ import { Body2 } from "components/SharedComponents"; import { View } from "components/styledComponents"; import type { Node } from "react"; import React, { useCallback } from "react"; -import { Animated } from "react-native"; import { useTranslation } from "sharedHooks"; -const AnimatedFlashList = Animated.createAnimatedComponent( FlashList ); - type Props = { data: Object, isLoading?: boolean, @@ -58,7 +55,7 @@ const NotificationsList = ( { return ( - item.id} renderItem={renderItem} diff --git a/src/i18n/l10n/en.ftl b/src/i18n/l10n/en.ftl index a0c1df829..852ec362b 100644 --- a/src/i18n/l10n/en.ftl +++ b/src/i18n/l10n/en.ftl @@ -589,6 +589,8 @@ NOTES = NOTES Notifications = Notifications +NOTIFICATIONS = NOTIFICATIONS + # notification when someone adds an identification to your observation notifications-user-added-identification-to-observation-by-you = <0>{$userName} added an identification to an observation by you diff --git a/src/i18n/l10n/en.ftl.json b/src/i18n/l10n/en.ftl.json index 1a624a25f..e1b9366f4 100644 --- a/src/i18n/l10n/en.ftl.json +++ b/src/i18n/l10n/en.ftl.json @@ -362,6 +362,7 @@ "val": "NOTES" }, "Notifications": "Notifications", + "NOTIFICATIONS": "NOTIFICATIONS", "notifications-user-added-identification-to-observation-by-you": { "comment": "notification when someone adds an identification to your observation", "val": "<0>{ $userName } added an identification to an observation by you" diff --git a/src/i18n/strings.ftl b/src/i18n/strings.ftl index a0c1df829..852ec362b 100644 --- a/src/i18n/strings.ftl +++ b/src/i18n/strings.ftl @@ -589,6 +589,8 @@ NOTES = NOTES Notifications = Notifications +NOTIFICATIONS = NOTIFICATIONS + # notification when someone adds an identification to your observation notifications-user-added-identification-to-observation-by-you = <0>{$userName} added an identification to an observation by you diff --git a/src/navigation/BottomTabNavigator/CustomTabBarContainer.js b/src/navigation/BottomTabNavigator/CustomTabBarContainer.js index 77d1b9abf..34791401a 100644 --- a/src/navigation/BottomTabNavigator/CustomTabBarContainer.js +++ b/src/navigation/BottomTabNavigator/CustomTabBarContainer.js @@ -84,7 +84,10 @@ const CustomTabBarContainer = ( { navigation, isOnline }: Props ): Node => { height: 44, size: 32, onPress: ( ) => { - navigation.navigate( "Notifications" ); + navigation.reset( { + index: 0, + routes: [{ name: "Notifications" }] + } ); setActiveTab( NOTIFICATIONS_SCREEN_ID ); }, active: NOTIFICATIONS_SCREEN_ID === activeTab diff --git a/src/navigation/StackNavigators/ObservationsStackNavigator.js b/src/navigation/StackNavigators/ObservationsStackNavigator.js index 60e301383..25dc29235 100644 --- a/src/navigation/StackNavigators/ObservationsStackNavigator.js +++ b/src/navigation/StackNavigators/ObservationsStackNavigator.js @@ -31,6 +31,7 @@ const taxonSearchTitle = () => {t( "SEARCH-TAXA" )}; const locationSearchTitle = () => {t( "SEARCH-LOCATION" )}; const userSearchTitle = () => {t( "SEARCH-USER" )}; const projectSearchTitle = () => {t( "SEARCH-PROJECT" )}; +const notificationsTitle = ( ) => {t( "NOTIFICATIONS" )}; const Stack = createNativeStackNavigator( ); @@ -52,6 +53,11 @@ const ObservationsStackNavigator = ( ): Node => ( Date: Thu, 8 Feb 2024 15:26:30 -0800 Subject: [PATCH 06/24] Stop polluting the log with info about realm.path --- src/components/App.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/components/App.js b/src/components/App.js index f84ba32f9..753c8dfec 100644 --- a/src/components/App.js +++ b/src/components/App.js @@ -46,8 +46,6 @@ type Props = { const App = ( { children }: Props ): Node => { const navigation = useNavigation( ); const realm = useRealm( ); - logger.debug( "[App.js] Need to open Realm in another app?" ); - logger.debug( "[App.js] realm.path: ", realm?.path ); const currentUser = useCurrentUser( ); useIconicTaxa( { reload: true } ); const { i18n } = useTranslation( ); @@ -169,6 +167,13 @@ const App = ( { children }: Props ): Node => { fetchInitialUrl( ); }, [navigateConfirmedUser] ); + useEffect( ( ) => { + if ( realm?.path ) { + logger.debug( "[App.js] Need to open Realm in another app?" ); + logger.debug( "[App.js] realm.path: ", realm.path ); + } + }, [realm?.path] ); + // this children prop is here for the sake of testing with jest // normally we would never do this in code return children || ; From f8878bdda5c180ee99bd376e7f6082299eaebc7d Mon Sep 17 00:00:00 2001 From: Ken-ichi Ueda Date: Thu, 8 Feb 2024 15:43:04 -0800 Subject: [PATCH 07/24] Restore ObsDetails nav header gradient --- src/components/ObsDetails/ObsDetailsHeader.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/components/ObsDetails/ObsDetailsHeader.js b/src/components/ObsDetails/ObsDetailsHeader.js index d8e65ef6a..351565115 100644 --- a/src/components/ObsDetails/ObsDetailsHeader.js +++ b/src/components/ObsDetails/ObsDetailsHeader.js @@ -10,6 +10,7 @@ import { } from "components/styledComponents"; import type { Node } from "react"; import React from "react"; +import DeviceInfo from "react-native-device-info"; import { useTranslation } from "sharedHooks"; @@ -18,6 +19,8 @@ import colors from "styles/tailwindColors"; import HeaderKebabMenu from "./HeaderKebabMenu"; +const isTablet = DeviceInfo.isTablet( ); + type Props = { belongsToCurrentUser?: boolean, observation: Object, @@ -43,7 +46,12 @@ const ObsDetailsHeader = ( { "justify-between", "h-10" )} - colors={["rgba(0,0,0,0.1)", "transparent"]} + colors={[ + isTablet + ? "rgba(0,0,0,0.1)" + : "rgba(0,0,0,0.6)", + "transparent" + ]} > { From 4b929a8e6f8b53eca3912f0cd72bde6b09edea92 Mon Sep 17 00:00:00 2001 From: Ken-ichi Ueda Date: Thu, 8 Feb 2024 18:55:16 -0800 Subject: [PATCH 08/24] v0.22.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 04f2c2dfc..b4559266b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "inaturalistreactnative", - "version": "0.21.1", + "version": "0.22.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "inaturalistreactnative", - "version": "0.21.1", + "version": "0.22.0", "hasInstallScript": true, "dependencies": { "@bam.tech/react-native-image-resizer": "^3.0.7", diff --git a/package.json b/package.json index 1347c1173..00586d0a4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "inaturalistreactnative", - "version": "0.21.1", + "version": "0.22.0", "private": true, "scripts": { "android": "react-native run-android", From bf3f755c31c1cbdeb82d468054450f41fbaf600c Mon Sep 17 00:00:00 2001 From: Ken-ichi Ueda Date: Thu, 8 Feb 2024 18:58:22 -0800 Subject: [PATCH 09/24] Stop using Android draft releases --- Gemfile.lock | 20 ++++++++++---------- fastlane/Fastfile | 2 -- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 2b539dab6..95e48831a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -16,17 +16,17 @@ GEM artifactory (3.0.15) atomos (0.1.3) aws-eventstream (1.3.0) - aws-partitions (1.883.0) - aws-sdk-core (3.190.3) + aws-partitions (1.888.0) + aws-sdk-core (3.191.1) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.651.0) aws-sigv4 (~> 1.8) jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.76.0) - aws-sdk-core (~> 3, >= 3.188.0) + aws-sdk-kms (1.77.0) + aws-sdk-core (~> 3, >= 3.191.0) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.142.0) - aws-sdk-core (~> 3, >= 3.189.0) + aws-sdk-s3 (1.143.0) + aws-sdk-core (~> 3, >= 3.191.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.8) aws-sigv4 (1.8.0) @@ -210,7 +210,7 @@ GEM minitest (5.21.2) molinillo (0.8.0) multi_json (1.15.0) - multipart-post (2.3.0) + multipart-post (2.4.0) nanaimo (0.3.0) nap (1.1.0) naturally (2.2.1) @@ -258,7 +258,7 @@ GEM uber (0.1.0) unicode-display_width (2.5.0) word_wrap (1.0.0) - xcodeproj (1.23.0) + xcodeproj (1.24.0) CFPropertyList (>= 2.3.3, < 4.0) atomos (~> 0.1.3) claide (>= 1.0.2, < 2.0) @@ -275,7 +275,7 @@ PLATFORMS DEPENDENCIES activesupport (>= 6.1.7.3, < 7.1.0) - cocoapods (>= 1.11.3) + cocoapods (>= 1.13, < 1.15) fastlane nokogiri @@ -283,4 +283,4 @@ RUBY VERSION ruby 2.7.5p203 BUNDLED WITH - 2.4.13 + 2.3.9 diff --git a/fastlane/Fastfile b/fastlane/Fastfile index c88656c73..97a743d4f 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -402,8 +402,6 @@ lane :internal do upload_to_play_store( aab: aab_path, track: "internal", - # TODO promote build to closed testing and jump through play store hoops - release_status: "draft", version_name: last_tag ) From 7f7097ea6b91ad4f7e34b85928f7da8e6b73290b Mon Sep 17 00:00:00 2001 From: Ken-ichi Ueda Date: Thu, 8 Feb 2024 19:00:46 -0800 Subject: [PATCH 10/24] v0.22.0+71 --- android/app/build.gradle | 4 ++-- fastlane/metadata/android/en-US/changelogs/71.txt | 14 ++++++++++++++ .../project.pbxproj | 8 ++++---- ios/iNaturalistReactNative/Info.plist | 4 ++-- ios/iNaturalistReactNativeTests/Info.plist | 4 ++-- 5 files changed, 24 insertions(+), 10 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/71.txt diff --git a/android/app/build.gradle b/android/app/build.gradle index c51e8dcc3..375ecad01 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -123,8 +123,8 @@ android { applicationId "org.inaturalist.iNaturalistMobile" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 70 - versionName "0.21.1" + versionCode 71 + versionName "0.22.0" setProperty("archivesBaseName", applicationId + "-v" + versionName + "+" + versionCode) manifestPlaceholders = [ GMAPS_API_KEY:project.env.get("GMAPS_API_KEY") ] // Detox Android setup diff --git a/fastlane/metadata/android/en-US/changelogs/71.txt b/fastlane/metadata/android/en-US/changelogs/71.txt new file mode 100644 index 000000000..047c4655b --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/71.txt @@ -0,0 +1,14 @@ +NEW +* Explore grid/list/map preference is now sticky +* Tab bar shows indicator when you have unviewed updates + +FIXED +* Iconic taxon chooser resets when importing a batch +* No flicker on log in +* Notifications navigation improvements +* Agree from Explore agrees with the ident taxon +* Notifications for comments display as comments +* Fixed photo count layout problems after scrolling +* LocationPicker shows actual coordinates +* Zoom resets when returning to camera +* Top-cropped photo on ObsDetail diff --git a/ios/iNaturalistReactNative.xcodeproj/project.pbxproj b/ios/iNaturalistReactNative.xcodeproj/project.pbxproj index 889906b3e..00e89528f 100644 --- a/ios/iNaturalistReactNative.xcodeproj/project.pbxproj +++ b/ios/iNaturalistReactNative.xcodeproj/project.pbxproj @@ -627,7 +627,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = iNaturalistReactNative/iNaturalistReactNative.entitlements; - CURRENT_PROJECT_VERSION = 70; + CURRENT_PROJECT_VERSION = 71; DEVELOPMENT_TEAM = N5J7L4P93Z; ENABLE_BITCODE = NO; "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64; @@ -748,7 +748,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = iNaturalistReactNative/iNaturalistReactNativeRelease.entitlements; - CURRENT_PROJECT_VERSION = 70; + CURRENT_PROJECT_VERSION = 71; DEVELOPMENT_TEAM = N5J7L4P93Z; "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64; HEADER_SEARCH_PATHS = ( @@ -1016,7 +1016,7 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = "iNaturalistReactNative-ShareExtension/iNaturalistReactNative-ShareExtension.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 70; + CURRENT_PROJECT_VERSION = 71; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = N5J7L4P93Z; "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "i386 arm64"; @@ -1061,7 +1061,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 70; + CURRENT_PROJECT_VERSION = 71; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = N5J7L4P93Z; "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "i386 arm64"; diff --git a/ios/iNaturalistReactNative/Info.plist b/ios/iNaturalistReactNative/Info.plist index 8d35a7b6f..593122a87 100644 --- a/ios/iNaturalistReactNative/Info.plist +++ b/ios/iNaturalistReactNative/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.21.1 + 0.22.0 CFBundleSignature ???? CFBundleURLTypes @@ -32,7 +32,7 @@ CFBundleVersion - 70 + 71 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/iNaturalistReactNativeTests/Info.plist b/ios/iNaturalistReactNativeTests/Info.plist index 0894cf602..e2d2edc2c 100644 --- a/ios/iNaturalistReactNativeTests/Info.plist +++ b/ios/iNaturalistReactNativeTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 0.21.1 + 0.22.0 CFBundleSignature ???? CFBundleVersion - 70 + 71 From 89e41b806fd269daa423e905aa00e1f93f95d046 Mon Sep 17 00:00:00 2001 From: Johannes Klein Date: Fri, 9 Feb 2024 22:02:23 +0100 Subject: [PATCH 11/24] Npm install with force on CI for testing purposes only --- .github/workflows/e2e_android.yml | 2 +- .github/workflows/e2e_ios.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/e2e_android.yml b/.github/workflows/e2e_android.yml index ab2dcd5cb..f09d9dbfc 100644 --- a/.github/workflows/e2e_android.yml +++ b/.github/workflows/e2e_android.yml @@ -73,7 +73,7 @@ jobs: - name: Install Dependencies if: steps.cache.outputs.cache-hit != 'true' - run: npm install + run: npm install --force # Generate the secret files needed for a release build - name: Create .env file diff --git a/.github/workflows/e2e_ios.yml b/.github/workflows/e2e_ios.yml index de4faae43..4893844ad 100644 --- a/.github/workflows/e2e_ios.yml +++ b/.github/workflows/e2e_ios.yml @@ -64,7 +64,7 @@ jobs: - name: Install Dependencies if: steps.cache.outputs.cache-hit != 'true' - run: npm install + run: npm install --force - name: Cache Pods uses: actions/cache@v3 From 1348387c573ecd87e27d648c0e6977d746df066a Mon Sep 17 00:00:00 2001 From: Johannes Klein Date: Fri, 9 Feb 2024 22:08:36 +0100 Subject: [PATCH 12/24] Replace reanimated jest setup --- tests/jest.setup.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/jest.setup.js b/tests/jest.setup.js index 97c7abe26..7111e4da0 100644 --- a/tests/jest.setup.js +++ b/tests/jest.setup.js @@ -38,7 +38,7 @@ jest.mock( () => require( "@react-native-async-storage/async-storage/jest/async-storage-mock" ) ); -require( "react-native-reanimated/lib/reanimated2/jestUtils" ).setUpTests(); +require( "react-native-reanimated" ).setUpTests(); jest.mock( "react-native-vision-camera", ( ) => ( { Camera: mockCamera, From d6a0c102b6bf98f296af7f0d272d733f19c18905 Mon Sep 17 00:00:00 2001 From: Johannes Klein Date: Fri, 9 Feb 2024 22:45:25 +0100 Subject: [PATCH 13/24] Revert "Npm install with force on CI for testing purposes only" This reverts commit 89e41b806fd269daa423e905aa00e1f93f95d046. --- .github/workflows/e2e_android.yml | 2 +- .github/workflows/e2e_ios.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/e2e_android.yml b/.github/workflows/e2e_android.yml index f09d9dbfc..ab2dcd5cb 100644 --- a/.github/workflows/e2e_android.yml +++ b/.github/workflows/e2e_android.yml @@ -73,7 +73,7 @@ jobs: - name: Install Dependencies if: steps.cache.outputs.cache-hit != 'true' - run: npm install --force + run: npm install # Generate the secret files needed for a release build - name: Create .env file diff --git a/.github/workflows/e2e_ios.yml b/.github/workflows/e2e_ios.yml index 4893844ad..de4faae43 100644 --- a/.github/workflows/e2e_ios.yml +++ b/.github/workflows/e2e_ios.yml @@ -64,7 +64,7 @@ jobs: - name: Install Dependencies if: steps.cache.outputs.cache-hit != 'true' - run: npm install --force + run: npm install - name: Cache Pods uses: actions/cache@v3 From 5d3e4f3a9ebe059c3f0906326a57c77cb3f5ee8a Mon Sep 17 00:00:00 2001 From: Johannes Klein Date: Fri, 9 Feb 2024 22:45:29 +0100 Subject: [PATCH 14/24] Revert "Replace reanimated jest setup" This reverts commit 1348387c573ecd87e27d648c0e6977d746df066a. --- tests/jest.setup.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/jest.setup.js b/tests/jest.setup.js index 7111e4da0..97c7abe26 100644 --- a/tests/jest.setup.js +++ b/tests/jest.setup.js @@ -38,7 +38,7 @@ jest.mock( () => require( "@react-native-async-storage/async-storage/jest/async-storage-mock" ) ); -require( "react-native-reanimated" ).setUpTests(); +require( "react-native-reanimated/lib/reanimated2/jestUtils" ).setUpTests(); jest.mock( "react-native-vision-camera", ( ) => ( { Camera: mockCamera, From 8e6269c8d03eb23b1c2cf1cc2431f17eac0e2e1e Mon Sep 17 00:00:00 2001 From: Amanda Bullington <35536439+albullington@users.noreply.github.com> Date: Fri, 9 Feb 2024 16:16:32 -0800 Subject: [PATCH 15/24] Wrap realm.write in safeRealmWrite transaction (#1123) * Add a safeRealmWrite transaction for better logging around writes; code cleanup and realm update * Add safeRealmWrite to tests and make sure action is called synchronously * Fix final test * Only write to realm when useObservationsUpdates data changes; code cleanup * Code cleanup --- ios/Podfile.lock | 4 +- package-lock.json | 48 ++---- package.json | 2 +- src/components/App.js | 161 +++--------------- .../LoginSignUp/AuthenticationService.js | 6 +- .../MyObservations/MyObservationsContainer.js | 36 ++-- .../MyObservations/ToolbarContainer.js | 5 - .../hooks/useDeleteObservations.js | 7 +- .../ObsDetails/ObsDetailsContainer.js | 13 +- .../ObsEdit/Sheets/DeleteObservationSheet.js | 5 +- .../ObservationsFlashList.js | 6 +- src/components/hooks/useChangeLocale.js | 36 ++++ src/components/hooks/useFreshInstall.js | 34 ++++ src/components/hooks/useLinking.js | 47 +++++ src/components/hooks/useLockOrientation.js | 19 +++ src/components/hooks/useReactQueryRefetch.js | 25 +++ src/realmModels/Observation.js | 11 +- src/realmModels/ObservationPhoto.js | 9 +- src/realmModels/Taxon.js | 5 +- src/sharedHelpers/safeRealmWrite.js | 32 ++++ src/sharedHelpers/uploadObservation.js | 5 +- src/sharedHooks/useIconicTaxa.js | 21 ++- src/sharedHooks/useLocalObservations.js | 10 +- .../useObservationUpdatesWhenFocused.js | 6 +- src/sharedHooks/useObservationsUpdates.js | 80 ++++----- src/sharedHooks/useUserMe.js | 26 ++- tests/helpers/user.js | 9 +- tests/integration/MyObservations.test.js | 33 ++-- .../sharedHooks/useCurrentUser.test.js | 7 +- .../useObservationUpdatesWhenFocused.test.js | 7 +- .../useObservationsUpdates.test.js | 13 +- .../integration/sharedHooks/useTaxon.test.js | 9 +- .../unit/components/DisplayTaxonName.test.js | 5 +- .../useDeleteObservations.test.js | 30 ++-- .../ObsEdit/DeleteObservationSheet.test.js | 9 +- 35 files changed, 438 insertions(+), 343 deletions(-) create mode 100644 src/components/hooks/useChangeLocale.js create mode 100644 src/components/hooks/useFreshInstall.js create mode 100644 src/components/hooks/useLinking.js create mode 100644 src/components/hooks/useLockOrientation.js create mode 100644 src/components/hooks/useReactQueryRefetch.js create mode 100644 src/sharedHelpers/safeRealmWrite.js diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 3323488c6..e7c180ebf 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -392,7 +392,7 @@ PODS: - React-perflogger (= 0.71.16) - ReactNativeExceptionHandler (2.10.10): - React-Core - - RealmJS (12.3.0): + - RealmJS (12.6.0): - React - RNAudioRecorderPlayer (3.6.0): - React-Core @@ -742,7 +742,7 @@ SPEC CHECKSUMS: React-runtimeexecutor: b5abe02558421897cd9f73d4f4b6adb4bc297083 ReactCommon: a1a263d94f02a0dc8442f341d5a11b3d7a9cd44d ReactNativeExceptionHandler: b11ff67c78802b2f62eed0e10e75cb1ef7947c60 - RealmJS: 4c52a15602e70b64cd9230b0a17a9c12741371f4 + RealmJS: a62dc7a1f94b888fe9e8712cd650167ad97dc636 RNAudioRecorderPlayer: 4690a7cd9e4fd8e58d9671936a7bc3b686e59051 RNCAsyncStorage: f2974eca860c16a3e56eea5771fda8d12e2d2057 RNCClipboard: 3f0451a8100393908bea5c5c5b16f96d45f30bfc diff --git a/package-lock.json b/package-lock.json index b4559266b..253c830b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -93,7 +93,7 @@ "react-native-vision-camera": "github:inaturalist/react-native-vision-camera#our-main-fork-2", "react-native-webview": "^11.26.1", "react-native-worklets-core": "^0.2.0", - "realm": "^12.3.0", + "realm": "^12.6.0", "reassure": "^0.10.1", "sanitize-html": "^2.11.0", "use-debounce": "^9.0.4", @@ -23440,17 +23440,19 @@ "integrity": "sha512-k2d6ACCkiNYz222Fs/iNze30rRJ1iIicW7JuX/7/cozvih6YCkFZH+J6mAFDVgv0dRBaAyr4jDqC95R2y4IADg==" }, "node_modules/realm": { - "version": "12.3.0", - "resolved": "https://registry.npmjs.org/realm/-/realm-12.3.0.tgz", - "integrity": "sha512-qlWu8RpgGQhCllwutGZUJ+B37AF+7RNNXlyo5SftZoQFiTKVlmhN5w6B2fBmTd7R9J7Tw+lJcYUbvCZuZ4es0w==", + "version": "12.6.0", + "resolved": "https://registry.npmjs.org/realm/-/realm-12.6.0.tgz", + "integrity": "sha512-lwixjVE8uiHXqRggJ9DwCxy3P1I0SUGBFG3dLQnXT20o6PdDVpXsTgE82m0svviKyDLs8yb5hLim5HRcHkH5rA==", "hasInstallScript": true, "dependencies": { "bson": "^4.7.2", "debug": "^4.3.4", - "node-fetch": "^2.6.9", "node-machine-id": "^1.1.12", "prebuild-install": "^7.1.1" }, + "engines": { + "node": ">=18" + }, "peerDependencies": { "react-native": ">=0.71.0" }, @@ -23460,25 +23462,6 @@ } } }, - "node_modules/realm/node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, "node_modules/reassure": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/reassure/-/reassure-0.10.1.tgz", @@ -43041,25 +43024,14 @@ "integrity": "sha512-k2d6ACCkiNYz222Fs/iNze30rRJ1iIicW7JuX/7/cozvih6YCkFZH+J6mAFDVgv0dRBaAyr4jDqC95R2y4IADg==" }, "realm": { - "version": "12.3.0", - "resolved": "https://registry.npmjs.org/realm/-/realm-12.3.0.tgz", - "integrity": "sha512-qlWu8RpgGQhCllwutGZUJ+B37AF+7RNNXlyo5SftZoQFiTKVlmhN5w6B2fBmTd7R9J7Tw+lJcYUbvCZuZ4es0w==", + "version": "12.6.0", + "resolved": "https://registry.npmjs.org/realm/-/realm-12.6.0.tgz", + "integrity": "sha512-lwixjVE8uiHXqRggJ9DwCxy3P1I0SUGBFG3dLQnXT20o6PdDVpXsTgE82m0svviKyDLs8yb5hLim5HRcHkH5rA==", "requires": { "bson": "^4.7.2", "debug": "^4.3.4", - "node-fetch": "^2.6.9", "node-machine-id": "^1.1.12", "prebuild-install": "^7.1.1" - }, - "dependencies": { - "node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "requires": { - "whatwg-url": "^5.0.0" - } - } } }, "reassure": { diff --git a/package.json b/package.json index 00586d0a4..4af4ef24c 100644 --- a/package.json +++ b/package.json @@ -118,7 +118,7 @@ "react-native-vision-camera": "github:inaturalist/react-native-vision-camera#our-main-fork-2", "react-native-webview": "^11.26.1", "react-native-worklets-core": "^0.2.0", - "realm": "^12.3.0", + "realm": "^12.6.0", "reassure": "^0.10.1", "sanitize-html": "^2.11.0", "use-debounce": "^9.0.4", diff --git a/src/components/App.js b/src/components/App.js index 753c8dfec..524153540 100644 --- a/src/components/App.js +++ b/src/components/App.js @@ -1,29 +1,31 @@ // @flow -import AsyncStorage from "@react-native-async-storage/async-storage"; -import { useNavigation } from "@react-navigation/native"; -import { focusManager } from "@tanstack/react-query"; -import { signOut } from "components/LoginSignUp/AuthenticationService"; import RootDrawerNavigator from "navigation/rootDrawerNavigator"; import { RealmContext } from "providers/contexts"; import type { Node } from "react"; -import React, { useCallback, useEffect } from "react"; -import { - AppState, Linking, LogBox -} from "react-native"; -import DeviceInfo from "react-native-device-info"; -import Orientation from "react-native-orientation-locker"; +import React, { useEffect } from "react"; +import { LogBox } from "react-native"; +import Realm from "realm"; import { addARCameraFiles } from "sharedHelpers/cvModel"; +import { log } from "sharedHelpers/logger"; import { useCurrentUser, useIconicTaxa, useObservationUpdatesWhenFocused, - useShare, - useTranslation, - useUserMe + useShare } from "sharedHooks"; -import { log } from "../../react-native-logs.config"; +import useChangeLocale from "./hooks/useChangeLocale"; +import useFreshInstall from "./hooks/useFreshInstall"; +import useLinking from "./hooks/useLinking"; +import useLockOrientation from "./hooks/useLockOrientation"; +import useReactQueryRefetch from "./hooks/useReactQueryRefetch"; + +const { useRealm } = RealmContext; + +const logger = log.extend( "App" ); + +Realm.setLogLevel( "warn" ); // Ignore warnings about 3rd parties that haven't implemented the new // NativeEventEmitter interface methods yet. As of 20230517, this is coming @@ -31,12 +33,6 @@ import { log } from "../../react-native-logs.config"; // https://stackoverflow.com/questions/69538962 LogBox.ignoreLogs( ["new NativeEventEmitter"] ); -const logger = log.extend( "App" ); - -const isTablet = DeviceInfo.isTablet(); - -const { useRealm } = RealmContext; - type Props = { children?: any, }; @@ -44,129 +40,22 @@ type Props = { // this children prop is here for the sake of testing with jest // normally we would never do this in code const App = ( { children }: Props ): Node => { - const navigation = useNavigation( ); const realm = useRealm( ); const currentUser = useCurrentUser( ); useIconicTaxa( { reload: true } ); - const { i18n } = useTranslation( ); + useReactQueryRefetch( ); + useFreshInstall( currentUser ); + useLinking( currentUser ); + useChangeLocale( currentUser ); + + useLockOrientation( ); useShare( ); + useObservationUpdatesWhenFocused( ); - // fetch current user from server and save to realm in useEffect - // this is used for changing locale and also for showing UserCard - const { remoteUser } = useUserMe( ); - - useEffect( () => { - if ( !isTablet ) { - Orientation.lockToPortrait(); - } - - return ( ) => Orientation?.unlockAllOrientations( ); + useEffect( ( ) => { + addARCameraFiles( ); }, [] ); - useObservationUpdatesWhenFocused(); - - // When the app is coming back from the background, set the focusManager to focused - // This will trigger react-query to refetch any queries that are stale - const onAppStateChange = status => { - focusManager.setFocused( status === "active" ); - }; - - useEffect( () => { - // subscribe to app state changes - const subscription = AppState.addEventListener( "change", onAppStateChange ); - - // unsubscribe on unmount - return ( ) => subscription?.remove(); - }, [] ); - - useEffect( () => { - addARCameraFiles(); - }, [] ); - - useEffect( ( ) => { - const checkForSignedInUser = async ( ) => { - // check to see if this is a fresh install of the app - // if it is, delete realm file when we sign the user out of the app - // this handles the case where a user deletes the app, then reinstalls - // and expects to be signed out with no previously saved data - const alreadyLaunched = await AsyncStorage.getItem( "alreadyLaunched" ); - if ( !alreadyLaunched ) { - await AsyncStorage.setItem( "alreadyLaunched", "true" ); - if ( !currentUser ) { - logger.debug( - "Signing out and deleting Realm because no signed in user found in the database" - ); - await signOut( { clearRealm: true } ); - } - } - }; - - checkForSignedInUser( ); - }, [currentUser] ); - - const changeLanguageToLocale = useCallback( - locale => i18n.changeLanguage( locale ), - [i18n] - ); - - // When we get the updated current user, update the record in the database - useEffect( ( ) => { - if ( remoteUser ) { - realm?.write( ( ) => { - realm?.create( "User", remoteUser, "modified" ); - } ); - - // If the current user's locale has changed, change the language - if ( remoteUser.locale !== i18n.language ) { - changeLanguageToLocale( remoteUser.locale ); - } - } - }, [changeLanguageToLocale, i18n, realm, remoteUser] ); - - // If the current user's locale is not set, change the language - useEffect( ( ) => { - if ( currentUser?.locale && currentUser?.locale !== i18n.language ) { - changeLanguageToLocale( currentUser.locale ); - } - }, [changeLanguageToLocale, currentUser?.locale, i18n] ); - - const navigateConfirmedUser = useCallback( ( ) => { - if ( currentUser ) { return; } - navigation.navigate( "LoginNavigator", { - screen: "Login", - params: { emailConfirmed: true } - } ); - }, [navigation, currentUser] ); - - const newAccountConfirmedUrl = "https://www.inaturalist.org/users/sign_in?confirmed=true"; - const existingAccountConfirmedUrl = "https://www.inaturalist.org/home?confirmed=true"; - // const testUrl = "https://www.inaturalist.org/observations"; - - useEffect( ( ) => { - Linking.addEventListener( "url", async ( { url } ) => { - if ( url === newAccountConfirmedUrl - // || url.includes( testUrl ) - || url === existingAccountConfirmedUrl - ) { - navigateConfirmedUser( ); - } - } ); - }, [navigateConfirmedUser] ); - - useEffect( ( ) => { - const fetchInitialUrl = async ( ) => { - const url = await Linking.getInitialURL( ); - - if ( url === newAccountConfirmedUrl - // || url?.includes( testUrl ) - || url === existingAccountConfirmedUrl - ) { - navigateConfirmedUser( ); - } - }; - fetchInitialUrl( ); - }, [navigateConfirmedUser] ); - useEffect( ( ) => { if ( realm?.path ) { logger.debug( "[App.js] Need to open Realm in another app?" ); diff --git a/src/components/LoginSignUp/AuthenticationService.js b/src/components/LoginSignUp/AuthenticationService.js index 790fdfa1f..aed79c42b 100644 --- a/src/components/LoginSignUp/AuthenticationService.js +++ b/src/components/LoginSignUp/AuthenticationService.js @@ -14,6 +14,7 @@ import RNSInfo from "react-native-sensitive-info"; import Realm from "realm"; import realmConfig from "realmModels/index"; import User from "realmModels/User"; +import safeRealmWrite from "sharedHelpers/safeRealmWrite"; import { log } from "../../../react-native-logs.config"; @@ -344,9 +345,10 @@ const authenticateUser = async ( // Save userId to local, encrypted storage const currentUser = { id: userId, login: remoteUsername, signedIn: true }; - realm?.write( ( ) => { + logger.debug( "writing current user to realm: ", currentUser ); + safeRealmWrite( realm, ( ) => { realm.create( "User", currentUser, "modified" ); - } ); + }, "saving current user in AuthenticationService" ); const currentRealmUser = User.currentUser( realm ); logger.debug( "Signed in", currentRealmUser.login, currentRealmUser.id, currentRealmUser ); const realmPathExists = await RNFS.exists( realm.path ); diff --git a/src/components/MyObservations/MyObservationsContainer.js b/src/components/MyObservations/MyObservationsContainer.js index 44e2c363d..06798ce1e 100644 --- a/src/components/MyObservations/MyObservationsContainer.js +++ b/src/components/MyObservations/MyObservationsContainer.js @@ -20,6 +20,7 @@ import { INCREMENT_SINGLE_UPLOAD_PROGRESS } from "sharedHelpers/emitUploadProgress"; import { log } from "sharedHelpers/logger"; +import safeRealmWrite from "sharedHelpers/safeRealmWrite"; import uploadObservation from "sharedHelpers/uploadObservation"; import { useCurrentUser, @@ -124,9 +125,10 @@ const MyObservationsContainer = ( ): Node => { const navigation = useNavigation( ); const { t } = useTranslation( ); const realm = useRealm( ); + const allObsToUpload = Observation.filterUnsyncedObservations( realm ); const { params: navParams } = useRoute( ); const [state, dispatch] = useReducer( uploadReducer, INITIAL_UPLOAD_STATE ); - const { observationList: observations, allObsToUpload } = useLocalObservations( ); + const { observationList: observations } = useLocalObservations( ); const { layout, writeLayoutToStorage } = useStoredLayout( "myObservationsLayout" ); const isOnline = useIsConnected( ); @@ -325,29 +327,25 @@ const MyObservationsContainer = ( ): Node => { const deletedObservations = response?.results; if ( !deletedObservations ) { return; } if ( deletedObservations?.length > 0 ) { - realm?.write( ( ) => { - deletedObservations.forEach( observationId => { - const localObsToDelete = realm.objects( "Observation" ) - .filtered( `id == ${observationId}` ); - realm.delete( localObsToDelete ); + safeRealmWrite( realm, ( ) => { + const localObservationsToDelete = realm.objects( "Observation" ) + .filtered( `id IN { ${deletedObservations} }` ); + localObservationsToDelete.forEach( observation => { + realm.delete( observation ); } ); - } ); + }, "deleting remote deleted observations in MyObservationsContainer" ); } }, [realm] ); const updateSyncTime = useCallback( ( ) => { - const currentSyncTime = new Date( ); - realm?.write( ( ) => { - const localPrefs = realm.objects( "LocalPreferences" )[0]; - if ( !localPrefs ) { - realm.create( "LocalPreferences", { - ...localPrefs, - last_sync_time: currentSyncTime - } ); - } else { - localPrefs.last_sync_time = currentSyncTime; - } - } ); + const localPrefs = realm.objects( "LocalPreferences" )[0]; + const updatedPrefs = { + ...localPrefs, + last_sync_time: new Date( ) + }; + safeRealmWrite( realm, ( ) => { + realm.create( "LocalPreferences", updatedPrefs, "modified" ); + }, "updating sync time in MyObservationsContainer" ); }, [realm] ); const syncObservations = useCallback( async ( ) => { diff --git a/src/components/MyObservations/ToolbarContainer.js b/src/components/MyObservations/ToolbarContainer.js index 418f0302e..7829ed916 100644 --- a/src/components/MyObservations/ToolbarContainer.js +++ b/src/components/MyObservations/ToolbarContainer.js @@ -7,7 +7,6 @@ import { Dimensions, PixelRatio } from "react-native"; import { useTheme } from "react-native-paper"; import { useCurrentUser, - useObservationsUpdates, useTranslation } from "sharedHooks"; @@ -61,19 +60,15 @@ const ToolbarContainer = ( { currentUploadCount } = uploadState; - const { refetch } = useObservationsUpdates( false ); - const handleSyncButtonPress = useCallback( ( ) => { if ( numUnuploadedObs > 0 ) { uploadMultipleObservations( ); } else { syncObservations( ); - refetch( ); } }, [ numUnuploadedObs, syncObservations, - refetch, uploadMultipleObservations ] ); diff --git a/src/components/MyObservations/hooks/useDeleteObservations.js b/src/components/MyObservations/hooks/useDeleteObservations.js index 800db7658..6d79a1abf 100644 --- a/src/components/MyObservations/hooks/useDeleteObservations.js +++ b/src/components/MyObservations/hooks/useDeleteObservations.js @@ -4,6 +4,7 @@ import { deleteRemoteObservation } from "api/observations"; import { RealmContext } from "providers/contexts"; import { useCallback, useEffect, useReducer } from "react"; import { log } from "sharedHelpers/logger"; +import safeRealmWrite from "sharedHelpers/safeRealmWrite"; import { useAuthenticatedMutation } from "sharedHooks"; const logger = log.extend( "useDeleteObservations" ); @@ -70,11 +71,11 @@ const useDeleteObservations = ( ): Object => { const observationToDelete = deletions[currentDeleteCount - 1]; const deleteLocalObservation = useCallback( ( ) => { - const realmObservation = realm.objectForPrimaryKey( "Observation", observationToDelete.uuid ); + const realmObservation = realm?.objectForPrimaryKey( "Observation", observationToDelete.uuid ); logger.info( "Local observation to delete: ", realmObservation.uuid ); - realm?.write( ( ) => { + safeRealmWrite( realm, ( ) => { realm?.delete( realmObservation ); - } ); + }, `deleting local observation ${realmObservation.uuid} in useDeleteObservations` ); logger.info( "Local observation deleted" ); return true; }, [realm, observationToDelete] ); diff --git a/src/components/ObsDetails/ObsDetailsContainer.js b/src/components/ObsDetails/ObsDetailsContainer.js index 2864fb51e..5051ae5a4 100644 --- a/src/components/ObsDetails/ObsDetailsContainer.js +++ b/src/components/ObsDetails/ObsDetailsContainer.js @@ -14,6 +14,7 @@ import React, { } from "react"; import { Alert, LogBox } from "react-native"; import Observation from "realmModels/Observation"; +import safeRealmWrite from "sharedHelpers/safeRealmWrite"; import { useAuthenticatedMutation, useAuthenticatedQuery, @@ -228,11 +229,11 @@ const ObsDetailsContainer = ( ): Node => { const markViewedLocally = async () => { if ( !localObservation ) { return; } - realm?.write( () => { + safeRealmWrite( realm, ( ) => { // Flags if all comments and identifications have been viewed localObservation.comments_viewed = true; localObservation.identifications_viewed = true; - } ); + }, "marking viewed locally in ObsDetailsContainer" ); }; const { refetch: refetchObservationUpdates } = useObservationsUpdates( @@ -264,12 +265,12 @@ const ObsDetailsContainer = ( ): Node => { { onSuccess: data => { if ( belongsToCurrentUser ) { - realm?.write( ( ) => { + safeRealmWrite( realm, ( ) => { const localComments = localObservation?.comments; const newComment = data[0]; newComment.user = currentUser; localComments.push( newComment ); - } ); + }, "setting local comment in ObsDetailsContainer" ); const updatedLocalObservation = realm.objectForPrimaryKey( "Observation", uuid ); dispatch( { type: "ADD_ACTIVITY_ITEM", observationShown: updatedLocalObservation } ); } else { @@ -304,7 +305,7 @@ const ObsDetailsContainer = ( ): Node => { { onSuccess: data => { if ( belongsToCurrentUser ) { - realm?.write( ( ) => { + safeRealmWrite( realm, ( ) => { const localIdentifications = localObservation?.identifications; const newIdentification = data[0]; newIdentification.user = currentUser; @@ -316,7 +317,7 @@ const ObsDetailsContainer = ( ): Node => { newIdentification.vision = true; } localIdentifications.push( newIdentification ); - } ); + }, "setting local identification in ObsDetailsContainer" ); const updatedLocalObservation = realm.objectForPrimaryKey( "Observation", uuid ); dispatch( { type: "ADD_ACTIVITY_ITEM", observationShown: updatedLocalObservation } ); } else { diff --git a/src/components/ObsEdit/Sheets/DeleteObservationSheet.js b/src/components/ObsEdit/Sheets/DeleteObservationSheet.js index 5ace7a45c..99d33f7ce 100644 --- a/src/components/ObsEdit/Sheets/DeleteObservationSheet.js +++ b/src/components/ObsEdit/Sheets/DeleteObservationSheet.js @@ -7,6 +7,7 @@ import { RealmContext } from "providers/contexts"; import type { Node } from "react"; import React, { useCallback } from "react"; import { log } from "sharedHelpers/logger"; +import safeRealmWrite from "sharedHelpers/safeRealmWrite"; import { useTranslation } from "sharedHooks"; const { useRealm } = RealmContext; @@ -41,9 +42,9 @@ const DeleteObservationSheet = ( { navToObsList( ); } else { logger.info( "Observation to add to deletion queue: ", localObsToDelete.uuid ); - realm?.write( ( ) => { + safeRealmWrite( realm, ( ) => { localObsToDelete._deleted_at = new Date( ); - } ); + }, "adding _deleted_at date in DeleteObservationSheet" ); logger.info( "Observation added to deletion queue; returning to MyObservations" ); diff --git a/src/components/SharedComponents/ObservationsFlashList/ObservationsFlashList.js b/src/components/SharedComponents/ObservationsFlashList/ObservationsFlashList.js index 0bc3f1d6a..7c87a8f2a 100644 --- a/src/components/SharedComponents/ObservationsFlashList/ObservationsFlashList.js +++ b/src/components/SharedComponents/ObservationsFlashList/ObservationsFlashList.js @@ -168,8 +168,12 @@ const ObservationsFlashList = ( { // react thinks we've rendered a second item w/ a duplicate key keyExtractor={item => item.uuid || item.id} numColumns={numColumns} - onEndReached={onEndReached} onEndReachedThreshold={0.2} + onMomentumScrollEnd={( ) => { + if ( dataCanBeFetched ) { + onEndReached( ); + } + }} onScroll={handleScroll} refreshing={isFetchingNextPage} renderItem={renderItem} diff --git a/src/components/hooks/useChangeLocale.js b/src/components/hooks/useChangeLocale.js new file mode 100644 index 000000000..faa2372f0 --- /dev/null +++ b/src/components/hooks/useChangeLocale.js @@ -0,0 +1,36 @@ +// @flow + +import { useCallback, useEffect } from "react"; +import { + useTranslation, + useUserMe +} from "sharedHooks"; + +const useChangeLocale = ( currentUser: ?Object ) => { + const { i18n } = useTranslation( ); + // fetch current user from server and save to realm in useEffect + // this is used for changing locale and also for showing UserCard + const { remoteUser } = useUserMe( { updateRealm: true } ); + const changeLanguageToLocale = useCallback( + locale => i18n.changeLanguage( locale ), + [i18n] + ); + + // When we get the updated current user, update the record in the database + useEffect( ( ) => { + if ( !remoteUser ) { return; } + // If the current user's locale has changed, change the language + if ( remoteUser?.locale !== i18n.language ) { + changeLanguageToLocale( remoteUser.locale ); + } + }, [changeLanguageToLocale, i18n, remoteUser] ); + + // If the current user's locale is not set, change the language + useEffect( ( ) => { + if ( currentUser?.locale && currentUser?.locale !== i18n.language ) { + changeLanguageToLocale( currentUser.locale ); + } + }, [changeLanguageToLocale, currentUser?.locale, i18n] ); +}; + +export default useChangeLocale; diff --git a/src/components/hooks/useFreshInstall.js b/src/components/hooks/useFreshInstall.js new file mode 100644 index 000000000..f2fde394f --- /dev/null +++ b/src/components/hooks/useFreshInstall.js @@ -0,0 +1,34 @@ +// @flow + +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { signOut } from "components/LoginSignUp/AuthenticationService"; +import { useEffect } from "react"; + +import { log } from "../../../react-native-logs.config"; + +const logger = log.extend( "useFreshInstall" ); + +const useFreshInstall = ( currentUser: ?Object ) => { + useEffect( ( ) => { + const checkForSignedInUser = async ( ) => { + // check to see if this is a fresh install of the app + // if it is, delete realm file when we sign the user out of the app + // this handles the case where a user deletes the app, then reinstalls + // and expects to be signed out with no previously saved data + const alreadyLaunched = await AsyncStorage.getItem( "alreadyLaunched" ); + if ( !alreadyLaunched ) { + await AsyncStorage.setItem( "alreadyLaunched", "true" ); + if ( !currentUser ) { + logger.debug( + "Signing out and deleting Realm because no signed in user found in the database" + ); + await signOut( { clearRealm: true } ); + } + } + }; + + checkForSignedInUser( ); + }, [currentUser] ); +}; + +export default useFreshInstall; diff --git a/src/components/hooks/useLinking.js b/src/components/hooks/useLinking.js new file mode 100644 index 000000000..f199b378d --- /dev/null +++ b/src/components/hooks/useLinking.js @@ -0,0 +1,47 @@ +// @flow + +import { useNavigation } from "@react-navigation/native"; +import { useCallback, useEffect } from "react"; +import { Linking } from "react-native"; + +const useLinking = ( currentUser: ?Object ) => { + const navigation = useNavigation( ); + const navigateConfirmedUser = useCallback( ( ) => { + if ( currentUser ) { return; } + navigation.navigate( "LoginNavigator", { + screen: "Login", + params: { emailConfirmed: true } + } ); + }, [navigation, currentUser] ); + + const newAccountConfirmedUrl = "https://www.inaturalist.org/users/sign_in?confirmed=true"; + const existingAccountConfirmedUrl = "https://www.inaturalist.org/home?confirmed=true"; + // const testUrl = "https://www.inaturalist.org/observations"; + + useEffect( ( ) => { + Linking.addEventListener( "url", async ( { url } ) => { + if ( url === newAccountConfirmedUrl + // || url.includes( testUrl ) + || url === existingAccountConfirmedUrl + ) { + navigateConfirmedUser( ); + } + } ); + }, [navigateConfirmedUser] ); + + useEffect( ( ) => { + const fetchInitialUrl = async ( ) => { + const url = await Linking.getInitialURL( ); + + if ( url === newAccountConfirmedUrl + // || url?.includes( testUrl ) + || url === existingAccountConfirmedUrl + ) { + navigateConfirmedUser( ); + } + }; + fetchInitialUrl( ); + }, [navigateConfirmedUser] ); +}; + +export default useLinking; diff --git a/src/components/hooks/useLockOrientation.js b/src/components/hooks/useLockOrientation.js new file mode 100644 index 000000000..177f2770c --- /dev/null +++ b/src/components/hooks/useLockOrientation.js @@ -0,0 +1,19 @@ +// @flow + +import { useEffect } from "react"; +import DeviceInfo from "react-native-device-info"; +import Orientation from "react-native-orientation-locker"; + +const isTablet = DeviceInfo.isTablet(); + +const useLockOrientation = ( ) => { + useEffect( () => { + if ( !isTablet ) { + Orientation.lockToPortrait(); + } + + return ( ) => Orientation?.unlockAllOrientations( ); + }, [] ); +}; + +export default useLockOrientation; diff --git a/src/components/hooks/useReactQueryRefetch.js b/src/components/hooks/useReactQueryRefetch.js new file mode 100644 index 000000000..2d023c124 --- /dev/null +++ b/src/components/hooks/useReactQueryRefetch.js @@ -0,0 +1,25 @@ +// @flow + +import { focusManager } from "@tanstack/react-query"; +import { useEffect } from "react"; +import { + AppState +} from "react-native"; + +const useReactQueryRefetch = ( ) => { + // When the app is coming back from the background, set the focusManager to focused + // This will trigger react-query to refetch any queries that are stale + const onAppStateChange = status => { + focusManager.setFocused( status === "active" ); + }; + + useEffect( () => { + // subscribe to app state changes + const subscription = AppState.addEventListener( "change", onAppStateChange ); + + // unsubscribe on unmount + return ( ) => subscription?.remove(); + }, [] ); +}; + +export default useReactQueryRefetch; diff --git a/src/realmModels/Observation.js b/src/realmModels/Observation.js index e91ec08c2..6904595ff 100644 --- a/src/realmModels/Observation.js +++ b/src/realmModels/Observation.js @@ -2,6 +2,7 @@ import { Realm } from "@realm/react"; import uuid from "react-native-uuid"; import { createObservedOnStringForUpload } from "sharedHelpers/dateAndTime"; import { formatExifDateAsString, parseExif } from "sharedHelpers/parseExif"; +import safeRealmWrite from "sharedHelpers/safeRealmWrite"; import Application from "./Application"; import Comment from "./Comment"; @@ -103,7 +104,7 @@ class Observation extends Realm.Object { const obsToUpsert = observations.filter( obs => !Observation.isUnsyncedObservation( realm, obs ) ); - realm?.write( ( ) => { + safeRealmWrite( realm, ( ) => { obsToUpsert.forEach( obs => { realm.create( "Observation", @@ -111,7 +112,7 @@ class Observation extends Realm.Object { "modified" ); } ); - } ); + }, "upserting remote observations in Observation" ); } } @@ -219,12 +220,12 @@ class Observation extends Realm.Object { observationSounds }; - realm?.write( ( ) => { + safeRealmWrite( realm, ( ) => { // using 'modified' here for the case where a new observation has the same Taxon // as a previous observation; otherwise, realm will error out // also using modified for updating observations which were already saved locally - realm?.create( "Observation", obsToSave, "modified" ); - } ); + realm.create( "Observation", obsToSave, "modified" ); + }, "saving local observation for upload in Observation" ); return realm.objectForPrimaryKey( "Observation", obs.uuid ); } diff --git a/src/realmModels/ObservationPhoto.js b/src/realmModels/ObservationPhoto.js index ab237c559..20e0dfa04 100644 --- a/src/realmModels/ObservationPhoto.js +++ b/src/realmModels/ObservationPhoto.js @@ -1,6 +1,7 @@ import { Realm } from "@realm/react"; import { FileUpload } from "inaturalistjs"; import uuid from "react-native-uuid"; +import safeRealmWrite from "sharedHelpers/safeRealmWrite"; import Photo from "./Photo"; @@ -90,9 +91,9 @@ class ObservationPhoto extends Realm.Object { // api v2, so just going to worry about deleting locally for now const obsPhotoToDelete = currentObservation?.observationPhotos.find( p => p.url === uri ); if ( obsPhotoToDelete ) { - realm?.write( ( ) => { + safeRealmWrite( realm, ( ) => { realm?.delete( obsPhotoToDelete ); - } ); + }, "deleting remote observation photo in ObservationPhoto" ); } } @@ -102,9 +103,9 @@ class ObservationPhoto extends Realm.Object { const obsPhotoToDelete = currentObservation?.observationPhotos .find( p => p.localFilePath === uri ); if ( obsPhotoToDelete ) { - realm?.write( ( ) => { + safeRealmWrite( realm, ( ) => { realm?.delete( obsPhotoToDelete ); - } ); + }, "deleting local observation photo in ObservationPhoto" ); } } diff --git a/src/realmModels/Taxon.js b/src/realmModels/Taxon.js index 650c63b1b..c1f0f9462 100644 --- a/src/realmModels/Taxon.js +++ b/src/realmModels/Taxon.js @@ -1,4 +1,5 @@ import { Realm } from "@realm/react"; +import safeRealmWrite from "sharedHelpers/safeRealmWrite"; import Photo from "./Photo"; @@ -109,9 +110,9 @@ class Taxon extends Realm.Object { static saveRemoteTaxon = async ( remoteTaxon, realm ) => { if ( remoteTaxon ) { const localTaxon = Taxon.mapApiToRealm( remoteTaxon, realm ); - realm?.write( ( ) => { + safeRealmWrite( realm, ( ) => { realm.create( "Taxon", localTaxon, "modified" ); - } ); + }, "saving remote taxon in Taxon" ); } }; diff --git a/src/sharedHelpers/safeRealmWrite.js b/src/sharedHelpers/safeRealmWrite.js new file mode 100644 index 000000000..f645d7ec3 --- /dev/null +++ b/src/sharedHelpers/safeRealmWrite.js @@ -0,0 +1,32 @@ +// @flow + +import { log } from "../../react-native-logs.config"; + +const logger = log.extend( "safeRealmWrite" ); + +// this is based on safeWrite from this github issue, but customized for +// realmjs: https://stackoverflow.com/questions/39366182/the-realm-is-already-in-a-write-transaction + +const safeRealmWrite = ( + realm: any, + action: Function, + description: string = "No description given" +): any => { + if ( realm.isInTransaction ) { + logger.info( "realm is in transaction:", realm.isInTransaction ); + realm.cancelTransaction( ); + } + // https://www.mongodb.com/docs/realm-sdks/react/latest/classes/Realm-1.html#beginTransaction.beginTransaction-1 + realm.beginTransaction( ); + try { + logger.info( "writing to realm:", description ); + const response = action( ); + realm.commitTransaction( ); + return response; + } catch ( e ) { + logger.info( "couldn't write to realm: ", e ); + throw new Error( `${description}: ${e.message}` ); + } +}; + +export default safeRealmWrite; diff --git a/src/sharedHelpers/uploadObservation.js b/src/sharedHelpers/uploadObservation.js index 51279a19b..2213dd096 100644 --- a/src/sharedHelpers/uploadObservation.js +++ b/src/sharedHelpers/uploadObservation.js @@ -10,6 +10,7 @@ import inatjs from "inaturalistjs"; import Observation from "realmModels/Observation"; import ObservationPhoto from "realmModels/ObservationPhoto"; import emitUploadProgress from "sharedHelpers/emitUploadProgress"; +import safeRealmWrite from "sharedHelpers/safeRealmWrite"; const UPLOAD_PROGRESS_INCREMENT = 0.5; @@ -28,10 +29,10 @@ const markRecordUploaded = ( observationUUID, recordUUID, type, response, realm } // TODO: add ObservationSound - realm?.write( ( ) => { + safeRealmWrite( realm, ( ) => { record.id = id; record._synced_at = new Date( ); - } ); + }, "marking record uploaded in uploadObservation.js" ); }; const uploadEvidence = async ( diff --git a/src/sharedHooks/useIconicTaxa.js b/src/sharedHooks/useIconicTaxa.js index a83cbe32b..fbd6a28a2 100644 --- a/src/sharedHooks/useIconicTaxa.js +++ b/src/sharedHooks/useIconicTaxa.js @@ -1,7 +1,8 @@ // @flow import { searchTaxa } from "api/taxa"; import { RealmContext } from "providers/contexts"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; +import safeRealmWrite from "sharedHelpers/safeRealmWrite"; import { useAuthenticatedQuery, useIsConnected } from "sharedHooks"; const { useRealm } = RealmContext; @@ -9,6 +10,7 @@ const { useRealm } = RealmContext; const useIconicTaxa = ( { reload }: Object ): Object => { const realm = useRealm( ); const isConnected = useIsConnected( ); + const [isUpdatingRealm, setIsUpdatingRealm] = useState( ); const queryKey = ["searchTaxa", reload]; const { data: iconicTaxa } = useAuthenticatedQuery( @@ -18,15 +20,18 @@ const useIconicTaxa = ( { reload }: Object ): Object => { ); useEffect( ( ) => { - if ( iconicTaxa?.length > 0 ) { - iconicTaxa.forEach( taxa => { - taxa.isIconic = true; - realm?.write( ( ) => { - realm?.create( "Taxon", taxa, "modified" ); + if ( iconicTaxa?.length > 0 && !isUpdatingRealm ) { + setIsUpdatingRealm( true ); + safeRealmWrite( realm, ( ) => { + iconicTaxa.forEach( taxa => { + realm.create( "Taxon", { + ...taxa, + isIconic: true + }, "modified" ); } ); - } ); + }, "modifying iconic taxa in useIconicTaxa" ); } - }, [iconicTaxa, realm] ); + }, [iconicTaxa, realm, isUpdatingRealm] ); return realm?.objects( "Taxon" ).filtered( "isIconic = true" ); }; diff --git a/src/sharedHooks/useLocalObservations.js b/src/sharedHooks/useLocalObservations.js index d5dff106b..5dc0cc94e 100644 --- a/src/sharedHooks/useLocalObservations.js +++ b/src/sharedHooks/useLocalObservations.js @@ -6,7 +6,6 @@ import { useEffect, useRef, useState } from "react"; -import Observation from "realmModels/Observation"; const { useRealm } = RealmContext; @@ -17,7 +16,6 @@ const useLocalObservations = ( ): Object => { // views from rendering when they have focus. const stagedObservationList = useRef( [] ); const [observationList, setObservationList] = useState( [] ); - const [allObsToUpload, setAllObsToUpload] = useState( [] ); const realm = useRealm( ); @@ -31,11 +29,8 @@ const useLocalObservations = ( ): Object => { localObservations.addListener( ( collection, _changes ) => { stagedObservationList.current = [...collection]; - const unsyncedObs = Observation.filterUnsyncedObservations( realm ); - if ( isFocused ) { setObservationList( stagedObservationList.current ); - setAllObsToUpload( unsyncedObs ); } } ); // eslint-disable-next-line consistent-return @@ -43,11 +38,10 @@ const useLocalObservations = ( ): Object => { // remember to remove listeners to avoid async updates localObservations?.removeAllListeners( ); }; - }, [isFocused, allObsToUpload.length, realm] ); + }, [isFocused, realm] ); return { - observationList, - allObsToUpload + observationList }; }; diff --git a/src/sharedHooks/useObservationUpdatesWhenFocused.js b/src/sharedHooks/useObservationUpdatesWhenFocused.js index 1bdc06ca8..37d443ea8 100644 --- a/src/sharedHooks/useObservationUpdatesWhenFocused.js +++ b/src/sharedHooks/useObservationUpdatesWhenFocused.js @@ -3,6 +3,7 @@ import { RealmContext } from "providers/contexts"; import { useCallback, useEffect } from "react"; import { AppState } from "react-native"; +import safeRealmWrite from "sharedHelpers/safeRealmWrite"; const { useRealm } = RealmContext; @@ -14,12 +15,13 @@ const useObservationUpdatesWhenFocused = () => { const observations = realm .objects( "Observation" ) .filtered( "comments_viewed == false OR identifications_viewed == false" ); - realm?.write( () => { + if ( observations.length === 0 ) { return; } + safeRealmWrite( realm, () => { observations.forEach( observation => { observation.comments_viewed = true; observation.identifications_viewed = true; } ); - } ); + }, "setting comments_viewed and ids_viewed to true in useObservationsUpdatesWhenFocused" ); }, [realm] ); const onAppStateChange = useCallback( diff --git a/src/sharedHooks/useObservationsUpdates.js b/src/sharedHooks/useObservationsUpdates.js index 17841ee6b..7663ee218 100644 --- a/src/sharedHooks/useObservationsUpdates.js +++ b/src/sharedHooks/useObservationsUpdates.js @@ -2,6 +2,8 @@ import { fetchObservationUpdates } from "api/observations"; import { RealmContext } from "providers/contexts"; +import { useEffect } from "react"; +import safeRealmWrite from "sharedHelpers/safeRealmWrite"; import { useAuthenticatedQuery, useIsConnected } from "sharedHooks"; const { useRealm } = RealmContext; @@ -59,45 +61,45 @@ const useObservationsUpdates = ( enabled: boolean ): Object => { ] */ - // Looping through all unviewed updates - const unviewed = data?.filter( result => result.viewed === false ); - unviewed?.forEach( update => { - // Get the observation from local realm that matches the update's resource_uuid - const existingObs = realm?.objectForPrimaryKey( - "Observation", - update.resource_uuid - ); - if ( !existingObs ) { - return; - } - // If both comments and identifications are already unviewed, nothing to do here - if ( - existingObs.comments_viewed === false - && existingObs.identifications_viewed === false - ) { - return; - } - // If the update is a comment, set the observation's comments_viewed to false - if ( - existingObs.comments_viewed || existingObs.comments_viewed === null - ) { - if ( update.comment_id ) { - realm?.write( () => { - existingObs.comments_viewed = false; - } ); - } - } - // If the update is an identification, set the observation's identifications_viewed to false - if ( - existingObs.identifications_viewed || existingObs.identifications_viewed === null - ) { - if ( update.identification_id ) { - realm?.write( () => { - existingObs.identifications_viewed = false; - } ); - } - } - } ); + useEffect( ( ) => { + // Looping through all unviewed updates + const remoteUnviewed = data?.filter( result => result.viewed === false ); + safeRealmWrite( realm, ( ) => { + remoteUnviewed?.forEach( update => { + // Get the observation from local realm that matches the update's resource_uuid + const existingObs = realm?.objectForPrimaryKey( + "Observation", + update.resource_uuid + ); + if ( !existingObs ) { + return; + } + // If both comments and identifications are already unviewed, nothing to do here + if ( + existingObs.comments_viewed === false + && existingObs.identifications_viewed === false + ) { + return; + } + // If the update is a comment, set the observation's comments_viewed to false + if ( + existingObs.comments_viewed || existingObs.comments_viewed === null + ) { + if ( update.comment_id ) { + existingObs.comments_viewed = false; + } + } + // If the update is an identification, set the observation's identifications_viewed to false + if ( + existingObs.identifications_viewed || existingObs.identifications_viewed === null + ) { + if ( update.identification_id ) { + existingObs.identifications_viewed = false; + } + } + } ); + }, "setting comments and/or identifications false in useObservationsUpdates" ); + }, [data, realm] ); return { refetch }; }; diff --git a/src/sharedHooks/useUserMe.js b/src/sharedHooks/useUserMe.js index 4cd2dbd1e..aa71c8646 100644 --- a/src/sharedHooks/useUserMe.js +++ b/src/sharedHooks/useUserMe.js @@ -1,11 +1,16 @@ // @flow import { fetchUserMe } from "api/users"; -import useAuthenticatedQuery from "sharedHooks/useAuthenticatedQuery"; -import useCurrentUser from "sharedHooks/useCurrentUser"; -import useIsConnected from "sharedHooks/useIsConnected"; +import { RealmContext } from "providers/contexts"; +import { useEffect } from "react"; +import safeRealmWrite from "sharedHelpers/safeRealmWrite"; +import { useAuthenticatedQuery, useCurrentUser, useIsConnected } from "sharedHooks"; -const useUserMe = ( ): Object => { +const { useRealm } = RealmContext; + +const useUserMe = ( options: ?Object ): Object => { + const realm = useRealm( ); const currentUser = useCurrentUser( ); + const updateRealm = options?.updateRealm; const isConnected = useIsConnected( ); const enabled = !!isConnected && !!currentUser; @@ -21,6 +26,19 @@ const useUserMe = ( ): Object => { } ); + const userLocaleChanged = ( + !currentUser?.locale || ( remoteUser?.locale !== currentUser?.locale ) + ) + && updateRealm; + + useEffect( ( ) => { + if ( userLocaleChanged && remoteUser ) { + safeRealmWrite( realm, ( ) => { + realm.create( "User", remoteUser, "modified" ); + }, "modifying current user via remote fetch in useUserMe" ); + } + }, [realm, userLocaleChanged, remoteUser] ); + return { remoteUser, isLoading, diff --git a/tests/helpers/user.js b/tests/helpers/user.js index df8ebd9c2..3c164610d 100644 --- a/tests/helpers/user.js +++ b/tests/helpers/user.js @@ -3,6 +3,7 @@ import i18next from "i18next"; import inatjs from "inaturalistjs"; import nock from "nock"; import RNSInfo from "react-native-sensitive-info"; +import safeRealmWrite from "sharedHelpers/safeRealmWrite"; import { makeResponse } from "tests/factory"; const TEST_JWT = "test-json-web-token"; @@ -12,9 +13,9 @@ async function signOut( options = {} ) { const realm = options.realm || global.realm; i18next.language = undefined; // This is the nuclear option, maybe revisit if it's a source of bugs - realm?.write( ( ) => { + safeRealmWrite( realm, ( ) => { realm.deleteAll( ); - } ); + }, "deleting entire realm in signOut function, user.js" ); await RNSInfo.deleteItem( "username" ); await RNSInfo.deleteItem( "jwtToken" ); await RNSInfo.deleteItem( "jwtGeneratedAt" ); @@ -30,9 +31,9 @@ async function signIn( user, options = {} ) { await RNSInfo.setItem( "accessToken", TEST_ACCESS_TOKEN ); inatjs.users.me.mockResolvedValue( makeResponse( [user] ) ); user.signedIn = true; - realm?.write( ( ) => { + safeRealmWrite( realm, ( ) => { realm.create( "User", user, "modified" ); - } ); + }, "signing user in, user.js" ); nock( API_HOST ) .post( "/oauth/token" ) .reply( 200, { access_token: TEST_ACCESS_TOKEN } ) diff --git a/tests/integration/MyObservations.test.js b/tests/integration/MyObservations.test.js index 004fb2227..355054e81 100644 --- a/tests/integration/MyObservations.test.js +++ b/tests/integration/MyObservations.test.js @@ -13,6 +13,7 @@ import path from "path"; import React from "react"; import Realm from "realm"; import realmConfig from "realmModels/index"; +import safeRealmWrite from "sharedHelpers/safeRealmWrite"; import factory, { makeResponse } from "tests/factory"; import { renderAppWithComponent } from "tests/helpers/render"; import { signIn, signOut } from "tests/helpers/user"; @@ -123,9 +124,9 @@ describe( "MyObservations", ( ) => { } ); beforeEach( async ( ) => { - global.mockRealms[__filename].write( ( ) => { + safeRealmWrite( global.mockRealms[__filename], ( ) => { global.mockRealms[__filename].deleteAll( ); - } ); + }, "delete realm, MyObservations integration test when signed in" ); await signIn( mockUser, { realm: global.mockRealms[__filename] } ); } ); @@ -169,13 +170,13 @@ describe( "MyObservations", ( ) => { } ) ]; - beforeEach( async () => { + beforeEach( ( ) => { // Write local observation to Realm - await global.mockRealms[__filename].write( () => { + safeRealmWrite( global.mockRealms[__filename], ( ) => { mockObservations.forEach( mockObservation => { global.mockRealms[__filename].create( "Observation", mockObservation ); } ); - } ); + }, "write local observation, MyObservations integration test with unsynced observations" ); } ); afterEach( ( ) => { @@ -277,13 +278,13 @@ describe( "MyObservations", ( ) => { } ) ]; - beforeEach( async () => { - await global.mockRealms[__filename].write( () => { + beforeEach( ( ) => { + safeRealmWrite( global.mockRealms[__filename], ( ) => { global.mockRealms[__filename].deleteAll( ); mockObservationsSynced.forEach( mockObservation => { global.mockRealms[__filename].create( "Observation", mockObservation ); } ); - } ); + }, "delete all and create synced observations, MyObservations integration test" ); } ); afterEach( ( ) => { @@ -321,12 +322,12 @@ describe( "MyObservations", ( ) => { } ); describe( "after initial sync", ( ) => { - beforeEach( async () => { - await global.mockRealms[__filename].write( () => { + beforeEach( ( ) => { + safeRealmWrite( global.mockRealms[__filename], ( ) => { global.mockRealms[__filename].create( "LocalPreferences", { last_sync_time: new Date( "2023-11-01" ) } ); - } ); + }, "add last_sync_time to LocalPreferences, MyObservations integration test" ); } ); it( "downloads deleted observations from server when sync button tapped", async ( ) => { @@ -357,12 +358,10 @@ describe( "MyObservations", ( ) => { expect( syncIcon ).toBeVisible( ); } ); fireEvent.press( syncIcon ); - const spy = jest.spyOn( global.mockRealms[__filename], "write" ); const deleteSpy = jest.spyOn( global.mockRealms[__filename], "delete" ); await waitFor( ( ) => { - expect( spy ).toHaveBeenCalled( ); + expect( deleteSpy ).toHaveBeenCalledTimes( 1 ); } ); - expect( deleteSpy ).toHaveBeenCalled( ); expect( global.mockRealms[__filename].objects( "Observation" ).length ).toBe( 1 ); } ); } ); @@ -370,10 +369,10 @@ describe( "MyObservations", ( ) => { } ); describe( "localization for current user", ( ) => { - beforeEach( async ( ) => { - await global.mockRealms[__filename].write( ( ) => { + beforeEach( ( ) => { + safeRealmWrite( global.mockRealms[__filename], ( ) => { global.mockRealms[__filename].deleteAll( ); - } ); + }, "delete all, MyObservations integration test, localization for current user" ); } ); afterEach( ( ) => { diff --git a/tests/integration/sharedHooks/useCurrentUser.test.js b/tests/integration/sharedHooks/useCurrentUser.test.js index 7bcb8e5f2..b9704b126 100644 --- a/tests/integration/sharedHooks/useCurrentUser.test.js +++ b/tests/integration/sharedHooks/useCurrentUser.test.js @@ -1,4 +1,5 @@ import { renderHook } from "@testing-library/react-native"; +import safeRealmWrite from "sharedHelpers/safeRealmWrite"; import { useCurrentUser } from "sharedHooks"; import factory from "tests/factory"; @@ -9,10 +10,10 @@ const mockUser = factory( "LocalUser", { describe( "useCurrentUser", () => { beforeEach( async ( ) => { - // Write mock observations to realm - await global.realm.write( () => { + // Write mock user to realm + safeRealmWrite( global.realm, ( ) => { global.realm.create( "User", mockUser ); - } ); + }, "create current user, useCurrentUser test" ); } ); it( "should return current user", () => { diff --git a/tests/integration/sharedHooks/useObservationUpdatesWhenFocused.test.js b/tests/integration/sharedHooks/useObservationUpdatesWhenFocused.test.js index cbb5b64e0..c2954c336 100644 --- a/tests/integration/sharedHooks/useObservationUpdatesWhenFocused.test.js +++ b/tests/integration/sharedHooks/useObservationUpdatesWhenFocused.test.js @@ -1,4 +1,5 @@ import { renderHook } from "@testing-library/react-native"; +import safeRealmWrite from "sharedHelpers/safeRealmWrite"; import useObservationUpdatesWhenFocused from "sharedHooks/useObservationUpdatesWhenFocused"; import factory from "tests/factory"; @@ -16,13 +17,13 @@ const mockObservations = [ ]; describe( "useObservationUpdatesWhenFocused", () => { - beforeAll( async () => { + beforeAll( ( ) => { // Write mock observations to realm - await global.realm.write( () => { + safeRealmWrite( global.realm, ( ) => { mockObservations.forEach( o => { global.realm.create( "Observation", o ); } ); - } ); + }, "write observations to realm, useObservationUpdatesWhenFocused test" ); } ); it( "should reset state of all observations in realm", () => { diff --git a/tests/integration/sharedHooks/useObservationsUpdates.test.js b/tests/integration/sharedHooks/useObservationsUpdates.test.js index 55c7cfbe8..70f74e4e0 100644 --- a/tests/integration/sharedHooks/useObservationsUpdates.test.js +++ b/tests/integration/sharedHooks/useObservationsUpdates.test.js @@ -1,5 +1,6 @@ import { faker } from "@faker-js/faker"; import { renderHook } from "@testing-library/react-native"; +import safeRealmWrite from "sharedHelpers/safeRealmWrite"; import useObservationsUpdates from "sharedHooks/useObservationsUpdates"; import factory from "tests/factory"; @@ -38,11 +39,11 @@ describe( "useObservationsUpdates", ( ) => { } ); describe( "when there is no local observation with the resource_uuid", ( ) => { - beforeEach( async ( ) => { + beforeEach( ( ) => { // Write mock observation to realm - await global.realm.write( () => { + safeRealmWrite( global.realm, ( ) => { global.realm.create( "Observation", mockObservation ); - } ); + }, "write mock observation, useObservationUpdates test" ); } ); it( "should return without writing to a local observation", ( ) => { @@ -66,16 +67,16 @@ describe( "useObservationsUpdates", ( ) => { ["not viewed comments and viewed identifications", false, true], ["not viewed comments and not viewed identifications", false, false] ] )( "when the local observation has %s", ( a1, viewedComments, viewedIdentifications ) => { - beforeEach( async ( ) => { + beforeEach( ( ) => { // Write mock observation to realm - await global.realm.write( () => { + safeRealmWrite( global.realm, ( ) => { global.realm.deleteAll( ); global.realm.create( "Observation", { ...mockObservation, comments_viewed: viewedComments, identifications_viewed: viewedIdentifications } ); - } ); + }, "delete all and create observation, useObservationsUpdates test" ); } ); it( "should write correct viewed status for comments and identifications", ( ) => { diff --git a/tests/integration/sharedHooks/useTaxon.test.js b/tests/integration/sharedHooks/useTaxon.test.js index fcac89d23..f333314d8 100644 --- a/tests/integration/sharedHooks/useTaxon.test.js +++ b/tests/integration/sharedHooks/useTaxon.test.js @@ -1,5 +1,6 @@ import { faker } from "@faker-js/faker"; import { renderHook } from "@testing-library/react-native"; +import safeRealmWrite from "sharedHelpers/safeRealmWrite"; import { useTaxon } from "sharedHooks"; import factory from "tests/factory"; @@ -26,9 +27,9 @@ describe( "useTaxon", ( ) => { describe( "with local taxon", ( ) => { beforeEach( async ( ) => { // Write mock taxon to realm - await global.realm.write( () => { + safeRealmWrite( global.realm, ( ) => { global.realm.create( "Taxon", mockTaxon, "modified" ); - } ); + }, "write mock taxon, useTaxon test" ); } ); it( "should return an object", ( ) => { @@ -47,9 +48,9 @@ describe( "useTaxon", ( ) => { describe( "when there is no local taxon with taxon id", ( ) => { beforeEach( async ( ) => { - await global.realm.write( ( ) => { + safeRealmWrite( global.realm, ( ) => { global.realm.deleteAll( ); - } ); + }, "delete all realm, useTaxon test" ); } ); it( "should make an API call and return passed in taxon when fetchRemote is enabled", ( ) => { diff --git a/tests/unit/components/DisplayTaxonName.test.js b/tests/unit/components/DisplayTaxonName.test.js index cb24d60d1..9ffb54c9b 100644 --- a/tests/unit/components/DisplayTaxonName.test.js +++ b/tests/unit/components/DisplayTaxonName.test.js @@ -3,6 +3,7 @@ import { render, screen } from "@testing-library/react-native"; import { DisplayTaxonName } from "components/SharedComponents"; import initI18next from "i18n/initI18next"; import React from "react"; +import safeRealmWrite from "sharedHelpers/safeRealmWrite"; import factory from "tests/factory"; const capitalizeFirstLetter = s => s.charAt( 0 ).toUpperCase( ) + s.slice( 1 ); @@ -158,7 +159,7 @@ describe( "DisplayTaxonName", ( ) => { describe( "when taxon is a Realm object", ( ) => { it( "fills in a missing genus rank from the rank_level", ( ) => { let taxon; - global.realm.write( ( ) => { + safeRealmWrite( global.realm, ( ) => { taxon = global.realm.create( "Taxon", { @@ -168,7 +169,7 @@ describe( "DisplayTaxonName", ( ) => { }, "modified" ); - } ); + }, "create taxon, DisplayTaxonName test" ); render( ); expect( screen.getByText( /Genus/ ) ).toBeTruthy( ); } ); diff --git a/tests/unit/components/MyObservations/useDeleteObservations.test.js b/tests/unit/components/MyObservations/useDeleteObservations.test.js index 6b0db2602..8f4c8a272 100644 --- a/tests/unit/components/MyObservations/useDeleteObservations.test.js +++ b/tests/unit/components/MyObservations/useDeleteObservations.test.js @@ -2,6 +2,7 @@ import { faker } from "@faker-js/faker"; import { renderHook, waitFor } from "@testing-library/react-native"; import useDeleteObservations from "components/MyObservations/hooks/useDeleteObservations"; import initI18next from "i18n/initI18next"; +import safeRealmWrite from "sharedHelpers/safeRealmWrite"; import factory from "tests/factory"; const mockMutate = jest.fn(); @@ -36,11 +37,15 @@ describe( "handle deletions", ( ) => { it( "should not make deletion API call for unsynced observations", async ( ) => { const deleteSpy = jest.spyOn( global.realm, "delete" ); - unsyncedObservations.forEach( observation => { - global.realm.write( ( ) => { - global.realm.create( "Observation", observation ); - } ); - } ); + safeRealmWrite( + global.realm, + ( ) => { + unsyncedObservations.forEach( observation => { + global.realm.create( "Observation", observation ); + } ); + }, + "write unsyncedObservations, useDeleteObservations test" + ); const unsyncedObservation = getLocalObservation( unsyncedObservations[0].uuid @@ -51,17 +56,20 @@ describe( "handle deletions", ( ) => { await waitFor( ( ) => { expect( mockMutate ).not.toHaveBeenCalled( ); } ); - expect( deleteSpy ).toHaveBeenCalled( ); } ); it( "should make deletion API call for previously synced observations", async ( ) => { const deleteSpy = jest.spyOn( global.realm, "delete" ); - syncedObservations.forEach( observation => { - global.realm.write( ( ) => { - global.realm.create( "Observation", observation ); - } ); - } ); + safeRealmWrite( + global.realm, + ( ) => { + syncedObservations.forEach( observation => { + global.realm.create( "Observation", observation ); + } ); + }, + "write syncedObservations, useDeleteObservations test" + ); const syncedObservation = getLocalObservation( syncedObservations[0].uuid ); expect( syncedObservation._synced_at ).not.toBeNull( ); diff --git a/tests/unit/components/ObsEdit/DeleteObservationSheet.test.js b/tests/unit/components/ObsEdit/DeleteObservationSheet.test.js index 6e99e30f2..2ff2d5891 100644 --- a/tests/unit/components/ObsEdit/DeleteObservationSheet.test.js +++ b/tests/unit/components/ObsEdit/DeleteObservationSheet.test.js @@ -5,6 +5,7 @@ import initI18next from "i18n/initI18next"; import i18next from "i18next"; import inatjs from "inaturalistjs"; import React from "react"; +import safeRealmWrite from "sharedHelpers/safeRealmWrite"; import factory from "tests/factory"; import { renderComponent } from "tests/helpers/render"; @@ -35,9 +36,9 @@ describe( "delete observation", ( ) => { beforeAll( async ( ) => { await initI18next( ); - global.realm.write( ( ) => { + safeRealmWrite( global.realm, ( ) => { global.realm.create( "Observation", currentObservation ); - } ); + }, "write Observation, DeleteObservationSheet test" ); } ); describe( "add observation to deletion queue", ( ) => { @@ -58,9 +59,9 @@ describe( "delete observation", ( ) => { describe( "cancel deletion", ( ) => { it( "should not add _deleted_at date in realm", ( ) => { const localObservation = getLocalObservation( currentObservation.uuid ); - global.realm.write( ( ) => { + safeRealmWrite( global.realm, ( ) => { localObservation._deleted_at = null; - } ); + }, "set _deleted_at to null, DeleteObservationSheet test" ); expect( localObservation ).toBeTruthy( ); renderDeleteSheet( ); const cancelButton = screen.queryByText( /CANCEL/ ); From 1d3d127dfd056ac39e1aded7515735c52368cc5b Mon Sep 17 00:00:00 2001 From: Ken-ichi Ueda Date: Fri, 9 Feb 2024 16:43:24 -0800 Subject: [PATCH 16/24] Increased online suggestions timeout to 5s Also added some temporary diagnostics UI to that screen to help figure out inconsistent offline / offline / missing suggestions behavior. May have also fixed a crash regarding UUIDs. --- src/components/Suggestions/Suggestions.js | 27 ++++++++++++++++--- .../Suggestions/SuggestionsContainer.js | 10 +++++++ .../Suggestions/hooks/useOnlineSuggestions.js | 17 +++++++++--- .../Suggestions/hooks/useTaxonSelected.js | 4 +-- 4 files changed, 49 insertions(+), 9 deletions(-) diff --git a/src/components/Suggestions/Suggestions.js b/src/components/Suggestions/Suggestions.js index 205135c95..068e6ad5b 100644 --- a/src/components/Suggestions/Suggestions.js +++ b/src/components/Suggestions/Suggestions.js @@ -36,7 +36,8 @@ type Props = { setSelectedPhotoUri: Function, observers: Array, topSuggestion: Object, - usingOfflineSuggestions: boolean + usingOfflineSuggestions: boolean, + debugData: any }; const Suggestion = ( { suggestion, onChosen } ) => ( @@ -63,7 +64,8 @@ const Suggestions = ( { setSelectedPhotoUri, observers, topSuggestion, - usingOfflineSuggestions + usingOfflineSuggestions, + debugData }: Props ): Node => { const { t } = useTranslation( ); const navigation = useNavigation( ); @@ -93,9 +95,26 @@ const Suggestions = ( { return null; }, [loadingSuggestions, suggestions, t] ); + /* eslint-disable i18next/no-literal-string */ + /* eslint-disable react/jsx-one-expression-per-line */ + /* eslint-disable max-len */ const renderFooter = useCallback( ( ) => ( - - ), [observers] ); + <> + + + Diagnostics + Online suggestions URI: {JSON.stringify( debugData?.selectedPhotoUri )} + Online suggestions updated at: {JSON.stringify( debugData?.onlineSuggestionsUpdatedAt )} + Online suggestions timed out: {JSON.stringify( debugData?.timedOut )} + Num online suggestions: {JSON.stringify( debugData?.onlineSuggestions?.results.length )} + Num offline suggestions: {JSON.stringify( debugData?.offlineSuggestions?.length )} + Error loading online: {JSON.stringify( debugData?.onlineSuggestionsError )} + + + ), [debugData, observers] ); + /* eslint-enable i18next/no-literal-string */ + /* eslint-enable react/jsx-one-expression-per-line */ + /* eslint-enable max-len */ const renderHeader = useCallback( ( ) => ( <> diff --git a/src/components/Suggestions/SuggestionsContainer.js b/src/components/Suggestions/SuggestionsContainer.js index fb00af603..497944aa0 100644 --- a/src/components/Suggestions/SuggestionsContainer.js +++ b/src/components/Suggestions/SuggestionsContainer.js @@ -20,6 +20,8 @@ const SuggestionsContainer = ( ): Node => { const [selectedTaxon, setSelectedTaxon] = useState( null ); const { + dataUpdatedAt: onlineSuggestionsUpdatedAt, + error: onlineSuggestionsError, onlineSuggestions, loadingOnlineSuggestions, timedOut @@ -73,6 +75,14 @@ const SuggestionsContainer = ( ): Node => { setSelectedPhotoUri={setSelectedPhotoUri} observers={observers} usingOfflineSuggestions={tryOfflineSuggestions && offlineSuggestions?.length > 0} + debugData={{ + timedOut, + onlineSuggestions, + offlineSuggestions, + onlineSuggestionsError, + onlineSuggestionsUpdatedAt, + selectedPhotoUri + }} /> ); }; diff --git a/src/components/Suggestions/hooks/useOnlineSuggestions.js b/src/components/Suggestions/hooks/useOnlineSuggestions.js index 0b5962b64..94c43dd5b 100644 --- a/src/components/Suggestions/hooks/useOnlineSuggestions.js +++ b/src/components/Suggestions/hooks/useOnlineSuggestions.js @@ -10,6 +10,8 @@ import { useAuthenticatedQuery } from "sharedHooks"; +const SCORE_IMAGE_TIMEOUT = 5_000; + const resizeImage = async ( path: string, width: number, @@ -63,9 +65,11 @@ const flattenUploadParams = async ( }; type OnlineSuggestionsResponse = { + dataUpdatedAt: Date, onlineSuggestions: Object, loadingOnlineSuggestions: boolean, - timedOut: boolean + timedOut: boolean, + error: Object } const useOnlineSuggestions = ( @@ -82,8 +86,10 @@ const useOnlineSuggestions = ( // uploading images const { data: onlineSuggestions, + dataUpdatedAt, isLoading: loadingOnlineSuggestions, - isError + isError, + error } = useAuthenticatedQuery( ["scoreImage", selectedPhotoUri], async optsWithAuth => { @@ -103,13 +109,14 @@ const useOnlineSuggestions = ( } ); + // Give up on suggestions request after a timeout useEffect( ( ) => { const timer = setTimeout( ( ) => { if ( onlineSuggestions === undefined ) { queryClient.cancelQueries( { queryKey: ["scoreImage", selectedPhotoUri] } ); setTimedOut( true ); } - }, 2000 ); + }, SCORE_IMAGE_TIMEOUT ); return ( ) => { clearTimeout( timer ); @@ -118,11 +125,15 @@ const useOnlineSuggestions = ( return timedOut ? { + dataUpdatedAt, + error, onlineSuggestions: undefined, loadingOnlineSuggestions: false, timedOut } : { + dataUpdatedAt, + error, onlineSuggestions, loadingOnlineSuggestions: loadingOnlineSuggestions && !isError, timedOut diff --git a/src/components/Suggestions/hooks/useTaxonSelected.js b/src/components/Suggestions/hooks/useTaxonSelected.js index 8dcd60604..decc6cdc0 100644 --- a/src/components/Suggestions/hooks/useTaxonSelected.js +++ b/src/components/Suggestions/hooks/useTaxonSelected.js @@ -24,7 +24,7 @@ const useTaxonSelected = ( selectedTaxon: ?Object, options: Object ) => { // screen (by adding an id) or they can first land on ObsEdit (by tapping the edit button) if ( lastScreen === "ObsDetails" ) { navigation.navigate( "ObsDetails", { - uuid: currentObservation.uuid, + uuid: currentObservation?.uuid, // TODO refactor so we're not passing complex objects as params; all // obs details really needs to know is the ID of the taxon suggestedTaxonId: selectedTaxon.id, @@ -44,7 +44,7 @@ const useTaxonSelected = ( selectedTaxon: ?Object, options: Object ) => { } }, [ comment, - currentObservation.uuid, + currentObservation?.uuid, lastScreen, navigation, selectedTaxon, From 2a2d9d81b9d6849aa4ec64f94f190d56c07013f5 Mon Sep 17 00:00:00 2001 From: Ken-ichi Ueda Date: Fri, 9 Feb 2024 17:12:33 -0800 Subject: [PATCH 17/24] Added more sources for ObsDetails images, hopefully so we can get an image out of cache more often --- src/components/ObsDetails/PhotoContainer.js | 34 ++++++++++++++++++--- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/src/components/ObsDetails/PhotoContainer.js b/src/components/ObsDetails/PhotoContainer.js index 4a448e6fb..4d07ac1ac 100644 --- a/src/components/ObsDetails/PhotoContainer.js +++ b/src/components/ObsDetails/PhotoContainer.js @@ -17,15 +17,39 @@ type Props = { const PhotoContainer = ( { photo, onPress, style }: Props ): Node => { const { t } = useTranslation( ); const [loadSuccess, setLoadSuccess] = useState( null ); - // check for local file path for unuploaded photos - const photoUrl = photo?.url - ? photo.url.replace( "square", "large" ) - : photo.localFilePath; + + const imageSources = []; + if ( photo.localFilePath ) { + imageSources.push( { uri: photo.localFilePath } ); + } + if ( photo.url ) { + imageSources.push( { + uri: photo.url, + width: 75, + height: 75 + } ); + imageSources.push( { + uri: photo.url.replace( "square", "small" ), + width: 240, + height: 240 + } ); + imageSources.push( { + uri: photo.url.replace( "square", "medium" ), + width: 500, + height: 500 + } ); + imageSources.push( { + uri: photo.url.replace( "square", "large" ), + width: 1024, + height: 1024 + } ); + } const image = ( Date: Fri, 9 Feb 2024 17:29:44 -0800 Subject: [PATCH 18/24] Fix obs deletion bug; better date formatting for Suggestions diagnostics --- .../MyObservations/hooks/useDeleteObservations.js | 12 +++++++----- src/components/Suggestions/Suggestions.js | 3 ++- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/components/MyObservations/hooks/useDeleteObservations.js b/src/components/MyObservations/hooks/useDeleteObservations.js index 6d79a1abf..6ec5bfe87 100644 --- a/src/components/MyObservations/hooks/useDeleteObservations.js +++ b/src/components/MyObservations/hooks/useDeleteObservations.js @@ -72,11 +72,13 @@ const useDeleteObservations = ( ): Object => { const deleteLocalObservation = useCallback( ( ) => { const realmObservation = realm?.objectForPrimaryKey( "Observation", observationToDelete.uuid ); - logger.info( "Local observation to delete: ", realmObservation.uuid ); - safeRealmWrite( realm, ( ) => { - realm?.delete( realmObservation ); - }, `deleting local observation ${realmObservation.uuid} in useDeleteObservations` ); - logger.info( "Local observation deleted" ); + logger.info( "Local observation to delete: ", realmObservation?.uuid ); + if ( realmObservation ) { + safeRealmWrite( realm, ( ) => { + realm?.delete( realmObservation ); + }, `deleting local observation ${realmObservation.uuid} in useDeleteObservations` ); + logger.info( "Local observation deleted" ); + } return true; }, [realm, observationToDelete] ); diff --git a/src/components/Suggestions/Suggestions.js b/src/components/Suggestions/Suggestions.js index 068e6ad5b..db58e4b44 100644 --- a/src/components/Suggestions/Suggestions.js +++ b/src/components/Suggestions/Suggestions.js @@ -20,6 +20,7 @@ import { convertOfflineScoreToConfidence, convertOnlineScoreToConfidence } from "sharedHelpers/convertScores"; +import { formatISONoTimezone } from "sharedHelpers/dateAndTime"; import { useTranslation } from "sharedHooks"; import AddCommentPrompt from "./AddCommentPrompt"; @@ -104,7 +105,7 @@ const Suggestions = ( { Diagnostics Online suggestions URI: {JSON.stringify( debugData?.selectedPhotoUri )} - Online suggestions updated at: {JSON.stringify( debugData?.onlineSuggestionsUpdatedAt )} + Online suggestions updated at: {formatISONoTimezone( debugData?.onlineSuggestionsUpdatedAt )} Online suggestions timed out: {JSON.stringify( debugData?.timedOut )} Num online suggestions: {JSON.stringify( debugData?.onlineSuggestions?.results.length )} Num offline suggestions: {JSON.stringify( debugData?.offlineSuggestions?.length )} From c90acad6f099c2b19ffa843e29884e1dfd734f01 Mon Sep 17 00:00:00 2001 From: Ken-ichi Ueda Date: Mon, 12 Feb 2024 12:52:56 -0800 Subject: [PATCH 19/24] Log stringified JSON errors --- src/api/error.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/api/error.js b/src/api/error.js index 95d4ad253..80e1031cd 100644 --- a/src/api/error.js +++ b/src/api/error.js @@ -37,7 +37,8 @@ async function handleError( e: Object, options: Object = {} ): Object { // TODO: this will log all errors handled here to the log file, in a production build // we probably don't want to do that, so change this back to console.error at one point logger.error( - `Error requesting ${e.response.url} (status: ${e.response.status}): ${errorJson}` + `Error requesting ${e.response.url} (status: ${e.response.status}): + ${JSON.stringify( errorJson )}` ); if ( typeof ( options.onApiError ) === "function" ) { options.onApiError( error ); From ccd0f8ed971ddf11dd076a433ff80a43fe593dfa Mon Sep 17 00:00:00 2001 From: Ken-ichi Ueda Date: Mon, 12 Feb 2024 14:29:21 -0800 Subject: [PATCH 20/24] Changed toolbar upload rotating icon rotation direction to be clockwise --- .../app/src/main/assets/fonts/INatIcon.ttf | Bin 27504 -> 27504 bytes android/link-assets-manifest.json | 2 +- assets/fonts/INatIcon.ttf | Bin 27504 -> 27504 bytes .../project.pbxproj | 8 ++++---- ios/link-assets-manifest.json | 2 +- .../Buttons/RotatingINatIconButton.js | 2 +- src/images/icons/rotate-exclamation.svg | 6 +++--- src/images/icons/rotate.svg | 6 +++--- 8 files changed, 13 insertions(+), 13 deletions(-) diff --git a/android/app/src/main/assets/fonts/INatIcon.ttf b/android/app/src/main/assets/fonts/INatIcon.ttf index 69d9b532b2d6079abb43c0faf9a913c51b0bfae0..32ca2bf90933c0a7769be300d7e19581441ca6e0 100644 GIT binary patch delta 208 zcmexxjq$@Z#t9BA@;RaI6J3_DsLYMOw(-r{@Dw9)F>zB9Gb1rsMiDk`MkO^9GiydQ zbv;HtMs_xKK1Mx8b30~pGjWIlB{f?{6ClS}j**>@MNM5zT~ve(B52KMBnC1;Ok9o; zsKbtt9mEHjXT14uga@NL&`4%>HaSK!b2}C>psmVEY$9SnU1mTd)s)y|8P&wqfHs0n cH8wIcQ3JXLC@LZbQUNv9OzqL;o~S1R059bwXaE2J delta 208 zcmexxjq$@Z#t9BA=W|l#Pjp$ra%8Tf+Qv6)!&8*l+1O1@)Rfp{8AZgb8I8zB9Gb1rsMiDk`MkO^9GiydQ zbv;HtMs_xKK1Mx8b30~pGjWIlB{f?{6ClS}j**>@MNM5zT~ve(B52KMBnC1;Ok9o; zsKbtt9mEHjXT14uga@NL&`4%>HaSK!b2}C>psmVEY$9SnU1mTd)s)y|8P&wqfHs0n cH8wIcQ3JXLC@LZbQUNv9OzqL;o~S1R059bwXaE2J delta 208 zcmexxjq$@Z#t9BA=W|l#Pjp$ra%8Tf+Qv6)!&8*l+1O1@)Rfp{8AZgb8I8 ( { transform: [ { - rotateZ: `-${rotation.value}deg` + rotateZ: `${rotation.value}deg` } ] } ), diff --git a/src/images/icons/rotate-exclamation.svg b/src/images/icons/rotate-exclamation.svg index 1bb427a67..b16d787fc 100644 --- a/src/images/icons/rotate-exclamation.svg +++ b/src/images/icons/rotate-exclamation.svg @@ -1,4 +1,4 @@ - - - + + + diff --git a/src/images/icons/rotate.svg b/src/images/icons/rotate.svg index 7bf90409a..a4e6c0a85 100644 --- a/src/images/icons/rotate.svg +++ b/src/images/icons/rotate.svg @@ -1,4 +1,4 @@ - - - + + + From 7c347faabf51edf591cd613b7c45dda4f592c9dd Mon Sep 17 00:00:00 2001 From: Ken-ichi Ueda Date: Mon, 12 Feb 2024 14:44:05 -0800 Subject: [PATCH 21/24] Fixed ObsMedia test --- .../components/ObsDetails/ObsMedia.test.js | 34 +++++++++++++------ 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/tests/unit/components/ObsDetails/ObsMedia.test.js b/tests/unit/components/ObsDetails/ObsMedia.test.js index 53265d8df..efddc68b6 100644 --- a/tests/unit/components/ObsDetails/ObsMedia.test.js +++ b/tests/unit/components/ObsDetails/ObsMedia.test.js @@ -25,6 +25,28 @@ const mockPhotos = _.compact( Array.from( mockObservation.observationPhotos ).map( op => op.photo ) ); +const expectedImageSource = [ + { + height: 75, + uri: mockObservation.observationPhotos[0].photo.url, + width: 75 + }, + { + height: 240, + uri: mockObservation.observationPhotos[0].photo.url, + width: 240 + }, { + height: 500, + uri: mockObservation.observationPhotos[0].photo.url, + width: 500 + }, + { + height: 1024, + uri: mockObservation.observationPhotos[0].photo.url, + width: 1024 + } +]; + describe( "ObsMedia", () => { beforeAll( async ( ) => { await initI18next( ); @@ -39,20 +61,12 @@ describe( "ObsMedia", () => { it( "should show photo with given url", async () => { render( ); const photo = await screen.findByTestId( "ObsMedia.photo" ); - expect( photo.props.source ).toStrictEqual( - { - uri: mockObservation.observationPhotos[0].photo.url - } - ); + expect( photo.props.source ).toStrictEqual( expectedImageSource ); } ); it( "should show photo with given url on tablet", async () => { render( ); const photo = await screen.findByTestId( "ObsMedia.photo" ); - expect( photo.props.source ).toStrictEqual( - { - uri: mockObservation.observationPhotos[0].photo.url - } - ); + expect( photo.props.source ).toStrictEqual( expectedImageSource ); } ); } ); From 0ed984206c441f57065bf6bdc331a78dd9f663dc Mon Sep 17 00:00:00 2001 From: budowski Date: Tue, 13 Feb 2024 18:17:48 -0600 Subject: [PATCH 22/24] Clear selection in GroupPhotos after removing a photo Closes #1058 --- src/components/PhotoImporter/GroupPhotosContainer.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/PhotoImporter/GroupPhotosContainer.js b/src/components/PhotoImporter/GroupPhotosContainer.js index 802525d5e..359a123fd 100644 --- a/src/components/PhotoImporter/GroupPhotosContainer.js +++ b/src/components/PhotoImporter/GroupPhotosContainer.js @@ -122,8 +122,10 @@ const GroupPhotosContainer = ( ): Node => { removedFromGroup.push( { photos: filteredGroupedPhotos } ); } } ); + // remove from group photos screen setGroupedPhotos( removedFromGroup ); + setSelectedObservations( [] ); }; const navToObsEdit = async ( ) => { From eb7f9d28e45fcaac7b2ff89a9bb22269db2a7818 Mon Sep 17 00:00:00 2001 From: Amanda Bullington <35536439+albullington@users.noreply.github.com> Date: Tue, 13 Feb 2024 16:52:11 -0800 Subject: [PATCH 23/24] Add navigation links to explore views (#1146) * Add navigation links to species, observer, identifier view items * Add tests to check for navigation; closes #1054 --- .../project.pbxproj | 4 +- src/components/Explore/Explore.js | 3 + src/components/Explore/ObserversView.js | 3 +- src/components/Explore/TaxonGridItem.js | 80 +++++++++++-------- .../SharedComponents/UserListItem.js | 13 ++- src/components/SharedComponents/index.js | 1 + .../components/Explore/TaxonGridItem.test.js | 46 +++++++++++ .../SharedComponents/UserListItem.test.js | 56 +++++++++++++ 8 files changed, 166 insertions(+), 40 deletions(-) create mode 100644 tests/unit/components/Explore/TaxonGridItem.test.js create mode 100644 tests/unit/components/SharedComponents/UserListItem.test.js diff --git a/ios/iNaturalistReactNative.xcodeproj/project.pbxproj b/ios/iNaturalistReactNative.xcodeproj/project.pbxproj index 3c6518af0..4bea510f8 100644 --- a/ios/iNaturalistReactNative.xcodeproj/project.pbxproj +++ b/ios/iNaturalistReactNative.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ 197A169E2A7C2567001A03DC /* taxonomy.json in Resources */ = {isa = PBXBuildFile; fileRef = 197A169C2A7C2567001A03DC /* taxonomy.json */; }; 20A80CB2AD058BDA23462D38 /* libPods-iNaturalistReactNative-ShareExtension.a in Frameworks */ = {isa = PBXBuildFile; fileRef = BBAA404A663814006B0F659A /* libPods-iNaturalistReactNative-ShareExtension.a */; }; 374CB22F29943E63005885ED /* Whitney-BookItalic-Pro.otf in Resources */ = {isa = PBXBuildFile; fileRef = 374CB22E29943E63005885ED /* Whitney-BookItalic-Pro.otf */; }; + 3922DED6305249D5BBFFBC9E /* INatIcon.ttf in Resources */ = {isa = PBXBuildFile; fileRef = CCD593FC02054019A624FF88 /* INatIcon.ttf */; }; 4FB3B444D46A4115B867B9CC /* inaturalisticons.ttf in Resources */ = {isa = PBXBuildFile; fileRef = EE004FD2EC174086A7AB2908 /* inaturalisticons.ttf */; }; 5A8D64AB921678B40E0229C8 /* BuildFile in Frameworks */ = {isa = PBXBuildFile; }; 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; }; @@ -25,7 +26,6 @@ A019DB3A4661689827F5BB56 /* libPods-iNaturalistReactNative.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 486ED9661FEC89EDDBE3DA02 /* libPods-iNaturalistReactNative.a */; }; A252B2AEA64E47C9AC1D20E8 /* Whitney-Light-Pro.otf in Resources */ = {isa = PBXBuildFile; fileRef = BA9D41ECEBFA4C38B74009B3 /* Whitney-Light-Pro.otf */; }; BA2479FA3D7B40A7BEF7B3CD /* Whitney-Medium-Pro.otf in Resources */ = {isa = PBXBuildFile; fileRef = D09FA3A0162844FF80A5EF96 /* Whitney-Medium-Pro.otf */; }; - 3922DED6305249D5BBFFBC9E /* INatIcon.ttf in Resources */ = {isa = PBXBuildFile; fileRef = CCD593FC02054019A624FF88 /* INatIcon.ttf */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -87,13 +87,13 @@ 8B8BAD0429F54EB300CE5C9F /* iNaturalistReactNative.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = iNaturalistReactNative.entitlements; path = iNaturalistReactNative/iNaturalistReactNative.entitlements; sourceTree = ""; }; BA9D41ECEBFA4C38B74009B3 /* Whitney-Light-Pro.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "Whitney-Light-Pro.otf"; path = "../assets/fonts/Whitney-Light-Pro.otf"; sourceTree = ""; }; BBAA404A663814006B0F659A /* libPods-iNaturalistReactNative-ShareExtension.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-iNaturalistReactNative-ShareExtension.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + CCD593FC02054019A624FF88 /* INatIcon.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = undefined; includeInIndex = 0; lastKnownFileType = unknown; name = INatIcon.ttf; path = ../assets/fonts/INatIcon.ttf; sourceTree = ""; }; CEBBC55F32B65362EB71A4C6 /* Pods-iNaturalistReactNative-ShareExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iNaturalistReactNative-ShareExtension.debug.xcconfig"; path = "Target Support Files/Pods-iNaturalistReactNative-ShareExtension/Pods-iNaturalistReactNative-ShareExtension.debug.xcconfig"; sourceTree = ""; }; D09FA3A0162844FF80A5EF96 /* Whitney-Medium-Pro.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "Whitney-Medium-Pro.otf"; path = "../assets/fonts/Whitney-Medium-Pro.otf"; sourceTree = ""; }; E67BC54FF5D9263C1DCFB23D /* Pods-iNaturalistReactNative-ShareExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iNaturalistReactNative-ShareExtension.release.xcconfig"; path = "Target Support Files/Pods-iNaturalistReactNative-ShareExtension/Pods-iNaturalistReactNative-ShareExtension.release.xcconfig"; sourceTree = ""; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; EE004FD2EC174086A7AB2908 /* inaturalisticons.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = inaturalisticons.ttf; path = ../assets/fonts/inaturalisticons.ttf; sourceTree = ""; }; FF1A612F80DAF77B84AD4694 /* Pods-iNaturalistReactNative.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iNaturalistReactNative.debug.xcconfig"; path = "Target Support Files/Pods-iNaturalistReactNative/Pods-iNaturalistReactNative.debug.xcconfig"; sourceTree = ""; }; - CCD593FC02054019A624FF88 /* INatIcon.ttf */ = {isa = PBXFileReference; name = "INatIcon.ttf"; path = "../assets/fonts/INatIcon.ttf"; sourceTree = ""; fileEncoding = undefined; lastKnownFileType = unknown; explicitFileType = undefined; includeInIndex = 0; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ diff --git a/src/components/Explore/Explore.js b/src/components/Explore/Explore.js index d65cdeaea..67e8a0119 100644 --- a/src/components/Explore/Explore.js +++ b/src/components/Explore/Explore.js @@ -86,6 +86,9 @@ const Explore = ( { ...exploreAPIParams, per_page: 20 }; + if ( exploreView === "observers" ) { + queryParams.order_by = "observation_count"; + } delete queryParams.taxon_name; const paramsTotalResults = { diff --git a/src/components/Explore/ObserversView.js b/src/components/Explore/ObserversView.js index 39aacb975..e571a0243 100644 --- a/src/components/Explore/ObserversView.js +++ b/src/components/Explore/ObserversView.js @@ -1,6 +1,6 @@ // @flow import { fetchObservers } from "api/observations"; -import UserListItem from "components/SharedComponents/UserListItem"; +import { UserListItem } from "components/SharedComponents"; import { View } from "components/styledComponents"; import type { Node } from "react"; import React, { useEffect } from "react"; @@ -22,6 +22,7 @@ const ObserversView = ( { queryParams, updateCount }: Props ): Node => { + console.log( queryParams, "query params" ); const { data, isFetchingNextPage, diff --git a/src/components/Explore/TaxonGridItem.js b/src/components/Explore/TaxonGridItem.js index a97a8c12a..8cbabf247 100644 --- a/src/components/Explore/TaxonGridItem.js +++ b/src/components/Explore/TaxonGridItem.js @@ -1,11 +1,13 @@ // @flow +import { useNavigation } from "@react-navigation/native"; import { DisplayTaxonName } from "components/SharedComponents"; import ObsImagePreview from "components/SharedComponents/ObservationsFlashList/ObsImagePreview"; -import { View } from "components/styledComponents"; +import { Pressable, View } from "components/styledComponents"; import type { Node } from "react"; import React from "react"; import Photo from "realmModels/Photo"; +import { useTranslation } from "sharedHooks"; import SpeciesSeenCheckmark from "./SpeciesSeenCheckmark"; @@ -16,40 +18,52 @@ type Props = { style?: Object }; -const ObsGridItem = ( { +const TaxonGridItem = ( { taxon, width = "w-full", height, style -}: Props ): Node => ( - - - - - - -); +}: Props ): Node => { + const navigation = useNavigation( ); + const { t } = useTranslation( ); -export default ObsGridItem; + return ( + navigation.navigate( "TaxonDetails", { id: taxon.id } )} + accessibilityLabel={t( "Navigate-to-taxon-details" )} + > + + + + + + + + ); +}; + +export default TaxonGridItem; diff --git a/src/components/SharedComponents/UserListItem.js b/src/components/SharedComponents/UserListItem.js index 20c74238d..187694353 100644 --- a/src/components/SharedComponents/UserListItem.js +++ b/src/components/SharedComponents/UserListItem.js @@ -1,9 +1,10 @@ // @flow +import { useNavigation } from "@react-navigation/native"; import { Body1, INatIcon, List2, UserIcon } from "components/SharedComponents"; -import { View } from "components/styledComponents"; +import { Pressable, View } from "components/styledComponents"; import type { Node } from "react"; import React from "react"; import User from "realmModels/User"; @@ -18,11 +19,15 @@ type Props = { const UserListItem = ( { item, count, countText }: Props ): Node => { const { t } = useTranslation( ); const user = item?.user; + const navigation = useNavigation( ); return ( - navigation.navigate( "UserProfile", { userId: user?.id } )} + accessibilityLabel={t( "Navigates-to-user-profile" )} > {user?.icon_url @@ -34,12 +39,12 @@ const UserListItem = ( { item, count, countText }: Props ): Node => { /> )} - {user?.login} + {user?.login && {user?.login}} {t( countText, { count } )} - + ); }; diff --git a/src/components/SharedComponents/index.js b/src/components/SharedComponents/index.js index 9950b99f3..98286b870 100644 --- a/src/components/SharedComponents/index.js +++ b/src/components/SharedComponents/index.js @@ -65,5 +65,6 @@ export { default as List2 } from "./Typography/List2"; export { default as Subheading1 } from "./Typography/Subheading1"; export { default as UploadStatus } from "./UploadStatus/UploadStatus"; export { default as UserIcon } from "./UserIcon/UserIcon"; +export { default as UserListItem } from "./UserListItem"; export { default as UserText } from "./UserText"; export { default as ViewWrapper } from "./ViewWrapper"; diff --git a/tests/unit/components/Explore/TaxonGridItem.test.js b/tests/unit/components/Explore/TaxonGridItem.test.js new file mode 100644 index 000000000..98c0dc0ae --- /dev/null +++ b/tests/unit/components/Explore/TaxonGridItem.test.js @@ -0,0 +1,46 @@ +import { + fireEvent, + screen +} from "@testing-library/react-native"; +import TaxonGridItem from "components/Explore/TaxonGridItem"; +import initI18next from "i18n/initI18next"; +import React from "react"; +import factory from "tests/factory"; +import { renderComponent } from "tests/helpers/render"; + +const mockTaxon = factory( "RemoteTaxon" ); + +const mockedNavigate = jest.fn( ); + +jest.mock( "@react-navigation/native", () => { + const actualNav = jest.requireActual( "@react-navigation/native" ); + return { + ...actualNav, + useNavigation: () => ( { + navigate: mockedNavigate + } ) + }; +} ); + +describe( "TaxonGridItem", ( ) => { + beforeAll( async () => { + await initI18next(); + } ); + + it( "should be accessible", ( ) => { + const taxonGridItem = ( + + ); + expect( taxonGridItem ).toBeAccessible(); + } ); + + it( "should navigate to user profile on tap", ( ) => { + renderComponent( ); + fireEvent.press( screen.getByTestId( `TaxonGridItem.Pressable.${mockTaxon.id}` ) ); + expect( mockedNavigate ).toHaveBeenCalledWith( "TaxonDetails", { id: mockTaxon.id } ); + } ); +} ); diff --git a/tests/unit/components/SharedComponents/UserListItem.test.js b/tests/unit/components/SharedComponents/UserListItem.test.js new file mode 100644 index 000000000..75a965c8f --- /dev/null +++ b/tests/unit/components/SharedComponents/UserListItem.test.js @@ -0,0 +1,56 @@ +import { faker } from "@faker-js/faker"; +import { + fireEvent, + screen +} from "@testing-library/react-native"; +import { UserListItem } from "components/SharedComponents"; +import initI18next from "i18n/initI18next"; +import React from "react"; +import factory from "tests/factory"; +import { renderComponent } from "tests/helpers/render"; + +const mockUser = factory( "RemoteUser", { + login: "test123", + id: faker.number.int( ) +} ); + +const mockedNavigate = jest.fn( ); + +jest.mock( "@react-navigation/native", () => { + const actualNav = jest.requireActual( "@react-navigation/native" ); + return { + ...actualNav, + useNavigation: () => ( { + navigate: mockedNavigate + } ) + }; +} ); + +describe( "UserListItem", ( ) => { + beforeAll( async () => { + await initI18next(); + } ); + + it( "should be accessible", ( ) => { + const userListItem = ( + + ); + expect( userListItem ).toBeAccessible(); + } ); + + it( "should navigate to user profile on tap", ( ) => { + renderComponent( ); + fireEvent.press( screen.getByTestId( `UserProfile.${mockUser.id}` ) ); + expect( mockedNavigate ).toHaveBeenCalledWith( "UserProfile", { userId: mockUser.id } ); + } ); +} ); From f5fbc3ce11592a21101fb96e526e51048fee1c46 Mon Sep 17 00:00:00 2001 From: budowski Date: Tue, 13 Feb 2024 18:59:11 -0600 Subject: [PATCH 24/24] Return to original screen after canceling photo import Closes #1051 --------- Co-authored-by: Ken-ichi Ueda --- index.js | 3 +- src/components/AddObsModal.js | 4 +- src/components/Camera/CameraContainer.js | 2 - src/components/Camera/CameraWithDevice.js | 5 +- .../Camera/StandardCamera/StandardCamera.js | 34 ++++++++++++-- .../StandardCamera/hooks/useBackPress.js | 11 ++--- .../MyObservations/MyObservationsContainer.js | 24 ++++++++-- .../ObsEdit/Sheets/AddEvidenceSheet.js | 3 +- src/components/PhotoImporter/PhotoGallery.js | 47 +++++++++++++++---- src/navigation/navigationUtils.js | 21 +++++++++ tests/unit/components/AddObsModal.test.js | 5 +- 11 files changed, 122 insertions(+), 37 deletions(-) create mode 100644 src/navigation/navigationUtils.js diff --git a/index.js b/index.js index 22f7c1037..6364cc405 100644 --- a/index.js +++ b/index.js @@ -25,6 +25,7 @@ import { reactQueryRetry } from "sharedHelpers/logging"; import { name as appName } from "./app.json"; import { log } from "./react-native-logs.config"; import { USER_AGENT } from "./src/components/LoginSignUp/AuthenticationService"; +import { navigationRef } from "./src/navigation/navigationUtils"; enableLatestRenderer( ); @@ -87,7 +88,7 @@ const AppWithProviders = ( ) => ( {/* NavigationContainer needs to be nested above ObsEditProvider */} - + diff --git a/src/components/AddObsModal.js b/src/components/AddObsModal.js index e6fbdaa73..b6f763610 100644 --- a/src/components/AddObsModal.js +++ b/src/components/AddObsModal.js @@ -4,6 +4,7 @@ import { useNavigation } from "@react-navigation/native"; import classnames from "classnames"; import { INatIconButton } from "components/SharedComponents"; import { Text, View } from "components/styledComponents"; +import { getCurrentRoute } from "navigation/navigationUtils"; import * as React from "react"; import { Platform } from "react-native"; import { useTheme } from "react-native-paper"; @@ -31,6 +32,7 @@ const AddObsModal = ( { closeModal }: Props ): React.Node => { const navigation = useNavigation( ); const navAndCloseModal = async ( screen, params ) => { + const currentRoute = getCurrentRoute(); resetStore( ); if ( screen === "ObsEdit" ) { const newObservation = await Observation.new( ); @@ -39,7 +41,7 @@ const AddObsModal = ( { closeModal }: Props ): React.Node => { // access nested screen navigation.navigate( "CameraNavigator", { screen, - params + params: { ...params, previousScreen: currentRoute } } ); closeModal( ); }; diff --git a/src/components/Camera/CameraContainer.js b/src/components/Camera/CameraContainer.js index 4ae33515f..cf941ad48 100644 --- a/src/components/Camera/CameraContainer.js +++ b/src/components/Camera/CameraContainer.js @@ -18,7 +18,6 @@ import CameraWithDevice from "./CameraWithDevice"; const CameraContainer = ( ): Node => { const { params } = useRoute( ); - const backToObsEdit = params?.backToObsEdit; const addEvidence = params?.addEvidence; const cameraType = params?.camera; const [cameraPosition, setCameraPosition] = useState( "back" ); @@ -34,7 +33,6 @@ const CameraContainer = ( ): Node => { return ( { // screen orientation locked to portrait on small devices if ( !isTablet ) { @@ -139,7 +137,6 @@ const CameraWithDevice = ( { ? ( { + const currentRoute = getCurrentRoute(); + if ( currentRoute.params && currentRoute.params.addEvidence ) { + navigation.navigate( "ObsEdit" ); + } else { + const previousScreen = params && params.previousScreen + ? params.previousScreen + : null; + const screenParams = previousScreen && previousScreen.name === "ObsDetails" + ? { + navToObsDetails: true, + uuid: previousScreen.params.uuid + } + : {}; + + navigation.navigate( "TabNavigator", { + screen: "ObservationsStackNavigator", + params: { + screen: "ObsList", + params: screenParams + } + } ); + } + }; const { handleBackButtonPress, setShowDiscardSheet, showDiscardSheet - } = useBackPress( backToObsEdit ); + } = useBackPress( onBack ); const { takePhoto, takePhotoOptions, @@ -83,7 +108,6 @@ const StandardCamera = ( { toggleFlash } = useTakePhoto( camera, addEvidence, device ); - const navigation = useNavigation( ); const { t } = useTranslation( ); const cameraPreviewUris = useStore( state => state.cameraPreviewUris ); diff --git a/src/components/Camera/StandardCamera/hooks/useBackPress.js b/src/components/Camera/StandardCamera/hooks/useBackPress.js index c170c42d0..aa18fdf1d 100644 --- a/src/components/Camera/StandardCamera/hooks/useBackPress.js +++ b/src/components/Camera/StandardCamera/hooks/useBackPress.js @@ -1,6 +1,6 @@ // @flow -import { useFocusEffect, useNavigation } from "@react-navigation/native"; +import { useFocusEffect } from "@react-navigation/native"; import { useCallback, useState @@ -10,20 +10,17 @@ import { } from "react-native"; import useStore from "stores/useStore"; -const useBackPress = ( backToObsEdit: ?boolean ): Object => { +const useBackPress = ( onBack: Function ): Object => { const [showDiscardSheet, setShowDiscardSheet] = useState( false ); - const navigation = useNavigation( ); const cameraPreviewUris = useStore( state => state.cameraPreviewUris ); const handleBackButtonPress = useCallback( ( ) => { if ( cameraPreviewUris.length > 0 ) { setShowDiscardSheet( true ); - } else if ( backToObsEdit ) { - navigation.navigate( "ObsEdit" ); } else { - navigation.goBack( ); + onBack(); } - }, [backToObsEdit, setShowDiscardSheet, cameraPreviewUris, navigation] ); + }, [setShowDiscardSheet, cameraPreviewUris, onBack] ); useFocusEffect( // note: cannot use navigation.addListener to trigger bottom sheet in tab navigator diff --git a/src/components/MyObservations/MyObservationsContainer.js b/src/components/MyObservations/MyObservationsContainer.js index 06798ce1e..c0e8764c1 100644 --- a/src/components/MyObservations/MyObservationsContainer.js +++ b/src/components/MyObservations/MyObservationsContainer.js @@ -7,6 +7,7 @@ import { } from "api/observations"; import { getJWT } from "components/LoginSignUp/AuthenticationService"; import { format } from "date-fns"; +import { navigationRef } from "navigation/navigationUtils"; import { RealmContext } from "providers/contexts"; import type { Node } from "react"; import React, { @@ -123,6 +124,7 @@ const { useRealm } = RealmContext; const MyObservationsContainer = ( ): Node => { const navigation = useNavigation( ); + const { params } = useRoute( ); const { t } = useTranslation( ); const realm = useRealm( ); const allObsToUpload = Observation.filterUnsyncedObservations( realm ); @@ -171,6 +173,18 @@ const MyObservationsContainer = ( ): Node => { : "grid" ); }; + useEffect( () => { + if ( navigationRef && navigationRef.isReady() ) { + if ( params && params.navToObsDetails ) { + // We wrap this in a setTimeout, since otherwise this routing doesn't work immediately + // when loading this screen + setTimeout( () => { + navigation.navigate( "ObsDetails", { uuid: params.uuid } ); + }, 100 ); + } + } + }, [navigation, params] ); + useEffect( ( ) => { // show progress in toolbar for observations uploaded on ObsEdit if ( navParams?.uuid && !state.uploadInProgress && currentUser ) { @@ -297,12 +311,12 @@ const MyObservationsContainer = ( ): Node => { const downloadRemoteObservationsFromServer = useCallback( async ( ) => { const apiToken = await getJWT( ); - const params = { + const searchParams = { user_id: currentUser?.id, per_page: 50, fields: Observation.FIELDS }; - const { results } = await searchObservations( params, { api_token: apiToken } ); + const { results } = await searchObservations( searchParams, { api_token: apiToken } ); Observation.upsertRemoteObservations( results, realm ); }, [currentUser, realm] ); @@ -311,10 +325,10 @@ const MyObservationsContainer = ( ): Node => { const syncRemoteDeletedObservations = useCallback( async ( ) => { const apiToken = await getJWT( ); const lastSyncTime = realm.objects( "LocalPreferences" )?.[0]?.last_sync_time; - const params = { since: format( new Date( ), "yyyy-MM-dd" ) }; + const deletedParams = { since: format( new Date( ), "yyyy-MM-dd" ) }; if ( lastSyncTime ) { try { - params.since = format( lastSyncTime, "yyyy-MM-dd" ); + deletedParams.since = format( lastSyncTime, "yyyy-MM-dd" ); } catch ( lastSyncTimeFormatError ) { if ( lastSyncTimeFormatError instanceof RangeError ) { // If we can't parse that date, assume we've never synced and use the default @@ -323,7 +337,7 @@ const MyObservationsContainer = ( ): Node => { } } } - const response = await checkForDeletedObservations( params, { api_token: apiToken } ); + const response = await checkForDeletedObservations( deletedParams, { api_token: apiToken } ); const deletedObservations = response?.results; if ( !deletedObservations ) { return; } if ( deletedObservations?.length > 0 ) { diff --git a/src/components/ObsEdit/Sheets/AddEvidenceSheet.js b/src/components/ObsEdit/Sheets/AddEvidenceSheet.js index 601ac2f9c..0c0d07169 100644 --- a/src/components/ObsEdit/Sheets/AddEvidenceSheet.js +++ b/src/components/ObsEdit/Sheets/AddEvidenceSheet.js @@ -39,8 +39,7 @@ const AddEvidenceSheet = ( { screen: "Camera", params: { addEvidence: true, - camera: "Standard", - backToObsEdit: true + camera: "Standard" } } ); } else if ( choice === "import" ) { diff --git a/src/components/PhotoImporter/PhotoGallery.js b/src/components/PhotoImporter/PhotoGallery.js index 5028c8a7a..b83bb3583 100644 --- a/src/components/PhotoImporter/PhotoGallery.js +++ b/src/components/PhotoImporter/PhotoGallery.js @@ -5,6 +5,7 @@ import PermissionGateContainer, { READ_MEDIA_PERMISSIONS } import { t } from "i18next"; import type { Node } from "react"; import React, { + useCallback, useState } from "react"; import { @@ -46,6 +47,31 @@ const PhotoGallery = ( ): Node => { ? params.fromGroupPhotos : false; + const navToObsList = useCallback( ( ) => { + navigation.navigate( "TabNavigator", { + screen: "ObservationsStackNavigator", + params: { + screen: "ObsList" + } + } ); + }, [navigation] ); + + const navToObsDetails = useCallback( uuid => navigation.navigate( "TabNavigator", { + screen: "ObservationsStackNavigator", + params: { + // Need to return to ObsDetails but with a navigation stack that goes back to ObsList + screen: "ObsList", + params: { + navToObsDetails: true, + uuid + } + } + } ), [navigation] ); + + const navToObsEdit = useCallback( ( ) => navigation.navigate( "ObsEdit", { + lastScreen: "PhotoGallery" + } ), [navigation] ); + const showPhotoGallery = React.useCallback( async () => { if ( photoGalleryShown ) { return; @@ -78,8 +104,15 @@ const PhotoGallery = ( ): Node => { // This screen was called from the plus button of the group photos screen - get back to it navigation.navigate( "CameraNavigator", { screen: "GroupPhotos" } ); navigation.setParams( { fromGroupPhotos: false } ); + } else if ( skipGroupPhotos ) { + // This only happens when being called from ObsEdit + navToObsEdit(); + + // Determine if we need to go back to ObsList or ObsDetails screen + } else if ( params && params.previousScreen && params.previousScreen.name === "ObsDetails" ) { + navToObsDetails( params.previousScreen.params.uuid ); } else { - navigation.goBack(); + navToObsList(); } setPhotoGalleryShown( false ); return; @@ -99,8 +132,6 @@ const PhotoGallery = ( ): Node => { return; } - const navToObsEdit = () => navigation.navigate( "ObsEdit", { lastScreen: "PhotoGallery" } ); - if ( skipGroupPhotos ) { // add evidence to existing observation setPhotoImporterState( { @@ -136,11 +167,11 @@ const PhotoGallery = ( ): Node => { setPhotoGalleryShown( false ); } }, [ - photoGalleryShown, numOfObsPhotos, setPhotoImporterState, - evidenceToAdd, galleryUris, navigation, setGroupedPhotos, - fromGroupPhotos, skipGroupPhotos, groupedPhotos, currentObservation, - updateObservations, observations, - currentObservationIndex] ); + navToObsEdit, navToObsList, photoGalleryShown, numOfObsPhotos, setPhotoImporterState, + evidenceToAdd, galleryUris, navigation, setGroupedPhotos, fromGroupPhotos, skipGroupPhotos, + groupedPhotos, currentObservation, updateObservations, observations, currentObservationIndex, + navToObsDetails, params + ] ); const onPermissionGranted = () => { setPermissionGranted( true ); diff --git a/src/navigation/navigationUtils.js b/src/navigation/navigationUtils.js new file mode 100644 index 000000000..30b6c8779 --- /dev/null +++ b/src/navigation/navigationUtils.js @@ -0,0 +1,21 @@ +import { createNavigationContainerRef } from "@react-navigation/native"; + +export const navigationRef = createNavigationContainerRef(); + +// Returns current active route +export function getCurrentRoute() { + if ( navigationRef.isReady() ) { + // Get the root navigator state + const rootState = navigationRef.getRootState(); + + // Find the active route in the navigation state + let route = rootState.routes[rootState.index]; + while ( route.state && route.state.routes ) { + route = route.state.routes[route.state.index]; + } + + return route; + } + + return null; +} diff --git a/tests/unit/components/AddObsModal.test.js b/tests/unit/components/AddObsModal.test.js index 8815afd6a..22c0a591a 100644 --- a/tests/unit/components/AddObsModal.test.js +++ b/tests/unit/components/AddObsModal.test.js @@ -42,7 +42,8 @@ describe( "AddObsModal", ( ) => { fireEvent.press( noEvidenceButton ); await waitFor( ( ) => { expect( mockNavigate ).toHaveBeenCalledWith( "CameraNavigator", { - screen: "ObsEdit" + screen: "ObsEdit", + params: { previousScreen: null } } ); } ); } ); @@ -56,7 +57,7 @@ describe( "AddObsModal", ( ) => { fireEvent.press( arCameraButton ); expect( mockNavigate ).toHaveBeenCalledWith( "CameraNavigator", { screen: "Camera", - params: { camera: "AR" } + params: { camera: "AR", previousScreen: null } } ); } );