From e81894d4060f81ac4eae652e0c7f58fea5729dfd Mon Sep 17 00:00:00 2001 From: Amanda Bullington <35536439+albullington@users.noreply.github.com> Date: Wed, 19 Oct 2022 13:01:04 -0700 Subject: [PATCH] Use authenticated queries for fetch/search requests (#195) * Use authenticated query for search results * Use search API for fetching places from Settings * Use authenticated query for authorized applications * Use authenticated query to fetch user.me * Move fetch/search api calls into react query format, out of hooks * Update with react query instead of hooks * Fetch list of blocked and muted users with authenticated query * Added Podfile postinstall block to get app running in a Simulator * Use auth query in identify * Upgrade Realm to 11.0.0-rc.0, most recent version that will work with RN 0.68.2 * Upgrade @realm/react library to 0.4.0; fixes initialization error on android due to importing realm * Use authquery for explore provider * ObsDetail wasn't showing the edit button for obs created while signed out * simplified ObsEditHeader so it takes a full observation instead of relying on the ObsEditContext * ObsEdit now accepts an obs UUID as a param and loads that if the context doesn't have a current obs * null checks for API methods, mostly to prevent requests that won't work b/c of missing params Co-authored-by: Ken-ichi Ueda --- .nvmrc | 2 +- ios/Podfile | 11 +- ios/Podfile.lock | 12 +- package-lock.json | 97 +++++++--- package.json | 4 +- react-native.config.js | 5 - src/api/authorizedApplications.js | 26 +++ src/api/messages.js | 20 +- src/api/observations.js | 91 +++++++++ src/api/places.js | 20 ++ src/api/projects.js | 45 +++++ src/api/providerAuthorizations.js | 26 +++ src/api/relationships.js | 21 ++ src/api/search.js | 32 ++++ src/api/users.js | 105 ++++++++++ src/components/Explore/DropdownPicker.js | 24 ++- src/components/Identify/GridItem.js | 2 +- src/components/Identify/Identify.js | 29 ++- .../Identify/hooks/useObservations.js | 68 ------- src/components/Messages/Messages.js | 11 +- src/components/ObsDetails/ObsDetails.js | 70 ++++--- src/components/ObsDetails/ObsDetailsHeader.js | 33 ++-- .../ObsDetails/helpers/faveObservation.js | 25 --- .../ObsDetails/hooks/useRemoteObservation.js | 84 -------- src/components/ObsEdit/AddID.js | 21 +- src/components/ObsEdit/CVSuggestions.js | 181 ------------------ .../ObsEdit/IdentificationSection.js | 1 - src/components/ObsEdit/ObsEdit.js | 9 + src/components/ObsEdit/ObsEditSearch.js | 79 -------- .../ObsEdit/hooks/useCVSuggestions.js | 93 --------- src/components/Projects/ProjectDetails.js | 23 ++- .../Projects/ProjectObservations.js | 12 +- src/components/Projects/ProjectSearch.js | 13 +- src/components/Projects/ProjectTabs.js | 19 +- src/components/Projects/Projects.js | 13 +- src/components/Projects/hooks/useMemberId.js | 36 ---- .../Projects/hooks/useProjectDetails.js | 42 ---- .../Projects/hooks/useProjectObservations.js | 60 ------ src/components/Projects/hooks/useProjects.js | 44 ----- src/components/Search/Search.js | 30 ++- src/components/Settings/PlaceSearchInput.js | 35 +++- src/components/Settings/Settings.js | 16 +- .../Settings/SettingsApplications.js | 33 ++-- .../Settings/SettingsRelationships.js | 109 ++++------- src/components/Settings/UserSearchInput.js | 20 +- .../hooks/useAuthorizedApplications.js | 51 ----- .../Settings/hooks/usePlaceDetails.js | 42 ---- src/components/Settings/hooks/usePlaces.js | 48 ----- .../hooks/useProviderAuthorizations.js | 51 ----- .../Settings/hooks/useRelationships.js | 74 ------- src/components/Settings/hooks/useUserMe.js | 47 ----- .../CustomHeaderWithTranslation.js | 21 -- .../ObservationViews/ObservationViews.js | 2 +- .../ObservationViews/UserCard.js | 13 +- src/components/UserProfile/UserProfile.js | 55 +++--- src/components/UserProfile/UserProjects.js | 11 +- .../UserProfile/hooks/useMemberProjects.js | 43 ----- .../UserProfile/hooks/useNetworkSite.js | 43 ----- .../UserProfile/hooks/useRemoteUser.js | 56 ------ src/models/Comment.js | 2 +- src/models/Identification.js | 2 +- src/models/Observation.js | 9 +- src/models/ObservationPhoto.js | 2 +- src/models/ObservationSound.js | 2 +- src/models/Photo.js | 2 +- src/models/Taxon.js | 2 +- src/models/User.js | 2 +- src/navigation/mainStackNavigation.js | 11 -- src/providers/ExploreProvider.js | 63 ++---- src/providers/ObsEditProvider.js | 15 +- src/providers/fields.js | 10 - src/sharedHooks/useAuthenticatedMutation.js | 24 +++ src/sharedHooks/useRemoteSearchResults.js | 54 ------ src/styles/obsEdit/cvSuggestions.js | 44 ----- src/styles/settings/settings.js | 5 +- tests/integration/MyObservations.test.js | 10 - tests/unit/components/AddID/AddID.test.js | 47 ++++- tests/unit/components/Explore/Explore.test.js | 44 +++-- .../components/ObsDetails/ObsDetails.test.js | 40 ++-- .../Projects/ProjectDetails.test.js | 34 ++-- .../Projects/ProjectObservations.test.js | 50 +++++ .../unit/components/Projects/Projects.test.js | 26 ++- .../components/Search/Search.taxa.test.js | 49 ++--- .../components/Search/Search.users.test.js | 45 +++-- .../UserProfile/UserProfile.test.js | 22 ++- 85 files changed, 1146 insertions(+), 1779 deletions(-) create mode 100644 src/api/authorizedApplications.js create mode 100644 src/api/observations.js create mode 100644 src/api/places.js create mode 100644 src/api/projects.js create mode 100644 src/api/providerAuthorizations.js create mode 100644 src/api/relationships.js create mode 100644 src/api/search.js create mode 100644 src/api/users.js delete mode 100644 src/components/Identify/hooks/useObservations.js delete mode 100644 src/components/ObsDetails/helpers/faveObservation.js delete mode 100644 src/components/ObsDetails/hooks/useRemoteObservation.js delete mode 100644 src/components/ObsEdit/CVSuggestions.js delete mode 100644 src/components/ObsEdit/ObsEditSearch.js delete mode 100644 src/components/ObsEdit/hooks/useCVSuggestions.js delete mode 100644 src/components/Projects/hooks/useMemberId.js delete mode 100644 src/components/Projects/hooks/useProjectDetails.js delete mode 100644 src/components/Projects/hooks/useProjectObservations.js delete mode 100644 src/components/Projects/hooks/useProjects.js delete mode 100644 src/components/Settings/hooks/useAuthorizedApplications.js delete mode 100644 src/components/Settings/hooks/usePlaceDetails.js delete mode 100644 src/components/Settings/hooks/usePlaces.js delete mode 100644 src/components/Settings/hooks/useProviderAuthorizations.js delete mode 100644 src/components/Settings/hooks/useRelationships.js delete mode 100644 src/components/Settings/hooks/useUserMe.js delete mode 100644 src/components/SharedComponents/CustomHeaderWithTranslation.js delete mode 100644 src/components/UserProfile/hooks/useMemberProjects.js delete mode 100644 src/components/UserProfile/hooks/useNetworkSite.js delete mode 100644 src/components/UserProfile/hooks/useRemoteUser.js delete mode 100644 src/providers/fields.js create mode 100644 src/sharedHooks/useAuthenticatedMutation.js delete mode 100644 src/sharedHooks/useRemoteSearchResults.js delete mode 100644 src/styles/obsEdit/cvSuggestions.js create mode 100644 tests/unit/components/Projects/ProjectObservations.test.js diff --git a/.nvmrc b/.nvmrc index 47c0a98a1..2a4e4ab81 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -12.13.0 +16.17.0 diff --git a/ios/Podfile b/ios/Podfile index e20462e6b..c08ba626d 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -26,8 +26,13 @@ target 'iNaturalistReactNative' do # you should disable the next line. # use_flipper!() - # post_install do |installer| - # react_native_post_install(installer) - # end + post_install do |installer| + # https://github.com/Agontuk/react-native-geolocation-service/issues/287#issuecomment-980772489 + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + config.build_settings["EXCLUDED_ARCHS[sdk=iphonesimulator*]"] = "arm64" + end + end + end end diff --git a/ios/Podfile.lock b/ios/Podfile.lock index d20ffe43d..ab6c6ce7e 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -10,9 +10,6 @@ PODS: - React-jsi (= 0.68.2) - ReactCommon/turbomodule/core (= 0.68.2) - fmt (6.2.1) - - GCDWebServer (3.5.4): - - GCDWebServer/Core (= 3.5.4) - - GCDWebServer/Core (3.5.4) - glog (0.3.5) - Permission-LocationWhenInUse (3.3.1): - RNPermissions @@ -314,8 +311,7 @@ PODS: - React-jsi (= 0.68.2) - React-logger (= 0.68.2) - React-perflogger (= 0.68.2) - - RealmJS (10.21.1): - - GCDWebServer + - RealmJS (11.0.0-rc.0): - React - RNAudioRecorderPlayer (3.5.1): - React-Core @@ -442,7 +438,6 @@ DEPENDENCIES: SPEC REPOS: trunk: - fmt - - GCDWebServer EXTERNAL SOURCES: boost: @@ -568,7 +563,6 @@ SPEC CHECKSUMS: FBLazyVector: a7a655862f6b09625d11c772296b01cd5164b648 FBReactNativeSpec: 81ce99032d5b586fddd6a38d450f8595f7e04be4 fmt: ff9d55029c625d3757ed641535fd4a75fedc7ce9 - GCDWebServer: 2c156a56c8226e2d5c0c3f208a3621ccffbe3ce4 glog: 476ee3e89abb49e07f822b48323c51c57124b572 Permission-LocationWhenInUse: 006c85c8de0c05b5d8be8e8029e4f6b813270293 RCT-Folly: 4d8508a426467c48885f1151029bc15fa5d7b3b8 @@ -606,7 +600,7 @@ SPEC CHECKSUMS: React-RCTVibration: 79040b92bfa9c3c2d2cb4f57e981164ec7ab9374 React-runtimeexecutor: b960b687d2dfef0d3761fbb187e01812ebab8b23 ReactCommon: 095366164a276d91ea704ce53cb03825c487a3f2 - RealmJS: 96af0fcb0bf79530e090067df590650917c27b20 + RealmJS: 6ee99e016e85a71233f92c64b8255007810b7478 RNAudioRecorderPlayer: 308940de4f9d1448a064874fd9d83479ae47c7a7 RNCAsyncStorage: d81ee5c3db1060afd49ea7045ad460eff82d2b7d RNCCheckbox: ed1b4ca295475b41e7251ebae046360a703b6eb5 @@ -624,6 +618,6 @@ SPEC CHECKSUMS: VisionCamera: c1c171fcdbf18c438987847f785829c5638f3a4c Yoga: 99652481fcd320aefa4a7ef90095b95acd181952 -PODFILE CHECKSUM: 51a6b3d8767f92f6783584e73a5acbcb4074e65d +PODFILE CHECKSUM: d1bc9d7f55b5f7449c3ceb6a7622454bc4f8b972 COCOAPODS: 1.11.3 diff --git a/package-lock.json b/package-lock.json index 5a186bc37..4d9b0988d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "@react-navigation/elements": "^1.3.1", "@react-navigation/native": "^6.0.8", "@react-navigation/native-stack": "^6.5.2", - "@realm/react": "^0.2.1", + "@realm/react": "^0.4.0", "@tanstack/react-query": "^4.2.1", "apisauce": "^2.1.2", "axios": "^0.25.0", @@ -72,7 +72,7 @@ "react-native-vision-camera": "^2.13.0", "react-spring": "^8.0.27", "react-tinder-card": "^1.4.5", - "realm": "^10.20.0-beta.3", + "realm": "^11.0.0-rc.0", "use-debounce": "^7.0.1" }, "devDependencies": { @@ -3586,21 +3586,26 @@ "resolved": "https://registry.npmjs.org/@realm.io/common/-/common-0.1.5.tgz", "integrity": "sha512-Y+UnICLvsPFpe2WOXWIdJUaV3G2qDocN8al/Yz13mYMkjODXHL4VhyfEKR2hvcAubv+7isdegEyYNdo3zQzbFA==" }, + "node_modules/@realm/common": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@realm/common/-/common-0.1.4.tgz", + "integrity": "sha512-bKpIRZIQ4ykribFi0igCwuvf7P4+Ex2XYKqDw1JDe6sCGAaPMwhazooyM6h32fUjtXRTbdAWH2S9JH8Xh/LrqQ==" + }, "node_modules/@realm/react": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@realm/react/-/react-0.2.1.tgz", - "integrity": "sha512-X1UdST8GoTWG6/JVPBSdVgi6UQ2J/9ixXzJ6AFXV6+TomtMtE82/Tb2xI/sU+wTi01y8A96zTnU+IXii9N9Itw==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@realm/react/-/react-0.4.0.tgz", + "integrity": "sha512-nWN0izov3rWdWZdJfGPbO2GfsvWn7h4ET4lbcVOjpt3zarldjAvAnIXzUlkZyKZI3anDj0izEWDieILzRY2jiA==", "dependencies": { - "lodash": "^4.17.21", - "realm": ">=10" + "@realm/common": "^0.1.4", + "lodash": "^4.17.21" }, "optionalDependencies": { "@babel/runtime": ">=7", - "react-native": ">=0.59" + "react-native": ">=0.68" }, "peerDependencies": { - "react": ">=16.8.0", - "realm": ">=10" + "react": ">=17.0.2", + "realm": "^11.0.0-rc || ^11.0.0" } }, "node_modules/@sideway/address": { @@ -5209,6 +5214,26 @@ "integrity": "sha512-uc2Vix1frTfnuzxxu1Hp4ktSvM3QaI4oXl4ZUqL1wjTu/BGki9TrCWoqLTg/drR1KwAEarXuRFCG2Svr1GxPFw==", "dev": true }, + "node_modules/clang-format": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/clang-format/-/clang-format-1.8.0.tgz", + "integrity": "sha512-pK8gzfu55/lHzIpQ1givIbWfn3eXnU7SfxqIwVgnn5jEM6j4ZJYjpFqFs4iSBPNedzRMmfjYjuQhu657WAXHXw==", + "dependencies": { + "async": "^3.2.3", + "glob": "^7.0.0", + "resolve": "^1.1.6" + }, + "bin": { + "check-clang-format": "bin/check-clang-format.js", + "clang-format": "index.js", + "git-clang-format": "bin/git-clang-format" + } + }, + "node_modules/clang-format/node_modules/async": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.4.tgz", + "integrity": "sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==" + }, "node_modules/class-utils": { "version": "0.3.6", "resolved": "https://registry.npmjs.org/class-utils/-/class-utils-0.3.6.tgz", @@ -14268,20 +14293,21 @@ "integrity": "sha512-k2d6ACCkiNYz222Fs/iNze30rRJ1iIicW7JuX/7/cozvih6YCkFZH+J6mAFDVgv0dRBaAyr4jDqC95R2y4IADg==" }, "node_modules/realm": { - "version": "10.21.1", - "resolved": "https://registry.npmjs.org/realm/-/realm-10.21.1.tgz", - "integrity": "sha512-I+QzOEw478LGPxgSLvF3YlsMCwRNe4d35uKB77tg2rARDYVtq4pW/XVfbvPrJnwPpgJR53stHmkspj0xvJgFUw==", + "version": "11.0.0-rc.0", + "resolved": "https://registry.npmjs.org/realm/-/realm-11.0.0-rc.0.tgz", + "integrity": "sha512-Lnq5oyj0MseFPU0sykz99PPR2STJAlAUzGFjTrHd/rSR3K/MdRN/l7Bw+R1tWLt0Y45cgTdnWUp9u672fJ4LiA==", "hasInstallScript": true, "dependencies": { "@realm.io/common": "^0.1.4", "bindings": "^1.5.0", "bson": "4.4.1", + "clang-format": "^1.6.0", "command-line-args": "^5.1.1", "deepmerge": "2.1.0", "fs-extra": "^4.0.3", "ini": "^1.3.7", "node-addon-api": "4.2.0", - "node-fetch": "^3.2.10", + "node-fetch": "^3.2.6", "node-machine-id": "^1.1.10", "prebuild-install": "^7.0.1", "progress": "^2.0.3", @@ -14298,7 +14324,7 @@ "npm": ">=7" }, "peerDependencies": { - "react-native": ">=0.64" + "react-native": ">=0.66.0" }, "peerDependenciesMeta": { "react-native": { @@ -19451,15 +19477,20 @@ "resolved": "https://registry.npmjs.org/@realm.io/common/-/common-0.1.5.tgz", "integrity": "sha512-Y+UnICLvsPFpe2WOXWIdJUaV3G2qDocN8al/Yz13mYMkjODXHL4VhyfEKR2hvcAubv+7isdegEyYNdo3zQzbFA==" }, + "@realm/common": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@realm/common/-/common-0.1.4.tgz", + "integrity": "sha512-bKpIRZIQ4ykribFi0igCwuvf7P4+Ex2XYKqDw1JDe6sCGAaPMwhazooyM6h32fUjtXRTbdAWH2S9JH8Xh/LrqQ==" + }, "@realm/react": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@realm/react/-/react-0.2.1.tgz", - "integrity": "sha512-X1UdST8GoTWG6/JVPBSdVgi6UQ2J/9ixXzJ6AFXV6+TomtMtE82/Tb2xI/sU+wTi01y8A96zTnU+IXii9N9Itw==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@realm/react/-/react-0.4.0.tgz", + "integrity": "sha512-nWN0izov3rWdWZdJfGPbO2GfsvWn7h4ET4lbcVOjpt3zarldjAvAnIXzUlkZyKZI3anDj0izEWDieILzRY2jiA==", "requires": { "@babel/runtime": ">=7", + "@realm/common": "^0.1.4", "lodash": "^4.17.21", - "react-native": ">=0.59", - "realm": ">=10" + "react-native": ">=0.68" } }, "@sideway/address": { @@ -20685,6 +20716,23 @@ "integrity": "sha512-uc2Vix1frTfnuzxxu1Hp4ktSvM3QaI4oXl4ZUqL1wjTu/BGki9TrCWoqLTg/drR1KwAEarXuRFCG2Svr1GxPFw==", "dev": true }, + "clang-format": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/clang-format/-/clang-format-1.8.0.tgz", + "integrity": "sha512-pK8gzfu55/lHzIpQ1givIbWfn3eXnU7SfxqIwVgnn5jEM6j4ZJYjpFqFs4iSBPNedzRMmfjYjuQhu657WAXHXw==", + "requires": { + "async": "^3.2.3", + "glob": "^7.0.0", + "resolve": "^1.1.6" + }, + "dependencies": { + "async": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.4.tgz", + "integrity": "sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==" + } + } + }, "class-utils": { "version": "0.3.6", "resolved": "https://registry.npmjs.org/class-utils/-/class-utils-0.3.6.tgz", @@ -27590,19 +27638,20 @@ "integrity": "sha512-k2d6ACCkiNYz222Fs/iNze30rRJ1iIicW7JuX/7/cozvih6YCkFZH+J6mAFDVgv0dRBaAyr4jDqC95R2y4IADg==" }, "realm": { - "version": "10.21.1", - "resolved": "https://registry.npmjs.org/realm/-/realm-10.21.1.tgz", - "integrity": "sha512-I+QzOEw478LGPxgSLvF3YlsMCwRNe4d35uKB77tg2rARDYVtq4pW/XVfbvPrJnwPpgJR53stHmkspj0xvJgFUw==", + "version": "11.0.0-rc.0", + "resolved": "https://registry.npmjs.org/realm/-/realm-11.0.0-rc.0.tgz", + "integrity": "sha512-Lnq5oyj0MseFPU0sykz99PPR2STJAlAUzGFjTrHd/rSR3K/MdRN/l7Bw+R1tWLt0Y45cgTdnWUp9u672fJ4LiA==", "requires": { "@realm.io/common": "^0.1.4", "bindings": "^1.5.0", "bson": "4.4.1", + "clang-format": "^1.6.0", "command-line-args": "^5.1.1", "deepmerge": "2.1.0", "fs-extra": "^4.0.3", "ini": "^1.3.7", "node-addon-api": "4.2.0", - "node-fetch": "^3.2.10", + "node-fetch": "^3.2.6", "node-machine-id": "^1.1.10", "prebuild-install": "^7.0.1", "progress": "^2.0.3", diff --git a/package.json b/package.json index f351e375a..198460335 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "@react-navigation/elements": "^1.3.1", "@react-navigation/native": "^6.0.8", "@react-navigation/native-stack": "^6.5.2", - "@realm/react": "^0.2.1", + "@realm/react": "^0.4.0", "@tanstack/react-query": "^4.2.1", "apisauce": "^2.1.2", "axios": "^0.25.0", @@ -77,7 +77,7 @@ "react-native-vision-camera": "^2.13.0", "react-spring": "^8.0.27", "react-tinder-card": "^1.4.5", - "realm": "^10.20.0-beta.3", + "realm": "^11.0.0-rc.0", "use-debounce": "^7.0.1" }, "devDependencies": { diff --git a/react-native.config.js b/react-native.config.js index 22ccea531..96e30b04c 100644 --- a/react-native.config.js +++ b/react-native.config.js @@ -4,11 +4,6 @@ module.exports = { platforms: { android: null // disable Android platform, other platforms will still autolink if provided } - }, - "react-native-vector-icons": { - platforms: { - ios: null - } } } }; diff --git a/src/api/authorizedApplications.js b/src/api/authorizedApplications.js new file mode 100644 index 000000000..622ea662d --- /dev/null +++ b/src/api/authorizedApplications.js @@ -0,0 +1,26 @@ +// @flow + +import inatjs from "inaturalistjs"; + +import handleError from "./error"; + +const PARAMS = { + fields: "application.official,application.name,created_at" +}; + +const fetchAuthorizedApplications = async ( + params: Object = {}, + opts: Object = {} +): Promise => { + try { + const { results } = await inatjs.authorized_applications.search( + { ...PARAMS, ...params }, + opts + ); + return results; + } catch ( e ) { + return handleError( e ); + } +}; + +export default fetchAuthorizedApplications; diff --git a/src/api/messages.js b/src/api/messages.js index 130997233..d7e3b00f7 100644 --- a/src/api/messages.js +++ b/src/api/messages.js @@ -1,18 +1,24 @@ // @flow import inatjs from "inaturalistjs"; -import MESSAGE_FIELDS from "providers/fields"; +import User from "../models/User"; import handleError from "./error"; -const searchMessages = async ( options: Object ): Promise => { - const params = { - page: 1, - fields: MESSAGE_FIELDS - }; +const MESSAGE_FIELDS = { + subject: true, + body: true, + from_user: User.USER_FIELDS, + to_user: User.USER_FIELDS +}; +const PARAMS = { + fields: MESSAGE_FIELDS +}; + +const searchMessages = async ( params: Object = {}, opts: Object = {} ): Promise => { try { - const { results } = await inatjs.messages.search( params, options ); + const { results } = await inatjs.messages.search( { ...PARAMS, ...params }, opts ); return results; } catch ( e ) { return handleError( e ); diff --git a/src/api/observations.js b/src/api/observations.js new file mode 100644 index 000000000..83a78ceea --- /dev/null +++ b/src/api/observations.js @@ -0,0 +1,91 @@ +// @flow + +import inatjs from "inaturalistjs"; + +import Observation from "../models/Observation"; +import handleError from "./error"; + +const PHOTO_FIELDS = { + id: true, + attribution: true, + license_code: true, + url: true +}; + +const OBSERVATION_PHOTOS_FIELDS = { + id: true, + photo: PHOTO_FIELDS, + position: true, + uuid: true +}; + +const TAXON_FIELDS = { + name: true, + preferred_common_name: true +}; + +const FIELDS = { + observation_photos: OBSERVATION_PHOTOS_FIELDS, + taxon: TAXON_FIELDS +}; + +const PARAMS = { + per_page: 10, + fields: FIELDS +}; + +const REMOTE_OBSERVATION_PARAMS = { + fields: "all" +}; + +const searchObservations = async ( params: Object = {}, opts: Object = {} ): Promise => { + try { + const { results } = await inatjs.observations.search( { ...PARAMS, ...params, ...opts } ); + return results; + } catch ( e ) { + return handleError( e ); + } +}; + +const faveObservation = async ( params: Object = {}, opts: Object = {} ): Promise => { + try { + return await inatjs.observations.fave( params, opts ); + } catch ( e ) { + return handleError( e ); + } +}; + +const unfaveObservation = async ( params: Object = {}, opts: Object = {} ): Promise => { + try { + return await inatjs.observations.unfave( params, opts ); + } catch ( e ) { + return handleError( e ); + } +}; + +const fetchRemoteObservation = async ( + uuid: string, + params: Object = {}, + opts: Object = {} +): Promise => { + try { + const { results } = await inatjs.observations.fetch( + uuid, + { ...REMOTE_OBSERVATION_PARAMS, ...params }, + opts + ); + if ( results && results.length > 0 ) { + return Observation.mimicRealmMappedPropertiesSchema( results[0] ); + } + return null; + } catch ( e ) { + return handleError( e ); + } +}; + +export { + faveObservation, + fetchRemoteObservation, + searchObservations, + unfaveObservation +}; diff --git a/src/api/places.js b/src/api/places.js new file mode 100644 index 000000000..c3dbce6ae --- /dev/null +++ b/src/api/places.js @@ -0,0 +1,20 @@ +// @flow + +import inatjs from "inaturalistjs"; + +import handleError from "./error"; + +const PARAMS = { + fields: "display_name" +}; + +const fetchPlace = async ( id: number, params: Object = {}, opts: Object = {} ): Promise => { + try { + const { results } = await inatjs.places.fetch( id, { ...PARAMS, ...params, ...opts } ); + return results[0]; + } catch ( e ) { + return handleError( e ); + } +}; + +export default fetchPlace; diff --git a/src/api/projects.js b/src/api/projects.js new file mode 100644 index 000000000..d443ab7e8 --- /dev/null +++ b/src/api/projects.js @@ -0,0 +1,45 @@ +// @flow + +import inatjs from "inaturalistjs"; + +import handleError from "./error"; + +const FIELDS = { + title: true, + icon: true, + header_image_url: true, + description: true +}; + +const PARAMS = { + fields: FIELDS +}; + +const fetchProjects = async ( + id: number, + params: Object = {}, + opts: Object = {} +): Promise => { + try { + const { results } = await inatjs.projects.fetch( id, { ...PARAMS, ...params }, opts ); + return results[0]; + } catch ( e ) { + return handleError( e ); + } +}; + +const searchProjects = async ( params: Object = {}, opts: Object = {} ): Promise => { + try { + const { results } = await inatjs.projects.search( { ...PARAMS, ...params }, opts ); + return results; + } catch ( e ) { + return handleError( e ); + } +}; + +export default searchProjects; + +export { + fetchProjects, + searchProjects +}; diff --git a/src/api/providerAuthorizations.js b/src/api/providerAuthorizations.js new file mode 100644 index 000000000..b20db7bc4 --- /dev/null +++ b/src/api/providerAuthorizations.js @@ -0,0 +1,26 @@ +// @flow + +import inatjs from "inaturalistjs"; + +import handleError from "./error"; + +const PARAMS = { + fields: "provider_name,created_at" +}; + +const fetchProviderAuthorizations = async ( + params: Object = {}, + opts: Object = {} +): Promise => { + try { + const { results } = await inatjs.provider_authorizations.search( + { ...PARAMS, ...params }, + opts + ); + return results; + } catch ( e ) { + return handleError( e ); + } +}; + +export default fetchProviderAuthorizations; diff --git a/src/api/relationships.js b/src/api/relationships.js new file mode 100644 index 000000000..133608dac --- /dev/null +++ b/src/api/relationships.js @@ -0,0 +1,21 @@ +// @flow + +import inatjs from "inaturalistjs"; + +import handleError from "./error"; + +const PARAMS = { + fields: "all", + per_page: 10 +}; + +const fetchRelationships = async ( params: Object = {}, opts: Object = {} ): Promise => { + try { + const response = await inatjs.relationships.search( { ...PARAMS, ...params }, opts ); + return response; + } catch ( e ) { + return handleError( e ); + } +}; + +export default fetchRelationships; diff --git a/src/api/search.js b/src/api/search.js new file mode 100644 index 000000000..ca9e4d1a4 --- /dev/null +++ b/src/api/search.js @@ -0,0 +1,32 @@ +// @flow + +import inatjs from "inaturalistjs"; + +import handleError from "./error"; + +const mappedRecords = { + taxa: "taxon", + places: "place", + users: "user", + projects: "project" +}; + +const PARAMS = { + per_page: 10, + fields: "all" +}; + +const fetchSearchResults = async ( params: Object = {}, opts: Object = {} ): Promise => { + try { + const { results } = await inatjs.search( { ...PARAMS, ...params }, opts ); + const records = results.map( result => { + const recordType = mappedRecords[params.sources]; + return result[recordType]; + } ); + return records; + } catch ( e ) { + return handleError( e ); + } +}; + +export default fetchSearchResults; diff --git a/src/api/users.js b/src/api/users.js new file mode 100644 index 000000000..8456684d6 --- /dev/null +++ b/src/api/users.js @@ -0,0 +1,105 @@ +// @flow + +import inatjs from "inaturalistjs"; + +import handleError from "./error"; + +const PARAMS = { + fields: "all" +}; + +const MEMBER_PROJECT_FIELDS = { + title: true, + icon: true +}; + +const MEMBER_PROJECT_PARAMS = { + per_page: 10, + fields: MEMBER_PROJECT_FIELDS +}; + +const REMOTE_USER_FIELDS = { + name: true, + login: true, + icon_url: true, + created_at: true, + roles: true, + site_id: true, + description: true, + updated_at: true, + species_count: true, + observations_count: true, + identifications_count: true, + journal_posts_count: true, + site: true +}; + +const REMOTE_USER_PARAMS = { + fields: REMOTE_USER_FIELDS +}; + +const fetchUserMe = async ( params: Object = {}, opts: Object = {} ): Promise => { + try { + const { results } = await inatjs.users.me( { ...PARAMS, ...params, ...opts } ); + return results[0]; + } catch ( e ) { + return handleError( e ); + } +}; + +const fetchMemberProjects = async ( params: Object = {}, opts: Object = {} ): Promise => { + try { + const { results } = await inatjs.users.projects( { + ...MEMBER_PROJECT_PARAMS, + ...params, + ...opts + } ); + return results; + } catch ( e ) { + return handleError( e ); + } +}; + +const fetchRemoteUser = async ( + id: number, + params: Object = {}, + opts: Object = {} +): Promise => { + if ( !id ) return null; + try { + const { results } = await inatjs.users.fetch( id, { + ...REMOTE_USER_PARAMS, + ...params, + ...opts + } ); + return results; + } catch ( e ) { + return handleError( e ); + } +}; + +const fetchRemoteUsers = async ( + ids: Array, + params: Object = {}, + opts: Object = {} +): Promise => { + try { + const responses = await Promise.all( ids.map( + userId => inatjs.users.fetch( userId, { + ...REMOTE_USER_PARAMS, + ...params, + ...opts + } ) + ) ); + return responses.map( r => r.results[0] ); + } catch ( e ) { + return handleError( e ); + } +}; + +export { + fetchMemberProjects, + fetchRemoteUser, + fetchRemoteUsers, + fetchUserMe +}; diff --git a/src/components/Explore/DropdownPicker.js b/src/components/Explore/DropdownPicker.js index a1f7282c4..99c68304d 100644 --- a/src/components/Explore/DropdownPicker.js +++ b/src/components/Explore/DropdownPicker.js @@ -1,5 +1,6 @@ // flow +import fetchSearchResults from "api/search"; import { t } from "i18next"; import type { Node } from "react"; import React from "react"; @@ -8,8 +9,9 @@ import { Image } from "react-native"; // and allows users to input immediately instead of first tapping the dropdown // this is a placeholder to get functionality working import DropDownPicker from "react-native-dropdown-picker"; -import useRemoteSearchResults from "sharedHooks/useRemoteSearchResults"; +import useAuthenticatedQuery from "sharedHooks/useAuthenticatedQuery"; import { imageStyles, viewStyles } from "styles/explore/explore"; +import { useDebounce } from "use-debounce"; type Props = { searchQuery: string, @@ -38,7 +40,18 @@ const DropdownPicker = ( { zIndex, zIndexInverse }: Props ): Node => { - const searchResults = useRemoteSearchResults( searchQuery, sources ); + // So we'll start searching only once the user finished typing + const [finalSearch] = useDebounce( searchQuery, 500 ); + + const { + data: searchResults + } = useAuthenticatedQuery( + ["fetchSearchResults", finalSearch], + optsWithAuth => fetchSearchResults( { + q: finalSearch, + sources + }, optsWithAuth ) + ); const placesItem = place => ( { label: place.name, @@ -75,6 +88,10 @@ const DropdownPicker = ( { } ); const displayItems = ( ) => { + if ( finalSearch === "" ) { + return []; + } + if ( !searchResults ) { return []; } if ( sources === "places" ) { return searchResults.map( item => placesItem( item ) ); } if ( sources === "taxa" ) { @@ -87,6 +104,9 @@ const DropdownPicker = ( { return []; }; + // TODO: change to the same style of dropdown as in SettingsRelationships? + // this should be standardized throughout the app + return ( diff --git a/src/components/Identify/Identify.js b/src/components/Identify/Identify.js index d82b44dd7..efa764646 100644 --- a/src/components/Identify/Identify.js +++ b/src/components/Identify/Identify.js @@ -1,16 +1,18 @@ // @flow +import { searchObservations } from "api/observations"; import DropdownPicker from "components/Explore/DropdownPicker"; import ViewWithFooter from "components/SharedComponents/ViewWithFooter"; import type { Node } from "react"; import React, { useState } from "react"; import { useTranslation } from "react-i18next"; import { Pressable, Text, View } from "react-native"; +import useAuthenticatedQuery from "sharedHooks/useAuthenticatedQuery"; import { viewStyles } from "styles/identify/identify"; +import Observation from "../../models/Observation"; import CardSwipeView from "./CardSwipeView"; import GridView from "./GridView"; -import useObservations from "./hooks/useObservations"; const Identify = ( ): Node => { const [view, setView] = React.useState( "grid" ); @@ -18,7 +20,28 @@ const Identify = ( ): Node => { const [placeId, setPlaceId] = useState( null ); const [taxon, setTaxon] = useState( "" ); const [taxonId, setTaxonId] = useState( null ); - const { observations, loading } = useObservations( placeId, taxonId ); + + const searchParams = { + reviewed: false, + fields: Observation.FIELDS + }; + + if ( placeId ) { + // $FlowIgnore + searchParams.place_id = placeId; + } + if ( taxonId ) { + // $FlowIgnore + searchParams.taxon_id = taxonId; + } + + const { + data: observations, + isLoading + } = useAuthenticatedQuery( + ["searchObservations"], + optsWithAuth => searchObservations( searchParams, optsWithAuth ) + ); const updatePlaceId = getValue => setPlaceId( getValue( ) ); const updateTaxonId = getValue => setTaxonId( getValue( ) ); @@ -34,7 +57,7 @@ const Identify = ( ): Node => { } return ( diff --git a/src/components/Identify/hooks/useObservations.js b/src/components/Identify/hooks/useObservations.js deleted file mode 100644 index fff84013e..000000000 --- a/src/components/Identify/hooks/useObservations.js +++ /dev/null @@ -1,68 +0,0 @@ -// @flow - -import inatjs from "inaturalistjs"; -import { useEffect, useState } from "react"; - -import Observation from "../../../models/Observation"; - -const useObservations = ( placeId: ?string, taxonId: ?number ): { - observations: Array, - loading: boolean -} => { - const [loading, setLoading] = useState( false ); - const [observations, setObservations] = useState( [] ); - - useEffect( ( ) => { - let isCurrent = true; - const fetchObservations = async ( ) => { - setLoading( true ); - try { - const params = { - reviewed: false, - // viewer_id: 1132118, - // locale: null, - // ttl: -1, - // order_by: "random", - // quality_grade: "any", - fields: Observation.FIELDS - }; - if ( placeId ) { - // $FlowFixMe - params.place_id = placeId; - } - if ( taxonId ) { - // $FlowFixMe - params.taxon_id = taxonId; - } - // $FlowFixMe - params.fields.observation_photos.photo.medium_url = true; - - const response = await inatjs.observations.search( params ); - const { results } = response; - if ( !isCurrent ) { return; } - setLoading( false ); - setObservations( results ); - } catch ( e ) { - setLoading( false ); - if ( !isCurrent ) { return; } - console.log( "Couldn't fetch observations for identify:", JSON.stringify( e.response ) ); - } - }; - - // this is for performance, so we're not searching the entire globe and all organisms - if ( taxonId || placeId ) { - fetchObservations( ); - } - - return ( ) => { - isCurrent = false; - }; - }, [placeId, taxonId] ); - - return { - observations, - loading - }; -}; - -export default useObservations; diff --git a/src/components/Messages/Messages.js b/src/components/Messages/Messages.js index 2ac9d0e79..82e17901f 100644 --- a/src/components/Messages/Messages.js +++ b/src/components/Messages/Messages.js @@ -4,22 +4,25 @@ import searchMessages from "api/messages"; import ViewWithFooter from "components/SharedComponents/ViewWithFooter"; import type { Node } from "react"; import React from "react"; -import useQuery from "sharedHooks/useAuthenticatedQuery"; +import useAuthenticatedQuery from "sharedHooks/useAuthenticatedQuery"; import MessageList from "./MessageList"; const Messages = ( ): Node => { const { - data: messages, + data, isLoading - } = useQuery( ["searchMessages"], searchMessages ); + } = useAuthenticatedQuery( + ["searchMessages"], + optsWithAuth => searchMessages( { page: 1 }, optsWithAuth ) + ); // TODO: Reload when accessing again return ( diff --git a/src/components/ObsDetails/ObsDetails.js b/src/components/ObsDetails/ObsDetails.js index cbab18ad2..90dcd9597 100644 --- a/src/components/ObsDetails/ObsDetails.js +++ b/src/components/ObsDetails/ObsDetails.js @@ -5,8 +5,9 @@ import { BottomSheetTextInput } from "@gorhom/bottom-sheet"; import { useNavigation, useRoute } from "@react-navigation/native"; +import { useQueryClient } from "@tanstack/react-query"; +import { faveObservation, fetchRemoteObservation, unfaveObservation } from "api/observations"; import createIdentification from "components/Identify/helpers/createIdentification"; -import { getUser } from "components/LoginSignUp/AuthenticationService"; import Button from "components/SharedComponents/Buttons/Button"; import PhotoScroll from "components/SharedComponents/PhotoScroll"; import QualityBadge from "components/SharedComponents/QualityBadge"; @@ -30,6 +31,8 @@ import { ActivityIndicator, Button as IconButton } from "react-native-paper"; import IconMaterial from "react-native-vector-icons/MaterialIcons"; import { formatObsListTime } from "sharedHelpers/dateAndTime"; import useApiToken from "sharedHooks/useApiToken"; +import useAuthenticatedQuery from "sharedHooks/useAuthenticatedQuery"; +import useCurrentUser from "sharedHooks/useCurrentUser"; import colors from "styles/colors"; import { imageStyles, textStyles, viewStyles } from "styles/obsDetails/obsDetails"; @@ -40,8 +43,6 @@ import ActivityTab from "./ActivityTab"; import DataTab from "./DataTab"; import checkCamelAndSnakeCase from "./helpers/checkCamelAndSnakeCase"; import createComment from "./helpers/createComment"; -import faveObservation from "./helpers/faveObservation"; -import useRemoteObservation from "./hooks/useRemoteObservation"; import ObsDetailsHeader from "./ObsDetailsHeader"; const { useRealm } = RealmContext; @@ -54,13 +55,15 @@ LogBox.ignoreLogs( [ ] ); const ObsDetails = ( ): Node => { + const currentUser = useCurrentUser( ); + const userId = currentUser?.id; const { t } = useTranslation( ); const [refetch, setRefetch] = useState( false ); const [showCommentBox, setShowCommentBox] = useState( false ); const [comment, setComment] = useState( "" ); const { addObservations } = useContext( ObsEditContext ); const { params } = useRoute( ); - let { observation } = params; + const { uuid } = params; const [tab, setTab] = useState( 0 ); const navigation = useNavigation( ); const [ids, setIds] = useState( [] ); @@ -68,9 +71,30 @@ const ObsDetails = ( ): Node => { const [addingComment, setAddingComment] = useState( false ); const [snapPoint, setSnapPoint] = useState( 100 ); const apiToken = useApiToken( ); + const [localObservation, setLocalObservation] = useState( null ); + + const queryClient = useQueryClient( ); + + const { + data: remoteObservation + } = useAuthenticatedQuery( + ["fetchRemoteObservation", uuid], + optsWithAuth => fetchRemoteObservation( uuid, { }, optsWithAuth ) + ); const realm = useRealm( ); + useEffect( ( ) => { + setLocalObservation( realm?.objectForPrimaryKey( "Observation", uuid ) ); + }, [realm, uuid] ); + + const observation = localObservation || remoteObservation; + + const taxon = observation?.taxon; + const user = observation?.user; + const faves = observation?.faves; + const currentUserFaved = faves?.length > 0 ? faves.find( fave => fave.user.id === userId ) : null; + // Clear the comment in a timeout so it doesn't trigger a re-render of the // text input *after* the bottom sheet modal gets dismissed, b/c that seems // to re-render the bottom sheet in a presented state, making it hard to @@ -115,15 +139,6 @@ const ObsDetails = ( ): Node => { ); - // TODO: we'll probably need to redo this logic a bit now that we're - // passing an observation via navigation instead of reopening realm - const { remoteObservation, currentUserFaved } = useRemoteObservation( observation, refetch ); - - /* TODO - removed this since otherwise refreshing new comments will not work (will - always use old local copy that doesn't include new comments) */ - if ( remoteObservation ) { - observation = remoteObservation; - } const showActivityTab = ( ) => setTab( 0 ); const showDataTab = ( ) => setTab( 1 ); @@ -132,33 +147,31 @@ const ObsDetails = ( ): Node => { useEffect( () => { const markViewedLocally = async ( ) => { if ( !apiToken ) return; - const existingObs = realm?.objectForPrimaryKey( "Observation", observation.uuid ); + const existingObs = realm?.objectForPrimaryKey( "Observation", uuid ); if ( !existingObs ) { return; } realm?.write( ( ) => { existingObs.viewed = true; } ); }; if ( observation ) { setIds( Array.from( observation.identifications ) ); } - if ( observation.viewed === false ) { - Observation.markObservationUpdatesViewed( observation.uuid, apiToken ); + if ( observation?.viewed === false ) { + Observation.markObservationUpdatesViewed( uuid, apiToken ); markViewedLocally( ); } - }, [apiToken, observation, realm] ); + }, [apiToken, observation, uuid, realm] ); if ( !observation ) { return null; } const comments = Array.from( observation.comments ); const photos = _.compact( Array.from( observation.observationPhotos ).map( op => op.photo ) ); - const { taxon, uuid, user } = observation; const onIDAdded = async identification => { // Add temporary ID to observation.identifications ("ghosted" ID, while we're trying to add it) - const currentUser = await getUser(); const newId = { body: identification.body, taxon: identification.taxon, user: { - id: currentUser?.id, + id: userId, login: currentUser?.login, signedIn: true }, @@ -175,7 +188,7 @@ const ObsDetails = ( ): Node => { try { const results = await createIdentification( { - observation_id: observation.uuid, + observation_id: uuid, taxon_id: newId.taxon.id, body: newId.body } ); @@ -207,7 +220,7 @@ const ObsDetails = ( ): Node => { } }; - const navToUserProfile = userId => navigation.navigate( "UserProfile", { userId } ); + const navToUserProfile = id => navigation.navigate( "UserProfile", { userId: id } ); const navToTaxonDetails = ( ) => navigation.navigate( "TaxonDetails", { id: taxon.id } ); const navToAddID = ( ) => { addObservations( [observation] ); @@ -274,12 +287,15 @@ const ObsDetails = ( ): Node => { ); const faveOrUnfave = async ( ) => { + // TODO: fix fave/unfave functionality with useMutation if ( currentUserFaved ) { - await faveObservation( uuid, "unfave" ); - setRefetch( !refetch ); + await unfaveObservation( { uuid } ); + setRefetch( true ); + queryClient.invalidateQueries( ["fetchRemoteObservation"] ); } else { - await faveObservation( uuid, "fave" ); - setRefetch( !refetch ); + await faveObservation( { uuid } ); + setRefetch( true ); + queryClient.invalidateQueries( ["fetchRemoteObservation"] ); } }; @@ -290,7 +306,7 @@ const ObsDetails = ( ): Node => { return ( - + { - const [isLocal, setIsLocal] = useState( null ); - const { openSavedObservation } = React.useContext( ObsEditContext ); - +const ObsDetailsHeader = ( { observation }: Props ): Node => { const { t } = useTranslation( ); const navigation = useNavigation( ); + const currentUser = useCurrentUser( ); + const obsCreatedLocally = observation?.id === null; + const obsOwnedByCurrentUser = observation?.user?.id === currentUser?.id; - const navToObsEdit = ( ) => navigation.navigate( "ObsEdit" ); - - useEffect( ( ) => { - const checkForLocalObservation = async ( ) => { - const isLocalObservation = await openSavedObservation( observationUUID ); - setIsLocal( isLocalObservation ); - }; - - navigation.addListener( "focus", ( ) => { - checkForLocalObservation( ); - } ); - }, [observationUUID, openSavedObservation, navigation] ); + const navToObsEdit = ( ) => navigation.navigate( "ObsEdit", { uuid: observation?.uuid } ); return ( navigation.goBack( )} /> {t( "Observation" )} - {isLocal ?