diff --git a/e2e/sharedFlows/switchPowerMode.js b/e2e/sharedFlows/switchPowerMode.js index fa8df438b..86f5e1b22 100644 --- a/e2e/sharedFlows/switchPowerMode.js +++ b/e2e/sharedFlows/switchPowerMode.js @@ -18,4 +18,8 @@ export default async function switchPowerMode() { const powerUserRadioButton = element( by.id( "all-observation-options" ) ); await waitFor( powerUserRadioButton ).toBeVisible().withTimeout( 10000 ); await powerUserRadioButton.tap(); + // Tap the settings radio button for suggestions flow first mode + const suggestionsFlowButton = element( by.id( "suggestions-flow-mode" ) ); + await waitFor( suggestionsFlowButton ).toBeVisible().withTimeout( 10000 ); + await suggestionsFlowButton.tap(); } diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 8f27382c9..404172d4f 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -39,9 +39,9 @@ PODS: - hermes-engine/Pre-built (= 0.73.11) - hermes-engine/Pre-built (0.73.11) - libevent (2.1.12) - - MMKV (2.0.2): - - MMKVCore (~> 2.0.2) - - MMKVCore (2.0.2) + - MMKV (2.1.0): + - MMKVCore (~> 2.1.0) + - MMKVCore (2.1.1) - Mute (0.6.1) - RCT-Folly (2022.05.16.00): - boost @@ -1206,7 +1206,7 @@ PODS: - VisionCamera/React (4.0.5): - React-Core - VisionCamera/FrameProcessors - - VisionCameraPluginInatVision (4.2.2): + - VisionCameraPluginInatVision (5.0.0): - React-Core - Yoga (1.14.0) @@ -1528,8 +1528,8 @@ SPEC CHECKSUMS: GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 hermes-engine: d992945b77c506e5164e6a9a77510c9d57472c59 libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913 - MMKV: 3eacda84cd1c4fc95cf848d3ecb69d85ed56006c - MMKVCore: 508b4d3a8ce031f1b5c8bd235f0517fb3f4c73a9 + MMKV: ce484c1ac40bf76d5f09a0195d2ec5b3d3840d55 + MMKVCore: 1eb661c6c498ab88e3df9ce5d8ff94d05fcc0567 Mute: 20135a96076f140cc82bfc8b810e2d6150d8ec7e RCT-Folly: cd21f1661364f975ae76b3308167ad66b09f53f5 RCTRequired: 415e56f7c33799a6483e41e4dce607f3daf1e69b @@ -1616,7 +1616,7 @@ SPEC CHECKSUMS: RNVectorIcons: 102cd20472bf0d7cd15443d43cd87f9c97228ac3 SocketRocket: f32cd54efbe0f095c4d7594881e52619cfe80b17 VisionCamera: f02de0b1b6b1516b327bd8215237a97e7386db8a - VisionCameraPluginInatVision: 69370c8be26b9b7372996d423f4fdb4c94e42ab1 + VisionCameraPluginInatVision: 54906a97413a22b55ca21ed674cb4b2b9af9ccc5 Yoga: 1f93d5925ea12fb0880b21efe3566677337cf2ed PODFILE CHECKSUM: eff4b75123af5d6680139a78c055b44ad37c269b diff --git a/ios/iNaturalistReactNative.xcodeproj/project.pbxproj b/ios/iNaturalistReactNative.xcodeproj/project.pbxproj index 546a105aa..25be688c9 100644 --- a/ios/iNaturalistReactNative.xcodeproj/project.pbxproj +++ b/ios/iNaturalistReactNative.xcodeproj/project.pbxproj @@ -23,6 +23,7 @@ 54EB1EFEC1F74152902EED02 /* Lato-BoldItalic.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 8C2D97D72EED451C887998A8 /* Lato-BoldItalic.ttf */; }; 5A8D64AB921678B40E0229C8 /* BuildFile in Frameworks */ = {isa = PBXBuildFile; }; 725BA058C5384A9185E8036A /* Lato-Bold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 00752F4ADC554701A45A848A /* Lato-Bold.ttf */; }; + 79C7ABE5C4BF4E65826F8414 /* INatIcon.ttf in Resources */ = {isa = PBXBuildFile; fileRef = F3AB9FDE3E7D47CA9DB7D276 /* INatIcon.ttf */; }; 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; }; 8B65ED3129F575C10054CCEF /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 8B65ED2F29F575C10054CCEF /* MainInterface.storyboard */; }; 8B65ED3529F575C10054CCEF /* iNaturalistReactNative-ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 8B65ED2B29F575C10054CCEF /* iNaturalistReactNative-ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -34,7 +35,6 @@ AE4DC81B3A87484CB3FD6750 /* Lato-Regular.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 4B0AEEF6CA584BCF9880EB35 /* Lato-Regular.ttf */; }; E23E0899594A7C6DF680FFDB /* libPods-iNaturalistReactNative-ShareExtension.a in Frameworks */ = {isa = PBXBuildFile; fileRef = A336AF0ADEAE537AB1B73F98 /* libPods-iNaturalistReactNative-ShareExtension.a */; }; E5DFC1C6FBFA45739CE91C69 /* Lato-MediumItalic.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 69DF855D92EA4ADFB73B47F1 /* Lato-MediumItalic.ttf */; }; - 79C7ABE5C4BF4E65826F8414 /* INatIcon.ttf in Resources */ = {isa = PBXBuildFile; fileRef = F3AB9FDE3E7D47CA9DB7D276 /* INatIcon.ttf */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -107,7 +107,7 @@ D7AE5BDBC584A83878A04344 /* 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 = ""; }; D8663889EABFBFC3077401E3 /* 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; }; - F3AB9FDE3E7D47CA9DB7D276 /* INatIcon.ttf */ = {isa = PBXFileReference; name = "INatIcon.ttf"; path = "../assets/fonts/INatIcon.ttf"; sourceTree = ""; fileEncoding = undefined; lastKnownFileType = unknown; explicitFileType = undefined; includeInIndex = 0; }; + F3AB9FDE3E7D47CA9DB7D276 /* INatIcon.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = undefined; includeInIndex = 0; lastKnownFileType = unknown; name = INatIcon.ttf; path = ../assets/fonts/INatIcon.ttf; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ diff --git a/package-lock.json b/package-lock.json index 5d849c10c..f1ebde401 100644 --- a/package-lock.json +++ b/package-lock.json @@ -104,7 +104,7 @@ "sanitize-html": "^2.13.0", "ts-jest": "^29.1.2", "uuid": "^11.0.5", - "vision-camera-plugin-inatvision": "github:inaturalist/vision-camera-plugin-inatvision#fb3c35d3b19c9bdeb4c0de359f3c1b8133b2a8eb", + "vision-camera-plugin-inatvision": "github:inaturalist/vision-camera-plugin-inatvision#83b1e532f00e25a51e7408acbe3c5b178370013b", "zustand": "^4.5.2" }, "devDependencies": { @@ -20725,9 +20725,9 @@ } }, "node_modules/vision-camera-plugin-inatvision": { - "version": "4.2.2", - "resolved": "git+ssh://git@github.com/inaturalist/vision-camera-plugin-inatvision.git#fb3c35d3b19c9bdeb4c0de359f3c1b8133b2a8eb", - "integrity": "sha512-pybIhPdLCgDOVurJmoDpnGxlaip6JLs8gAiHHPgy/NcO3aljJaWJhLqOex8vd4Ofk1G4mKc7Q528/Yg+1qqQZA==", + "version": "5.0.0", + "resolved": "git+ssh://git@github.com/inaturalist/vision-camera-plugin-inatvision.git#83b1e532f00e25a51e7408acbe3c5b178370013b", + "integrity": "sha512-cdd9yCwD2eghoPSk8hPJ9EkFeWEtQucGj4M19zfi5k3E3piSc9QPOE2T+ab7e55Uo8ROD1USVxQVn8++9mBL2g==", "license": "MIT", "dependencies": { "h3-js": "^4.1.0" diff --git a/package.json b/package.json index 9e89f013c..ee7f88bf9 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "test:memory": "jest --runInBand --logHeapUsage", "lint": "npm run lint:eslint && npm run lint:flow && npm run lint:rubocop", "lint:fix": "npm run lint:eslint:fix && npm run lint:flow && npm run lint:rubocop:fix", - "lint:staged:fix": "npx lint-staged", + "lint:staged:fix": "npx lint-staged && npm run lint:flow", "lint:eslint": "eslint .", "lint:eslint:fix": "eslint . --fix", "lint:flow": "flow check", @@ -138,7 +138,7 @@ "sanitize-html": "^2.13.0", "ts-jest": "^29.1.2", "uuid": "^11.0.5", - "vision-camera-plugin-inatvision": "github:inaturalist/vision-camera-plugin-inatvision#fb3c35d3b19c9bdeb4c0de359f3c1b8133b2a8eb", + "vision-camera-plugin-inatvision": "github:inaturalist/vision-camera-plugin-inatvision#83b1e532f00e25a51e7408acbe3c5b178370013b", "zustand": "^4.5.2" }, "devDependencies": { @@ -207,8 +207,7 @@ "packageManager": "yarn@3.6.4", "lint-staged": { "*.{js,jsx,ts,tsx}": [ - "eslint --cache --fix --max-warnings=0 --cache-location .eslintcache", - "flow check --temp-dir=/tmp/flow --max-workers 4" + "eslint --cache --fix --max-warnings=0 --cache-location .eslintcache" ], "*.rb": [ "bundle exec rubocop --force-exclusion --parallel" diff --git a/src/api/types.d.ts b/src/api/types.d.ts index 768a63509..ffa77e626 100644 --- a/src/api/types.d.ts +++ b/src/api/types.d.ts @@ -83,6 +83,7 @@ export interface ApiObservationSound { export interface ApiTaxon { default_photo?: ApiPhoto; + representative_photo?: ApiPhoto; iconic_taxon_name?: string; id?: number; name?: string; diff --git a/src/components/Camera/AICamera/AICamera.js b/src/components/Camera/AICamera/AICamera.js index d28818114..10fce244a 100644 --- a/src/components/Camera/AICamera/AICamera.js +++ b/src/components/Camera/AICamera/AICamera.js @@ -13,7 +13,7 @@ import DeviceInfo from "react-native-device-info"; import LinearGradient from "react-native-linear-gradient"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { VolumeManager } from "react-native-volume-manager"; -import { convertOfflineScoreToConfidence } from "sharedHelpers/convertScores.ts"; +import convertScoreToConfidence from "sharedHelpers/convertScores.ts"; import { log } from "sharedHelpers/logger"; import { deleteSentinelFile, logStage } from "sharedHelpers/sentinelFiles.ts"; import { @@ -262,7 +262,7 @@ const AICamera = ( { confidence={ isDefaultMode ? null - : convertOfflineScoreToConfidence( result?.score ) + : convertScoreToConfidence( result?.combined_score ) } unpressable taxon={result?.taxon} diff --git a/src/components/Camera/AICamera/AIDebugButton.js b/src/components/Camera/AICamera/AIDebugButton.js index 4f05b5519..810f17eae 100644 --- a/src/components/Camera/AICamera/AIDebugButton.js +++ b/src/components/Camera/AICamera/AIDebugButton.js @@ -73,11 +73,9 @@ const AIDebugButton = ( { { const [result, setResult] = useState( null ); const [resultTimestamp, setResultTimestamp] = useState( undefined ); const [modelLoaded, setModelLoaded] = useState( false ); - const [confidenceThreshold, setConfidenceThreshold] = useState( 0.7 ); + const [confidenceThreshold, setConfidenceThreshold] = useState( 70 ); const [fps, setFPS] = useState( 1 ); const [numStoredResults, setNumStoredResults] = useState( 5 ); const [cropRatio, setCropRatio] = useState( 1 ); @@ -38,12 +38,8 @@ const usePredictions = ( ) => { .map( p => ( { name: p.name, rank_level: p.rank_level, - score: p.score, - taxon_id: p.taxon_id, - ancestor_ids: p.ancestor_ids, - rank: p.rank, - iconic_class_id: p.iconic_class_id, - spatial_class_id: p.spatial_class_id + combined_score: p.combined_score, + taxon_id: p.taxon_id } ) ) .sort( ( a, b ) => a.rank_level - b.rank_level ); const branchIDs = branch.map( t => t.taxon_id ); @@ -59,7 +55,7 @@ const usePredictions = ( ) => { name: finestPrediction.name, iconic_taxon_name: iconicTaxon?.name }, - score: finestPrediction.score, + combined_score: finestPrediction.combined_score, timestamp: cvResult.timestamp }; } diff --git a/src/components/Camera/helpers/visionPluginWrapper.e2e-mock b/src/components/Camera/helpers/visionPluginWrapper.e2e-mock index a1d1513ed..dd949b9bb 100644 --- a/src/components/Camera/helpers/visionPluginWrapper.e2e-mock +++ b/src/components/Camera/helpers/visionPluginWrapper.e2e-mock @@ -8,8 +8,7 @@ const mockModelResult = { { name: "Sempervivum tectorum", rank_level: 10, - rank: "species", - score: 0.96, + combined_score: 96, taxon_id: 51779 } ] diff --git a/src/components/Camera/hooks/usePrepareStoreAndNavigate.ts b/src/components/Camera/hooks/usePrepareStoreAndNavigate.ts index 915a7ab57..7b519195c 100644 --- a/src/components/Camera/hooks/usePrepareStoreAndNavigate.ts +++ b/src/components/Camera/hooks/usePrepareStoreAndNavigate.ts @@ -25,6 +25,7 @@ const usePrepareStoreAndNavigate = ( ): Function => { const setSavingPhoto = useStore( state => state.setSavingPhoto ); const setCameraState = useStore( state => state.setCameraState ); const setSentinelFileName = useStore( state => state.setSentinelFileName ); + const isAdvancedSuggestionsMode = useStore( state => state.layout.isAdvancedSuggestionsMode ); const { deviceStorageFull, showStorageFullAlert } = useDeviceStorageFull( ); @@ -166,7 +167,13 @@ const usePrepareStoreAndNavigate = ( ): Function => { lastScreen: "CameraWithDevice" } ); } - return navigation.push( "Suggestions", { + if ( isAdvancedSuggestionsMode ) { + return navigation.push( "Suggestions", { + entryScreen: "CameraWithDevice", + lastScreen: "CameraWithDevice" + } ); + } + return navigation.push( "ObsEdit", { entryScreen: "CameraWithDevice", lastScreen: "CameraWithDevice" } ); @@ -176,7 +183,8 @@ const usePrepareStoreAndNavigate = ( ): Function => { createObsWithCameraPhotos, setSentinelFileName, navigation, - updateObsWithCameraPhotos + updateObsWithCameraPhotos, + isAdvancedSuggestionsMode ] ); return prepareStoreAndNavigate; diff --git a/src/components/LoginSignUp/Header.js b/src/components/LoginSignUp/Header.js index 55b72f06b..6b5b75584 100644 --- a/src/components/LoginSignUp/Header.js +++ b/src/components/LoginSignUp/Header.js @@ -19,10 +19,10 @@ const Header = ( { headerText, hideHeader }: Props ): Node => { ); return ( - + {renderLogo()} {headerText && ( - + {headerText} )} diff --git a/src/components/LoginSignUp/Login.tsx b/src/components/LoginSignUp/Login.tsx index 62db720ad..8b591712e 100644 --- a/src/components/LoginSignUp/Login.tsx +++ b/src/components/LoginSignUp/Login.tsx @@ -1,43 +1,27 @@ import { t } from "i18next"; import React, { useCallback } from "react"; -import useKeyboardInfo from "sharedHooks/useKeyboardInfo"; import Header from "./Header"; import LoginForm from "./LoginForm"; import LoginSignUpWrapper from "./LoginSignUpWrapper"; -const TARGET_NON_KEYBOARD_HEIGHT = 420 as const; -const HIDE_HEADER_HEIGHT = 570 as const; -const HIDE_FOOTER_HEIGHT = 500 as const; - const Login = ( ) => { - const { - keyboardShown, - keyboardVerticalOffset, - nonKeyboardHeight - } = useKeyboardInfo( TARGET_NON_KEYBOARD_HEIGHT ); - - const hideHeader = keyboardShown && ( nonKeyboardHeight < HIDE_HEADER_HEIGHT ); - const hideFooter = keyboardShown && ( nonKeyboardHeight < HIDE_FOOTER_HEIGHT ); - - const renderLoginForm = useCallback( ( ) => ( + const renderLoginForm = useCallback( ( { scrollViewRef } ) => ( <>
- ), [hideHeader, hideFooter] ); + ), [] ); return ( - {renderLoginForm( )} + {renderLoginForm} ); }; diff --git a/src/components/LoginSignUp/LoginForm.tsx b/src/components/LoginSignUp/LoginForm.tsx index 8b55ee569..ba5e099a2 100644 --- a/src/components/LoginSignUp/LoginForm.tsx +++ b/src/components/LoginSignUp/LoginForm.tsx @@ -6,7 +6,9 @@ import { import { Image, View } from "components/styledComponents"; import { t } from "i18next"; import { RealmContext } from "providers/contexts.ts"; -import React, { useEffect, useRef, useState } from "react"; +import React, { + useCallback, useEffect, useRef, useState +} from "react"; import { Trans } from "react-i18next"; import { Platform, @@ -25,7 +27,7 @@ import LoginSignUpInputField from "./LoginSignUpInputField"; const { useRealm } = RealmContext; interface Props { - hideFooter?: boolean; + scrollViewRef?: React.Ref } interface LoginFormParams { @@ -39,8 +41,9 @@ type ParamList = { } const LoginForm = ( { - hideFooter + scrollViewRef }: Props ) => { + const firstInputFieldRef = useRef( null ); const { params } = useRoute>( ); const emailConfirmed = params?.emailConfirmed; const realm = useRealm( ); @@ -110,6 +113,87 @@ const LoginForm = ( { setIsDefaultMode ] ); + const scrollToItem = useCallback( ( ) => { + firstInputFieldRef.current.measureLayout( + scrollViewRef.current, + ( _, y ) => { + scrollViewRef.current.scrollTo( { y, animated: true } ); + }, + () => console.log( "Failed to measure" ) + ); + }, [scrollViewRef] ); + + useEffect( ( ) => { + if ( keyboardShown ) { + scrollToItem( ); + } + }, [keyboardShown, scrollToItem] ); + + const renderFooter = ( ) => ( + <> + + {t( "OR-SIGN-IN-WITH" )} + + + {/* + Note: Sign in with Apple is doable in Android if we want to: + https://github.com/invertase/react-native-apple-authentication?tab=readme-ov-file#android + */} + { Platform.OS === "ios" && ( + logIn( async ( ) => signInWithApple( realm ) )} + disabled={loading} + className="mr-8" + icon="apple" + // The svg icon for the Apple logo was downloaded from Apple, + // according to the Design Guidelines it already has a margin inside the svg + // so we scale it here to fill the entire button. + size={50} + color={colors.black} + backgroundColor={colors.white} + accessibilityLabel={t( "Sign-in-with-Apple" )} + mode="contained" + width={50} + height={50} + /> + ) } + logIn( async ( ) => signInWithGoogle( realm ) )} + disabled={loading} + backgroundColor={colors.white} + accessibilityLabel={t( "Sign-in-with-Google" )} + mode="contained" + width={50} + height={50} + > + + + + navigation.navigate( "SignUp" )} + components={[ + , + + ]} + /> + + ); + return ( @@ -127,20 +211,22 @@ const LoginForm = ( { ) } - setEmail( text )} - testID="Login.email" - // https://github.com/facebook/react-native/issues/39411#issuecomment-1817575790 - // textContentType prevents visual flickering, which is a temporary issue - // in iOS 17 - textContentType="emailAddress" - /> + + setEmail( text )} + testID="Login.email" + // https://github.com/facebook/react-native/issues/39411#issuecomment-1817575790 + // textContentType prevents visual flickering, which is a temporary issue + // in iOS 17 + textContentType="emailAddress" + /> + setIsPasswordVisible( prevState => !prevState )} > {isPasswordVisible @@ -164,7 +250,7 @@ const LoginForm = ( { navigation.navigate( "ForgotPassword" )} > {t( "Forgot-Password" )} @@ -187,69 +273,7 @@ const LoginForm = ( { testID="Login.loginButton" text={t( "LOG-IN" )} /> - - {t( "OR-SIGN-IN-WITH" )} - - - {/* - Note: Sign in with Apple is doable in Android if we want to: - https://github.com/invertase/react-native-apple-authentication?tab=readme-ov-file#android - */} - { Platform.OS === "ios" && ( - logIn( async ( ) => signInWithApple( realm ) )} - disabled={loading} - className="mr-8" - icon="apple" - // The svg icon for the Apple logo was downloaded from Apple, - // according to the Design Guidelines it already has a margin inside the svg - // so we scale it here to fill the entire button. - size={50} - color={colors.black} - backgroundColor={colors.white} - accessibilityLabel={t( "Sign-in-with-Apple" )} - mode="contained" - width={50} - height={50} - /> - ) } - logIn( async ( ) => signInWithGoogle( realm ) )} - disabled={loading} - backgroundColor={colors.white} - accessibilityLabel={t( "Sign-in-with-Google" )} - mode="contained" - width={50} - height={50} - > - - - - - {!hideFooter && ( - navigation.navigate( "SignUp" )} - components={[ - , - - ]} - /> - )} + {renderFooter( )} ); diff --git a/src/components/LoginSignUp/LoginSignUpInputField.tsx b/src/components/LoginSignUp/LoginSignUpInputField.tsx index 76e631ef2..00ebe8877 100644 --- a/src/components/LoginSignUp/LoginSignUpInputField.tsx +++ b/src/components/LoginSignUp/LoginSignUpInputField.tsx @@ -29,8 +29,8 @@ const LoginSignUpInputField: Function = forwardRef( ( { testID, textContentType }: Props, ref: Ref ) => ( - - + + {headerText} , - keyboardVerticalOffset?: number, - scrollEnabled?: boolean + imageStyle?: StyleProp } -const KEYBOARD_AVOIDING_VIEW_STYLE = { - flex: 1, - flexGrow: 1 -} as const; +const windowHeight = Dimensions.get( "window" ).height; const SCROLL_VIEW_STYLE = { - flex: 1, - justifyContent: "space-between" + minHeight: windowHeight * 1.1 } as const; const LoginSignupWrapper = ( { backgroundSource, children, - imageStyle, - keyboardVerticalOffset, - scrollEnabled = true + imageStyle }: Props ) => { + const scrollViewRef = useRef( null ); const navigation = useNavigation( ); + + useEffect( ( ) => { + const resetScroll = ( ) => { + if ( scrollViewRef.current ) { + scrollViewRef.current?.scrollTo( { y: 0, animated: false } ); + } + }; + const unsubscribe = navigation.addListener( "focus", ( ) => { + resetScroll( ); + } ); + return unsubscribe; + }, [navigation] ); + // Make the StatusBar translucent in Android but reset it when we leave // because this alters the layout. useEffect( ( ) => { if ( Platform.OS !== "android" ) return ( ) => undefined; // Hide on first render StatusBar.setTranslucent( true ); - // StatusBar.setTranslucent( true ); + + const resetScroll = () => { + if ( scrollViewRef.current ) { + scrollViewRef.current?.scrollTo( { y: 0, animated: false } ); + } + }; const unsubscribe = navigation.addListener( "focus", ( ) => { + console.log( "resetting scroll" ); + resetScroll( ); // Hide when focused StatusBar.setTranslucent( true ); } ); @@ -61,10 +78,12 @@ const LoginSignupWrapper = ( { return unsubscribe; }, [navigation] ); + const fitContentWithinScreenStyle = { height: windowHeight * 0.85 }; + return ( - - - {children} - - + + + {typeof children === "function" + ? children( { scrollViewRef } ) + : children} + + + ); diff --git a/src/components/LoginSignUp/SignUp.tsx b/src/components/LoginSignUp/SignUp.tsx index d4fe3c88b..7a5d01ba0 100644 --- a/src/components/LoginSignUp/SignUp.tsx +++ b/src/components/LoginSignUp/SignUp.tsx @@ -7,22 +7,18 @@ import LoginSignUpWrapper from "./LoginSignUpWrapper"; import SignUpForm from "./SignUpForm"; const TARGET_NON_KEYBOARD_HEIGHT = 440; -const HIDE_HEADER_HEIGHT = 580; const IMAGE_STYLE = { opacity: 0.5 }; const SignUp = ( ) => { const { - keyboardShown, - keyboardVerticalOffset, - nonKeyboardHeight + keyboardShown } = useKeyboardInfo( TARGET_NON_KEYBOARD_HEIGHT ); - const hideHeader = nonKeyboardHeight < HIDE_HEADER_HEIGHT && keyboardShown; + const hideHeader = keyboardShown; return (
{ // the top of the list const sortedList = _.orderBy( suggestionsList, - suggestion => suggestion.combined_score || suggestion.score, + suggestion => suggestion.combined_score, ["desc"] ); @@ -237,7 +237,7 @@ const MatchContainer = ( ) => { // make sure list is in order of confidence score const sortedList = _.orderBy( orderedList, - suggestion => suggestion.combined_score || suggestion.score, + suggestion => suggestion.combined_score, ["desc"] ); dispatch( { @@ -254,8 +254,14 @@ const MatchContainer = ( ) => { const otherSuggestions = orderedSuggestions .filter( suggestion => suggestion.taxon.id !== taxonId ); - const navToTaxonDetails = ( ) => { - navigation.push( "TaxonDetails", { id: taxonId } ); + const navToTaxonDetails = photo => { + const params = { id: taxonId }; + if ( !photo?.isRepresentativeButOtherTaxon ) { + params.firstPhotoID = photo.id; + } else { + params.representativePhoto = photo; + } + navigation.push( "TaxonDetails", params ); }; const handleSaveOrDiscardPress = async action => { diff --git a/src/components/Match/PhotosSection.js b/src/components/Match/PhotosSection.js index 7b731f8f1..b490bc720 100644 --- a/src/components/Match/PhotosSection.js +++ b/src/components/Match/PhotosSection.js @@ -6,7 +6,7 @@ import { import { Image, Pressable, View } from "components/styledComponents"; -import _, { compact, uniqBy } from "lodash"; +import _, { compact } from "lodash"; import React, { useEffect, useState } from "react"; import Photo from "realmModels/Photo"; import getImageDimensions from "sharedHelpers/getImageDimensions"; @@ -16,7 +16,7 @@ type Props = { representativePhoto: Object, taxon: Object, obsPhotos: Array, - navToTaxonDetails: ( ) => void + navToTaxonDetails: ( photo: Object ) => void } const PhotosSection = ( { @@ -35,22 +35,33 @@ const PhotosSection = ( { const taxonPhotos = compact( localTaxonPhotos - ? localTaxonPhotos.map( taxonPhoto => taxonPhoto.photo ) + ? localTaxonPhotos.map( taxonPhoto => ( { ...taxonPhoto.photo } ) ) : [taxon?.defaultPhoto] ); - // don't show the iconic taxon photo which is a mashup of 9 photos - const taxonPhotosNoIconic = localTaxon?.isIconic - ? taxonPhotos.slice( 1, 4 ) - : taxonPhotos.slice( 0, 3 ); - - // Add the representative photo at the start of the list of taxon photos. - const taxonPhotosWithRepPhoto = compact( [representativePhoto, ...taxonPhotosNoIconic] ); - // The representative photo might be already included in taxonPhotosNoIconic - const uniqueTaxonPhotos = uniqBy( taxonPhotosWithRepPhoto, "id" ); - if ( uniqueTaxonPhotos.length > 3 ) { - uniqueTaxonPhotos.pop( ); + // don't show the iconic taxon photo which is a mashup of 9 bestTaxonPhotos + if ( localTaxon?.isIconic ) { + taxonPhotos.splice( 0 ); } + // If the representative photo is already included in taxonPhotos, don't add it but move + // it to the start of the list. + let firstPhoto; + if ( representativePhoto && taxonPhotos.some( photo => photo.id === representativePhoto.id ) ) { + const repPhotoIndex = taxonPhotos.findIndex( photo => photo.id === representativePhoto.id ); + // The first photo to show is the realm version of the representative photo + firstPhoto = taxonPhotos.splice( repPhotoIndex, 1 )[0]; + } else if ( representativePhoto ) { + // This is possible because a representative photo can be from a different taxon, e.g. children + // of common ancestors. In this case, the representative photo is not included in taxonPhotos. + firstPhoto = { ...representativePhoto, isRepresentativeButOtherTaxon: true }; + } + // Add the representative photo at the start of the list of taxon bestTaxonPhotos. + const taxonPhotosWithRepPhoto = compact( [ + firstPhoto, + ...taxonPhotos + ] ); + const bestTaxonPhotos = taxonPhotosWithRepPhoto.slice( 0, 3 ); + const observationPhotos = compact( obsPhotos ? obsPhotos.map( obsPhoto => obsPhoto.photo ) @@ -70,24 +81,24 @@ const PhotosSection = ( { }, [observationPhoto] ); const getLayoutClasses = ( ) => { - // Basic layout: no taxon photos + obs photo a square + // Basic layout: no taxon bestTaxonPhotos + obs photo a square let containerClass = "flex-row"; let observationPhotoClass = "w-full h-full"; let taxonPhotosContainerClass; let taxonPhotoClass; // If there is only one taxon photo: obs photo a square, // taxon photo a square in the lower right corner of the obs photo - if ( uniqueTaxonPhotos.length === 1 ) { + if ( bestTaxonPhotos.length === 1 ) { containerClass = "flex-row relative"; observationPhotoClass = "w-full h-full"; taxonPhotosContainerClass = "absolute bottom-0 right-0 w-1/3 h-1/3"; taxonPhotoClass = "w-full h-full border-l-[3px] border-t-[3px] border-white"; } - if ( uniqueTaxonPhotos.length > 1 ) { + if ( bestTaxonPhotos.length > 1 ) { if ( displayPortraitLayout ) { containerClass = "flex-row"; observationPhotoClass = "w-2/3 h-full pr-[3px]"; - if ( uniqueTaxonPhotos.length === 2 ) { + if ( bestTaxonPhotos.length === 2 ) { taxonPhotosContainerClass = "flex-col w-1/3 h-full space-y-[3px]"; taxonPhotoClass = "w-full h-1/2"; } else { @@ -97,7 +108,7 @@ const PhotosSection = ( { } else { containerClass = "flex-col"; observationPhotoClass = "w-full h-2/3 pb-[3px]"; - if ( uniqueTaxonPhotos.length === 2 ) { + if ( bestTaxonPhotos.length === 2 ) { taxonPhotosContainerClass = "flex-row w-full h-1/3 space-x-[3px]"; taxonPhotoClass = "w-1/2 h-full"; } else { @@ -147,10 +158,10 @@ const PhotosSection = ( { layoutClasses?.taxonPhotosContainerClass )} > - {uniqueTaxonPhotos.map( photo => ( + {bestTaxonPhotos.map( photo => ( navToTaxonDetails( photo )} accessibilityState={{ disabled: false }} key={photo.id} className={classnames( @@ -180,12 +191,12 @@ const PhotosSection = ( { return ( {renderObservationPhoto( )} - {uniqueTaxonPhotos.length > 0 && renderTaxonPhotos( )} + {bestTaxonPhotos.length > 0 && renderTaxonPhotos( )} setMediaViewerVisible( false )} uri={observationPhoto} - photos={observationPhotos} + bestTaxonPhotos={observationPhotos} /> ); diff --git a/src/components/Match/calculateConfidence.js b/src/components/Match/calculateConfidence.js index e2003f90a..21583624f 100644 --- a/src/components/Match/calculateConfidence.js +++ b/src/components/Match/calculateConfidence.js @@ -3,17 +3,18 @@ const calculateConfidence = suggestion => { return null; } - // Note: combined_score have values between 0 and 100, - // compared with vision-plugin v4.2.2 model results that have a score field between 0 and 1 - // However, the common_ancestor from the API also has a score field with a range of 0-100 - const factor = suggestion.score > 1 - ? 1 - : 100; - const score = suggestion?.score - ? suggestion.score * factor - : suggestion.combined_score; - const confidence = parseFloat( score.toFixed( 1 ) ); - return confidence; + // Note: combined_score as returned from vision-plugin >v5 as well as iNatVisionAPI + // have values between 0 and 100. + // For common_ancestor from API, the combined_core parameter is renamed to score by the node API + if ( suggestion.combined_score === undefined && suggestion.score !== undefined ) { + return parseFloat( suggestion.score.toFixed( 1 ) ); + } if ( suggestion.combined_score !== undefined ) { + return parseFloat( suggestion.combined_score.toFixed( 1 ) ); + } + + // Return null confidence if neither score exists - I hope to see this as NaN and detect the + // problem rather than hiding it. + return null; }; export default calculateConfidence; diff --git a/src/components/Notifications/NotificationsListItem.tsx b/src/components/Notifications/NotificationsListItem.tsx index 3a0508674..e85b294dd 100644 --- a/src/components/Notifications/NotificationsListItem.tsx +++ b/src/components/Notifications/NotificationsListItem.tsx @@ -29,15 +29,11 @@ const NotificationsListItem = ( { notification }: Props ) => { )} onPress={( ) => { setObsDetailsTab( OBS_DETAILS_TAB.ACTIVITY ); - navigation.navigate( "TabStackNavigator", { - screen: "ObsDetails", - params: { - uuid: notification.resource_uuid, - targetActivityItemID: notification.identification_id || notification.comment_id - } + navigation.push( "ObsDetails", { + uuid: notification.resource_uuid, + targetActivityItemID: notification.identification_id || notification.comment_id } ); }} - > diff --git a/src/components/ObsDetailsDefaultMode/CommunitySection/ActivityItem.js b/src/components/ObsDetailsDefaultMode/CommunitySection/ActivityItem.js index c99f45476..68e1efae5 100644 --- a/src/components/ObsDetailsDefaultMode/CommunitySection/ActivityItem.js +++ b/src/components/ObsDetailsDefaultMode/CommunitySection/ActivityItem.js @@ -68,7 +68,7 @@ const ActivityItem = ( { ); return ( - + { if ( targetItemID === item?.id ) { - onLayoutTargetItem( event ); + const { layout } = event.nativeEvent; + onLayoutTargetItem( layout ); } }} key={item.uuid} diff --git a/src/components/ObsDetailsDefaultMode/IdentificationSheets.tsx b/src/components/ObsDetailsDefaultMode/IdentificationSheets.tsx new file mode 100644 index 000000000..638a38d88 --- /dev/null +++ b/src/components/ObsDetailsDefaultMode/IdentificationSheets.tsx @@ -0,0 +1,495 @@ +// flow was giving a lot of annoying errors on this screen, so added TypeScript +import { useRoute } from "@react-navigation/native"; +import { createComment } from "api/comments"; +import { createIdentification } from "api/identifications"; +import { + TextInputSheet, + WarningSheet +} from "components/SharedComponents"; +import { RealmContext } from "providers/contexts.ts"; +import type { Node } from "react"; +import React, { + useCallback, useEffect, + useMemo, useReducer +} from "react"; +import { Alert, Platform } from "react-native"; +import { fetchTaxonAndSave } from "sharedHelpers/taxon"; +import { + useAuthenticatedMutation, + useTranslation +} from "sharedHooks"; + +import AgreeWithIDSheet from "./Sheets/AgreeWithIDSheet"; +import PotentialDisagreementSheet from "./Sheets/PotentialDisagreementSheet"; +import SuggestIDSheet from "./Sheets/SuggestIDSheet"; + +const { useRealm } = RealmContext; + +const textInputStyle = Platform.OS === "android" + ? { + height: 125 + } + : undefined; + +interface Taxon { + id: number; + ancestor_ids: number[]; + [key: string]: unknown; +} + +interface Observation { + uuid?: string; + taxon?: Taxon; + community_taxon?: Taxon; + prefers_community_taxon: boolean | null; + user?: { + prefers_community_taxa: boolean; + }; + [key: string]: unknown; +} + +interface Identification { + taxon?: Taxon; + body?: string; + vision?: boolean; +} + +interface IdentState { + comment: string | null; + commentIsOptional: boolean; + identBodySheetShown: boolean; + newIdentification: Identification | null; + showPotentialDisagreementSheet: boolean; + showSuggestIdSheet: boolean; + identTaxon: Taxon | null; +} + +type IdentAction = + | { type: "SET_IDENT_TAXON"; taxon: Taxon } + | { type: "CLEAR_SUGGESTED_TAXON" } + | { type: "CONFIRM_ID" } + | { type: "DISCARD_ID" } + | { type: "HIDE_EDIT_IDENT_BODY_SHEET" } + | { type: "HIDE_POTENTIAL_DISAGREEMENT_SHEET" } + | { type: "SET_NEW_IDENTIFICATION"; taxon?: Taxon; body?: string; vision?: boolean } + | { type: "SHOW_EDIT_IDENT_BODY_SHEET" } + | { type: "SHOW_POTENTIAL_DISAGREEMENT_SHEET" } + | { type: "SUBMIT_IDENTIFICATION" }; + +const initialIdentState: IdentState = { + comment: null, + commentIsOptional: false, + identBodySheetShown: false, + newIdentification: null, + showPotentialDisagreementSheet: false, + showSuggestIdSheet: false, + identTaxon: null +}; + +const SET_IDENT_TAXON = "SET_IDENT_TAXON"; +const CLEAR_SUGGESTED_TAXON = "CLEAR_SUGGESTED_TAXON"; +const CONFIRM_ID = "CONFIRM_ID"; +const DISCARD_ID = "DISCARD_ID"; +const HIDE_EDIT_IDENT_BODY_SHEET = "HIDE_EDIT_IDENT_BODY_SHEET"; +const HIDE_POTENTIAL_DISAGREEMENT_SHEET = "HIDE_POTENTIAL_DISAGREEMENT_SHEET"; +const SET_NEW_IDENTIFICATION = "SET_NEW_IDENTIFICATION"; +const SHOW_EDIT_IDENT_BODY_SHEET = "SHOW_EDIT_IDENT_BODY_SHEET"; +const SHOW_POTENTIAL_DISAGREEMENT_SHEET = "SHOW_POTENTIAL_DISAGREEMENT_SHEET"; +const SUBMIT_IDENTIFICATION = "SUBMIT_IDENTIFICATION"; + +const identReducer = ( state: IdentState, action: IdentAction ): IdentState => { + switch ( action.type ) { + case SHOW_POTENTIAL_DISAGREEMENT_SHEET: + return { + ...state, + showPotentialDisagreementSheet: true + }; + case SET_NEW_IDENTIFICATION: + return { + ...state, + newIdentification: { + taxon: action.taxon, + body: action.body, + vision: action.vision + } + }; + case CONFIRM_ID: + return { ...state, showSuggestIdSheet: true }; + case DISCARD_ID: + return { + ...state, + showSuggestIdSheet: false, + identTaxon: null, + newIdentification: null + }; + case HIDE_POTENTIAL_DISAGREEMENT_SHEET: + return { + ...state, + showPotentialDisagreementSheet: false, + identTaxon: null, + newIdentification: null + }; + case SHOW_EDIT_IDENT_BODY_SHEET: + return { + ...state, + identBodySheetShown: true + }; + case HIDE_EDIT_IDENT_BODY_SHEET: + return { + ...state, + identBodySheetShown: false + }; + case SUBMIT_IDENTIFICATION: + return { + ...state, + showPotentialDisagreementSheet: false, + showSuggestIdSheet: false, + newIdentification: null + }; + case SET_IDENT_TAXON: + return { ...state, identTaxon: action.taxon }; + case CLEAR_SUGGESTED_TAXON: + return { ...state, identTaxon: null }; + default: + return state; + } +}; + +interface RouteParams { + identAt?: string; + identTaxonId?: number; + identTaxonFromVision?: boolean; + uuid?: string; + [key: string]: unknown; +} + +interface Props { + agreeIdentification: boolean; + closeAgreeWithIdSheet: () => void; + confirmRemoteObsWasDeleted?: () => void; + handleCommentMutationSuccess: ( data: unknown ) => void; + handleIdentificationMutationSuccess: ( data: unknown ) => void; + hideAddCommentSheet: () => void; + loadActivityItem: () => void; + observation: Observation; + remoteObsWasDeleted?: boolean; + showAddCommentSheet?: boolean; + showAgreeWithIdSheet: boolean; +} + +const IdentificationSheets: React.FC = ( { + agreeIdentification, + closeAgreeWithIdSheet, + confirmRemoteObsWasDeleted, + handleCommentMutationSuccess, + handleIdentificationMutationSuccess, + hideAddCommentSheet, + loadActivityItem, + observation, + remoteObsWasDeleted, + showAddCommentSheet, + showAgreeWithIdSheet +}: Props ): Node => { + const { params } = useRoute(); + const routeParams = params as RouteParams; + const { + identAt, + identTaxonId, + identTaxonFromVision, + uuid + } = routeParams; + const [state, dispatch] = useReducer( identReducer, initialIdentState ); + + const { + comment, + commentIsOptional, + identBodySheetShown, + identTaxon, + newIdentification, + showPotentialDisagreementSheet, + showSuggestIdSheet + } = state; + + const realm = useRealm( ); + const { t } = useTranslation( ); + + const hasComment = useMemo( + ( ) => ( comment || newIdentification?.body || "" ).length > 0, + [comment, newIdentification?.body] + ); + + const showAddCommentHeader = useCallback( ( ) => { + if ( hasComment ) { + return t( "EDIT-COMMENT" ); + } if ( commentIsOptional ) { + return t( "ADD-OPTIONAL-COMMENT" ); + } + return t( "ADD-COMMENT" ); + }, [commentIsOptional, hasComment, t] ); + + const editIdentBody = useCallback( ( ) => dispatch( { type: SHOW_EDIT_IDENT_BODY_SHEET } ), [] ); + + const onChangeIdentBody = useCallback( body => dispatch( { + type: SET_NEW_IDENTIFICATION, + taxon: newIdentification?.taxon, + body + } ), [newIdentification?.taxon] ); + + const onCloseIdentBodySheet = useCallback( ( ) => { + dispatch( { type: HIDE_EDIT_IDENT_BODY_SHEET } ); + }, [] ); + + const showErrorAlert = useCallback( error => Alert.alert( "Error", error, [{ text: t( "OK" ) }], { + cancelable: true + } ), [t] ); + + const createIdentificationMutation = useAuthenticatedMutation( + ( idParams, optsWithAuth ) => createIdentification( idParams, optsWithAuth ), + { + onSuccess: data => { + handleIdentificationMutationSuccess( data ); + if ( uuid ) { + dispatch( { type: CLEAR_SUGGESTED_TAXON } ); + } + }, + onError: e => { + let error = null; + if ( e ) { + error = t( "Couldnt-create-identification-error", { error: e.message } ); + } else { + error = t( "Couldnt-create-identification-unknown-error" ); + } + showErrorAlert( error ); + } + } + ); + + const hasPotentialDisagreement = useCallback( ( ) => { + // based on disagreement code in iNat web + // https://github.com/inaturalist/inaturalist/blob/30a27d0eb79dd17af38292785b0137e6024bbdb7/app/webpack/observations/show/ducks/observation.js#L827-L838 + let observationTaxon = observation?.taxon; + + const doesNotPreferCommunityTaxon = observation.prefers_community_taxon === false + || ( observation.user?.prefers_community_taxa === false + && observation.prefers_community_taxon === null ); + + if ( doesNotPreferCommunityTaxon ) { + observationTaxon = observation?.community_taxon || observation.taxon; + } + return observationTaxon + && identTaxon?.id !== observationTaxon.id + && observationTaxon.ancestor_ids.includes( identTaxon?.id ); + }, [identTaxon?.id, observation] ); + + const setNewIdentification = useCallback( ( ) => { + dispatch( { + type: SET_NEW_IDENTIFICATION, + taxon: identTaxon, + vision: identTaxonFromVision + } ); + }, [identTaxon, identTaxonFromVision] ); + + const hideIdentificationSheets = !identTaxon + || showPotentialDisagreementSheet + || showSuggestIdSheet + || identBodySheetShown; + + useEffect( () => { + if ( hideIdentificationSheets ) return; + setNewIdentification( ); + if ( hasPotentialDisagreement( ) ) { + dispatch( { type: "SHOW_POTENTIAL_DISAGREEMENT_SHEET" } ); + } else { + dispatch( { type: CONFIRM_ID } ); + } + }, [ + hideIdentificationSheets, + hasPotentialDisagreement, + observation, + setNewIdentification + ] ); + + // Translates identification-related params to local state + useEffect( ( ) => { + async function fetchAndSet() { + let taxon = realm.objectForPrimaryKey( "Taxon", identTaxonId ); + if ( !taxon ) { + taxon = await fetchTaxonAndSave( identTaxonId, realm ); + } + dispatch( { + type: SET_IDENT_TAXON, + taxon + } ); + } + if ( identTaxonId ) { + fetchAndSet(); + } else { + dispatch( { type: CLEAR_SUGGESTED_TAXON } ); + } + }, [ + // This should change with every new navigation event back to ObsDetails, + // so even if identTaxonId doesn't change, e.g. you add an ID of taxon X, + // cancel, then add another ID of taxon X, we still update the identTaxon + identAt, + identTaxonId, + realm + ] ); + + const onAgree = useCallback( ( ident: Identification ) => { + const agreeParams = { + observation_id: observation?.uuid, + taxon_id: ident.taxon?.id, + body: ident.body + }; + + loadActivityItem( ); + createIdentificationMutation.mutate( { identification: agreeParams } ); + closeAgreeWithIdSheet( ); + }, [closeAgreeWithIdSheet, createIdentificationMutation, observation?.uuid, loadActivityItem] ); + + const potentialDisagreeSheetDiscardChanges = useCallback( ( ) => { + dispatch( { type: HIDE_POTENTIAL_DISAGREEMENT_SHEET } ); + }, [] ); + + const doSuggestId = useCallback( ( potentialDisagree?: boolean ) => { + if ( !newIdentification?.taxon ) { + throw new Error( "Cannot create an identification without a taxon" ); + } + // New taxon identification added by user + const idParams = { + observation_id: uuid, + taxon_id: newIdentification.taxon.id, + vision: newIdentification.vision, + disagreement: potentialDisagree, + body: newIdentification?.body + }; + + loadActivityItem( ); + createIdentificationMutation.mutate( { identification: idParams } ); + }, [createIdentificationMutation, newIdentification, uuid, loadActivityItem] ); + + const onSuggestId = useCallback( ( ) => { + if ( hasPotentialDisagreement( ) ) { + dispatch( { type: "SHOW_POTENTIAL_DISAGREEMENT_SHEET" } ); + } else { + dispatch( { type: SUBMIT_IDENTIFICATION } ); + doSuggestId(); + } + }, [ + doSuggestId, + hasPotentialDisagreement + ] ); + + const onPotentialDisagreePressed = useCallback( ( potentialDisagree?: boolean ) => { + dispatch( { type: SUBMIT_IDENTIFICATION } ); + doSuggestId( potentialDisagree ); + }, [doSuggestId] ); + + const suggestIdSheetDiscardChanges = useCallback( ( ) => dispatch( { type: DISCARD_ID } ), [] ); + + const createCommentMutation = useAuthenticatedMutation( + ( commentParams, optsWithAuth ) => createComment( commentParams, optsWithAuth ), + { + onSuccess: data => handleCommentMutationSuccess( data ), + onError: e => { + let error = null; + if ( e ) { + error = t( "Couldnt-create-comment", { error: e.message } ); + } else { + error = t( "Couldnt-create-comment", { error: t( "Unknown-error" ) } ); + } + showErrorAlert( error ); + } + } + ); + + const onCommentAdded = useCallback( ( body: string ) => { + loadActivityItem( ); + createCommentMutation.mutate( { + comment: { + body, + parent_id: uuid, + parent_type: "Observation" + } + } ); + }, [createCommentMutation, uuid, loadActivityItem] ); + + const confirmCommentFromCommentSheet = useCallback( ( newComment: string ) => { + if ( !commentIsOptional ) { + onCommentAdded( newComment ); + } + }, [commentIsOptional, onCommentAdded] ); + + const addCommentHeaderText = showAddCommentHeader( ); + + return ( + <> + {showAgreeWithIdSheet && agreeIdentification && ( +