From d79306ffee92f6f531c8ce17dddad8d92d85d5d9 Mon Sep 17 00:00:00 2001 From: Amanda Bullington <35536439+albullington@users.noreply.github.com> Date: Fri, 15 Mar 2024 09:49:41 +1100 Subject: [PATCH] Cache user icon to resolve flicker (#1255) * Use FastImage to load & cache user icon * Fetch user icon before leaving login screen --- ios/Podfile.lock | 32 +++ package-lock.json | 16 ++ package.json | 1 + src/components/App.js | 1 + .../LoginSignUp/AuthenticationService.js | 19 +- .../SharedComponents/UserIcon/UserIcon.js | 8 +- src/components/styledComponents.js | 6 + .../CustomTabBarContainer.js | 22 +- .../BottomTabNavigator/NavButton.js | 4 +- src/navigation/BottomTabNavigator/index.js | 4 +- src/sharedHooks/useUserMe.js | 6 +- tests/jest.setup.js | 8 + .../__snapshots__/UserIcon.test.js.snap | 197 +++++++++++------- 13 files changed, 225 insertions(+), 99 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index c1fdc276c..a6d7e6745 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -17,6 +17,18 @@ PODS: - hermes-engine/Pre-built (= 0.71.16) - hermes-engine/Pre-built (0.71.16) - libevent (2.1.12) + - libwebp (1.3.2): + - libwebp/demux (= 1.3.2) + - libwebp/mux (= 1.3.2) + - libwebp/sharpyuv (= 1.3.2) + - libwebp/webp (= 1.3.2) + - libwebp/demux (1.3.2): + - libwebp/webp + - libwebp/mux (1.3.2): + - libwebp/demux + - libwebp/sharpyuv (1.3.2) + - libwebp/webp (1.3.2): + - libwebp/sharpyuv - RCT-Folly (2021.07.22.00): - boost - DoubleConversion @@ -409,6 +421,10 @@ PODS: - React-Core - RNDeviceInfo (10.12.0): - React-Core + - RNFastImage (8.6.3): + - React-Core + - SDWebImage (~> 5.11.1) + - SDWebImageWebPCoder (~> 0.8.4) - RNFlashList (1.6.3): - React-Core - RNFS (2.20.0): @@ -457,6 +473,12 @@ PODS: - React-Core - RNVectorIcons (9.2.0): - React-Core + - SDWebImage (5.11.1): + - SDWebImage/Core (= 5.11.1) + - SDWebImage/Core (5.11.1) + - SDWebImageWebPCoder (0.8.5): + - libwebp (~> 1.0) + - SDWebImage/Core (~> 5.10) - VisionCamera (2.15.6): - React - React-callinvoker @@ -528,6 +550,7 @@ DEPENDENCIES: - "RNCPicker (from `../node_modules/@react-native-picker/picker`)" - "RNDateTimePicker (from `../node_modules/@react-native-community/datetimepicker`)" - RNDeviceInfo (from `../node_modules/react-native-device-info`) + - RNFastImage (from `../node_modules/react-native-fast-image`) - "RNFlashList (from `../node_modules/@shopify/flash-list`)" - RNFS (from `../node_modules/react-native-fs`) - RNGestureHandler (from `../node_modules/react-native-gesture-handler`) @@ -547,6 +570,9 @@ SPEC REPOS: trunk: - fmt - libevent + - libwebp + - SDWebImage + - SDWebImageWebPCoder EXTERNAL SOURCES: boost: @@ -669,6 +695,8 @@ EXTERNAL SOURCES: :path: "../node_modules/@react-native-community/datetimepicker" RNDeviceInfo: :path: "../node_modules/react-native-device-info" + RNFastImage: + :path: "../node_modules/react-native-fast-image" RNFlashList: :path: "../node_modules/@shopify/flash-list" RNFS: @@ -708,6 +736,7 @@ SPEC CHECKSUMS: glog: 04b94705f318337d7ead9e6d17c019bd9b1f6b1b hermes-engine: 2382506846564caf4152c45390dc24f08fce7057 libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913 + libwebp: 1786c9f4ff8a279e4dac1e8f385004d5fc253009 RCT-Folly: 424b8c9a7a0b9ab2886ffe9c3b041ef628fd4fb1 RCTRequired: 44a3cda52ccac0be738fbf43fef90f3546a48c52 RCTTypeSafety: da7fbf9826fc898ca8b10dc840f2685562039a64 @@ -761,6 +790,7 @@ SPEC CHECKSUMS: RNCPicker: 32ca102146bc7d34a8b93a998d9938d9f9ec7898 RNDateTimePicker: ccd988deb223cbb2e669e157ec576c2c6217128c RNDeviceInfo: db5c64a060e66e5db3102d041ebe3ef307a85120 + RNFastImage: 5c9c9fed9c076e521b3f509fe79e790418a544e8 RNFlashList: 4b4b6b093afc0df60ae08f9cbf6ccd4c836c667a RNFS: 4ac0f0ea233904cb798630b3c077808c06931688 RNGestureHandler: 6e4dc6b7ab3a385386d4e36228bd065e5a611394 @@ -772,6 +802,8 @@ SPEC CHECKSUMS: RNStoreReview: 31dbfd0dac2eea9675f0b84f1dd3261c2110c337 RNSVG: d00c8f91c3cbf6d476451313a18f04d220d4f396 RNVectorIcons: fcc2f6cb32f5735b586e66d14103a74ce6ad61f8 + SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d + SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d VisionCamera: 523b49054bee9dace64189ab6631cb41e8b83fe0 VisionCameraPluginInatVision: 7e09a4ca0b34dd81afd4b68aa26a27eff5bb8fd4 Yoga: e29645ec5a66fb00934fad85338742d1c247d4cb diff --git a/package-lock.json b/package-lock.json index fcae4a6f4..d3c6c8973 100644 --- a/package-lock.json +++ b/package-lock.json @@ -60,6 +60,7 @@ "react-native-event-listeners": "^1.0.7", "react-native-exception-handler": "^2.10.10", "react-native-exif-reader": "github:inaturalist/react-native-exif-reader", + "react-native-fast-image": "^8.6.3", "react-native-fs": "^2.20.0", "react-native-geocoder-reborn": "^0.9.0", "react-native-gesture-handler": "^2.13.0", @@ -22801,6 +22802,15 @@ "react-native": "*" } }, + "node_modules/react-native-fast-image": { + "version": "8.6.3", + "resolved": "https://registry.npmjs.org/react-native-fast-image/-/react-native-fast-image-8.6.3.tgz", + "integrity": "sha512-Sdw4ESidXCXOmQ9EcYguNY2swyoWmx53kym2zRsvi+VeFCHEdkO+WG1DK+6W81juot40bbfLNhkc63QnWtesNg==", + "peerDependencies": { + "react": "^17 || ^18", + "react-native": ">=0.60.0" + } + }, "node_modules/react-native-fs": { "version": "2.20.0", "resolved": "https://registry.npmjs.org/react-native-fs/-/react-native-fs-2.20.0.tgz", @@ -42885,6 +42895,12 @@ "from": "react-native-exif-reader@github:inaturalist/react-native-exif-reader", "requires": {} }, + "react-native-fast-image": { + "version": "8.6.3", + "resolved": "https://registry.npmjs.org/react-native-fast-image/-/react-native-fast-image-8.6.3.tgz", + "integrity": "sha512-Sdw4ESidXCXOmQ9EcYguNY2swyoWmx53kym2zRsvi+VeFCHEdkO+WG1DK+6W81juot40bbfLNhkc63QnWtesNg==", + "requires": {} + }, "react-native-fs": { "version": "2.20.0", "resolved": "https://registry.npmjs.org/react-native-fs/-/react-native-fs-2.20.0.tgz", diff --git a/package.json b/package.json index d120b870b..5475feb04 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "react-native-event-listeners": "^1.0.7", "react-native-exception-handler": "^2.10.10", "react-native-exif-reader": "github:inaturalist/react-native-exif-reader", + "react-native-fast-image": "^8.6.3", "react-native-fs": "^2.20.0", "react-native-geocoder-reborn": "^0.9.0", "react-native-gesture-handler": "^2.13.0", diff --git a/src/components/App.js b/src/components/App.js index c976f787f..df5ad0af8 100644 --- a/src/components/App.js +++ b/src/components/App.js @@ -42,6 +42,7 @@ type Props = { const App = ( { children }: Props ): Node => { const realm = useRealm( ); const currentUser = useCurrentUser( ); + useIconicTaxa( { reload: true } ); useReactQueryRefetch( ); useFreshInstall( currentUser ); diff --git a/src/components/LoginSignUp/AuthenticationService.js b/src/components/LoginSignUp/AuthenticationService.js index 25eb87e58..30243c51a 100644 --- a/src/components/LoginSignUp/AuthenticationService.js +++ b/src/components/LoginSignUp/AuthenticationService.js @@ -1,5 +1,6 @@ // @flow import { getUserAgent } from "api/userAgent"; +import { fetchUserMe } from "api/users"; import { create } from "apisauce"; import axios from "axios"; import i18next from "i18next"; @@ -348,9 +349,23 @@ const authenticateUser = async ( // Save userId to local, encrypted storage const currentUser = { id: userId, login: remoteUsername, signedIn: true }; - logger.debug( "writing current user to realm: ", currentUser ); + + // try to fetch user data (especially for loading user icon) from userMe + const apiToken = await getJWT( ); + const options = { + api_token: apiToken + }; + const remoteUser = await fetchUserMe( { }, options ); + const localUser = remoteUser + ? { + ...remoteUser, + signedIn: true + } + : currentUser; + + logger.debug( "writing current user to realm: ", localUser ); safeRealmWrite( realm, ( ) => { - realm.create( "User", currentUser, "modified" ); + realm.create( "User", localUser, "modified" ); }, "saving current user in AuthenticationService" ); const currentRealmUser = User.currentUser( realm ); logger.debug( "Signed in", currentRealmUser.login, currentRealmUser.id, currentRealmUser ); diff --git a/src/components/SharedComponents/UserIcon/UserIcon.js b/src/components/SharedComponents/UserIcon/UserIcon.js index 1db82c68b..eb518daf4 100644 --- a/src/components/SharedComponents/UserIcon/UserIcon.js +++ b/src/components/SharedComponents/UserIcon/UserIcon.js @@ -1,8 +1,9 @@ // @flow import classNames from "classnames"; -import { Image } from "components/styledComponents"; -import * as React from "react"; +import { FastImage as Image } from "components/styledComponents"; +import type { Node } from "react"; +import React from "react"; import colors from "styles/tailwindColors"; type Props = { @@ -15,7 +16,7 @@ type Props = { const UserIcon = ( { uri, small, active, large, medium -}: Props ): React.Node => { +}: Props ): Node => { const getSize = ( ) => { if ( small ) { return "w-[22px] h-[22px]"; @@ -34,6 +35,7 @@ const UserIcon = ( { "rounded-full", size ); + // For unknown reasons, the green border doesn't show up on Android using nativewind classNames // but it works with style, might warrant further investigation or an issue in nativewind const style = { borderColor: colors.inatGreen, borderWidth: 3 }; diff --git a/src/components/styledComponents.js b/src/components/styledComponents.js index f410010d3..e98516b88 100644 --- a/src/components/styledComponents.js +++ b/src/components/styledComponents.js @@ -17,6 +17,8 @@ import { TextInput as UntyledTextInput, View as UnstyledView } from "react-native"; +// eslint-disable-next-line import/no-extraneous-dependencies +import UnstyledFastImage from "react-native-fast-image"; import UnstyledLinearGradient from "react-native-linear-gradient"; // $FlowIgnore @@ -68,8 +70,12 @@ const fontMonoClass: string = ( Platform.OS === "ios" // $FlowIgnore const LinearGradient = styled( UnstyledLinearGradient ); +// $FlowIgnore +const FastImage = styled( UnstyledFastImage ); + export { BottomSheetTextInput, + FastImage, fontMonoClass, Image, ImageBackground, diff --git a/src/navigation/BottomTabNavigator/CustomTabBarContainer.js b/src/navigation/BottomTabNavigator/CustomTabBarContainer.js index 34791401a..06cf585ce 100644 --- a/src/navigation/BottomTabNavigator/CustomTabBarContainer.js +++ b/src/navigation/BottomTabNavigator/CustomTabBarContainer.js @@ -13,11 +13,10 @@ const OBS_LIST_SCREEN_ID = "ObservationsStackNavigator"; const NOTIFICATIONS_SCREEN_ID = "Notifications"; type Props = { - navigation: Object, - isOnline: boolean + navigation: Object }; -const CustomTabBarContainer = ( { navigation, isOnline }: Props ): Node => { +const CustomTabBarContainer = ( { navigation }: Props ): Node => { const { t } = useTranslation( ); const currentUser = useCurrentUser( ); const [activeTab, setActiveTab] = useState( OBS_LIST_SCREEN_ID ); @@ -29,8 +28,6 @@ const CustomTabBarContainer = ( { navigation, isOnline }: Props ): Node => { testID: DRAWER_ID, accessibilityLabel: t( "Open-drawer" ), accessibilityHint: t( "Opens-the-side-drawer-menu" ), - width: 44, - height: 44, size: 32, onPress: ( ) => { navigation.openDrawer( ); @@ -43,8 +40,6 @@ const CustomTabBarContainer = ( { navigation, isOnline }: Props ): Node => { testID: EXPLORE_SCREEN_ID, accessibilityLabel: t( "Explore" ), accessibilityHint: t( "Navigates-to-explore" ), - width: 44, - height: 44, size: 40, onPress: ( ) => { navigation.navigate( "ObservationsStackNavigator", { @@ -56,16 +51,10 @@ const CustomTabBarContainer = ( { navigation, isOnline }: Props ): Node => { }, { icon: "person", - userIconUri: isOnline - ? User.uri( currentUser ) - : null, - testID: User.uri( currentUser ) && isOnline - ? "NavButton.avatar" - : "NavButton.personIcon", + userIconUri: User.uri( currentUser ), + testID: "NavButton.personIcon", accessibilityLabel: t( "Observations" ), accessibilityHint: t( "Navigates-to-observations" ), - width: 44, - height: 44, size: 40, onPress: ( ) => { navigation.navigate( "ObservationsStackNavigator", { @@ -80,8 +69,6 @@ const CustomTabBarContainer = ( { navigation, isOnline }: Props ): Node => { testID: NOTIFICATIONS_SCREEN_ID, accessibilityLabel: t( "Notifications" ), accessibilityHint: t( "Navigates-to-notifications" ), - width: 44, - height: 44, size: 32, onPress: ( ) => { navigation.reset( { @@ -94,7 +81,6 @@ const CustomTabBarContainer = ( { navigation, isOnline }: Props ): Node => { } ] ), [ activeTab, - isOnline, currentUser, isDrawerOpen, navigation, diff --git a/src/navigation/BottomTabNavigator/NavButton.js b/src/navigation/BottomTabNavigator/NavButton.js index e2801fe83..d14b9e542 100644 --- a/src/navigation/BottomTabNavigator/NavButton.js +++ b/src/navigation/BottomTabNavigator/NavButton.js @@ -29,8 +29,8 @@ const NavButton = ( { accessibilityLabel, accessibilityHint, accessibilityRole = "tab", - width, - height + width = 44, + height = 44 }: Props ): React.Node => { /* eslint-disable react/jsx-props-no-spreading */ const sharedProps = { diff --git a/src/navigation/BottomTabNavigator/index.js b/src/navigation/BottomTabNavigator/index.js index b151b718e..ce49bde32 100644 --- a/src/navigation/BottomTabNavigator/index.js +++ b/src/navigation/BottomTabNavigator/index.js @@ -17,7 +17,6 @@ import DeveloperStackNavigator from "navigation/StackNavigators/DeveloperStackNa import ObservationsStackNavigator from "navigation/StackNavigators/ObservationsStackNavigator"; import ProjectsStackNavigator from "navigation/StackNavigators/ProjectsStackNavigator"; import React from "react"; -import { useIsConnected } from "sharedHooks"; import CustomTabBarContainer from "./CustomTabBarContainer"; @@ -28,8 +27,7 @@ const OBS_LIST_SCREEN_ID = "ObservationsStackNavigator"; /* eslint-disable react/jsx-props-no-spreading */ const BottomTabs = ( ) => { - const isOnline = useIsConnected( ); - const renderTabBar = props => ; + const renderTabBar = props => ; const aboutTitle = () => {t( "ABOUT-INATURALIST" )}; const donateTitle = () => {t( "DONATE" )}; diff --git a/src/sharedHooks/useUserMe.js b/src/sharedHooks/useUserMe.js index 9f914b45b..3e39286e3 100644 --- a/src/sharedHooks/useUserMe.js +++ b/src/sharedHooks/useUserMe.js @@ -3,7 +3,11 @@ import { fetchUserMe } from "api/users"; import { RealmContext } from "providers/contexts"; import { useCallback, useEffect } from "react"; import safeRealmWrite from "sharedHelpers/safeRealmWrite"; -import { useAuthenticatedQuery, useCurrentUser, useIsConnected } from "sharedHooks"; +import { + useAuthenticatedQuery, + useCurrentUser, + useIsConnected +} from "sharedHooks"; const { useRealm } = RealmContext; diff --git a/tests/jest.setup.js b/tests/jest.setup.js index 2f4b0e4d1..5c278ffa1 100644 --- a/tests/jest.setup.js +++ b/tests/jest.setup.js @@ -378,3 +378,11 @@ inatjs.announcements.search.mockResolvedValue( makeResponse( ) ); inatjs.observations.updates.mockResolvedValue( makeResponse( ) ); jest.mock( "react-native-audio-recorder-player", ( ) => MockAudioRecorderPlayer ); + +jest.mock( "react-native-fast-image", ( ) => { + const actualNav = jest.requireActual( "react-native-fast-image" ); + return { + ...actualNav, + preload: jest.fn( ) + }; +} ); diff --git a/tests/unit/components/SharedComponents/UserIcon/__snapshots__/UserIcon.test.js.snap b/tests/unit/components/SharedComponents/UserIcon/__snapshots__/UserIcon.test.js.snap index 7d8118363..0704cf72f 100644 --- a/tests/unit/components/SharedComponents/UserIcon/__snapshots__/UserIcon.test.js.snap +++ b/tests/unit/components/SharedComponents/UserIcon/__snapshots__/UserIcon.test.js.snap @@ -1,98 +1,155 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`UserIcon displays active user image correctly 1`] = ` - +> + + `; exports[`UserIcon displays small user image correctly 1`] = ` - +> + + `; exports[`UserIcon displays user image correctly 1`] = ` - +> + + `;