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 <kenichi.ueda@gmail.com>
This commit is contained in:
Amanda Bullington
2022-10-19 13:01:04 -07:00
committed by GitHub
parent c4d9cd4dc6
commit e81894d406
85 changed files with 1146 additions and 1779 deletions

2
.nvmrc
View File

@@ -1 +1 @@
12.13.0
16.17.0

View File

@@ -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

View File

@@ -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

97
package-lock.json generated
View File

@@ -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",

View File

@@ -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": {

View File

@@ -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
}
}
}
};

View File

@@ -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<any> => {
try {
const { results } = await inatjs.authorized_applications.search(
{ ...PARAMS, ...params },
opts
);
return results;
} catch ( e ) {
return handleError( e );
}
};
export default fetchAuthorizedApplications;

View File

@@ -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<any> => {
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<any> => {
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 );

91
src/api/observations.js Normal file
View File

@@ -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<any> => {
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<?number> => {
try {
return await inatjs.observations.fave( params, opts );
} catch ( e ) {
return handleError( e );
}
};
const unfaveObservation = async ( params: Object = {}, opts: Object = {} ): Promise<?number> => {
try {
return await inatjs.observations.unfave( params, opts );
} catch ( e ) {
return handleError( e );
}
};
const fetchRemoteObservation = async (
uuid: string,
params: Object = {},
opts: Object = {}
): Promise<?number> => {
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
};

20
src/api/places.js Normal file
View File

@@ -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<any> => {
try {
const { results } = await inatjs.places.fetch( id, { ...PARAMS, ...params, ...opts } );
return results[0];
} catch ( e ) {
return handleError( e );
}
};
export default fetchPlace;

45
src/api/projects.js Normal file
View File

@@ -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<any> => {
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<any> => {
try {
const { results } = await inatjs.projects.search( { ...PARAMS, ...params }, opts );
return results;
} catch ( e ) {
return handleError( e );
}
};
export default searchProjects;
export {
fetchProjects,
searchProjects
};

View File

@@ -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<any> => {
try {
const { results } = await inatjs.provider_authorizations.search(
{ ...PARAMS, ...params },
opts
);
return results;
} catch ( e ) {
return handleError( e );
}
};
export default fetchProviderAuthorizations;

21
src/api/relationships.js Normal file
View File

@@ -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<any> => {
try {
const response = await inatjs.relationships.search( { ...PARAMS, ...params }, opts );
return response;
} catch ( e ) {
return handleError( e );
}
};
export default fetchRelationships;

32
src/api/search.js Normal file
View File

@@ -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<any> => {
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;

105
src/api/users.js Normal file
View File

@@ -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<any> => {
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<any> => {
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<any> => {
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<number>,
params: Object = {},
opts: Object = {}
): Promise<any> => {
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
};

View File

@@ -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 (
<DropDownPicker
onClose={onClose}

View File

@@ -68,7 +68,7 @@ const GridItem = ( {
testID="ObsList.photo"
/>
<Image
source={{ uri: item.user.icon_url }}
source={{ uri: item?.user?.icon_url }}
style={imageStyles.userImage}
testID="ObsList.identifierPhoto"
/>

View File

@@ -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 (
<GridView
loading={loading}
loading={isLoading}
observationList={observations}
testID="Identify.observationGrid"
/>

View File

@@ -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<Object>,
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;

View File

@@ -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 (
<ViewWithFooter>
<MessageList
loading={isLoading}
messageList={messages}
messageList={data}
testID="Messages.messages"
/>
</ViewWithFooter>

View File

@@ -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 => {
</TouchableOpacity>
);
// 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 (
<BottomSheetModalProvider>
<ViewWithFooter>
<ObsDetailsHeader observationUUID={uuid} />
<ObsDetailsHeader observation={observation} />
<ScrollView
testID={`ObsDetails.${uuid}`}
contentContainerStyle={viewStyles.scrollView}

View File

@@ -2,44 +2,37 @@
import { HeaderBackButton } from "@react-navigation/elements";
import { useNavigation } from "@react-navigation/native";
import { ObsEditContext } from "providers/contexts";
import type { Node } from "react";
import React, { useEffect, useState } from "react";
import React from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { Button, Headline } from "react-native-paper";
import useCurrentUser from "sharedHooks/useCurrentUser";
import colors from "styles/colors";
import { viewStyles } from "styles/obsDetails/obsDetailsHeader";
type Props = {
observationUUID: string
observation: ?Object
}
const ObsDetailsHeader = ( { observationUUID }: Props ): Node => {
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 (
<View style={viewStyles.headerRow}>
<HeaderBackButton onPress={( ) => navigation.goBack( )} />
<Headline>{t( "Observation" )}</Headline>
{isLocal ? <Button icon="pencil" onPress={navToObsEdit} textColor={colors.gray} /> : <View />}
{
( obsCreatedLocally || obsOwnedByCurrentUser )
? <Button icon="pencil" onPress={navToObsEdit} textColor={colors.gray} />
: <View />
}
</View>
);
};

View File

@@ -1,25 +0,0 @@
// @flow
import { getJWTToken } from "components/LoginSignUp/AuthenticationService";
import inatjs from "inaturalistjs";
const faveObservation = async ( uuid: string, endpoint: string ): Promise<?number> => {
const apiToken = await getJWTToken( false );
try {
const params = {
uuid
};
const options = {
api_token: apiToken
};
const response = await inatjs.observations[endpoint]( params, options );
return response.total_results;
} catch ( e ) {
console.log( "Couldn't fave observation:", JSON.stringify( e.response ) );
}
return 0;
};
export default faveObservation;

View File

@@ -1,84 +0,0 @@
// @flow
import NetInfo from "@react-native-community/netinfo";
import { getUsername } from "components/LoginSignUp/AuthenticationService";
import inatjs from "inaturalistjs";
import { useEffect, useRef, useState } from "react";
import Observation from "../../../models/Observation";
import User from "../../../models/User";
const useRemoteObservation = ( observation: Object, refetch: boolean ): Object => {
const [remoteObservation, setRemoteObservation] = useState( null );
const [isConnected, setIsConnected] = useState( null );
const [currentUserFaved, setCurrentUserFaved] = useState( null );
const prevRefetch = useRef( refetch ).current;
useEffect( ( ) => {
const unsubscribe = NetInfo.addEventListener( state => {
setIsConnected( state.isConnected );
} );
// Unsubscribe
unsubscribe( );
}, [] );
useEffect( ( ) => {
let isCurrent = true;
const fetchObservation = async ( ) => {
try {
const currentUserLogin = await getUsername( );
const params = {
fields: Observation.FIELDS
};
// $FlowFixMe
params.fields.application = {
icon: true,
name: true,
url: true
};
// $FlowFixMe
params.fields.faves = {
user: User.USER_FIELDS
};
const response = await inatjs.observations.fetch( observation.uuid, params );
const { results } = response;
const obs = Observation.mimicRealmMappedPropertiesSchema( results[0] );
if ( !isCurrent ) { return; }
if ( obs.faves ) {
const userFavedObs = obs.faves.find( fave => fave.user.login === currentUserLogin );
if ( userFavedObs ) {
setCurrentUserFaved( true );
} else {
setCurrentUserFaved( false );
}
}
setRemoteObservation( obs );
} catch ( e ) {
if ( !isCurrent ) { return; }
console.log( `Couldn't fetch observation with uuid ${observation.uuid}: `, e.message );
}
};
// TODO: probably need an error message for no connectivity
if ( isConnected && ( !observation || refetch !== prevRefetch ) ) {
fetchObservation( );
}
return ( ) => {
isCurrent = false;
};
}, [observation, isConnected, refetch, prevRefetch] );
return {
remoteObservation,
currentUserFaved
};
};
export default useRemoteObservation;

View File

@@ -6,6 +6,7 @@ import {
BottomSheetModalProvider
} from "@gorhom/bottom-sheet";
import { useNavigation } from "@react-navigation/native";
import fetchSearchResults from "api/search";
import ViewNoFooter from "components/SharedComponents/ViewNoFooter";
import * as React from "react";
import { useRef, useState } from "react";
@@ -13,7 +14,9 @@ import { useTranslation } from "react-i18next";
import {
FlatList,
Image,
Pressable, TextInput as NativeTextInput, TouchableOpacity,
Pressable,
TextInput as NativeTextInput,
TouchableOpacity,
View
} from "react-native";
import {
@@ -21,7 +24,7 @@ import {
} from "react-native-paper";
import uuid from "react-native-uuid";
import IconMaterial from "react-native-vector-icons/MaterialIcons";
import useRemoteSearchResults from "sharedHooks/useRemoteSearchResults";
import useAuthenticatedQuery from "sharedHooks/useAuthenticatedQuery";
import colors from "styles/colors";
import { textStyles, viewStyles } from "styles/obsDetails/addID";
@@ -50,11 +53,17 @@ const AddID = ( { route }: Props ): React.Node => {
const { onIDAdded, goBackOnSave, hideComment } = route.params;
const bottomSheetModalRef = useRef( null );
const [taxonSearch, setTaxonSearch] = useState( "" );
const taxonList = useRemoteSearchResults(
taxonSearch,
"taxa",
"taxon.name,taxon.preferred_common_name,taxon.default_photo.square_url,taxon.rank"
const {
data: taxonList
} = useAuthenticatedQuery(
["fetchSearchResults", taxonSearch],
optsWithAuth => fetchSearchResults( {
q: taxonSearch,
sources: "taxa",
fields: "taxon.name,taxon.preferred_common_name,taxon.default_photo.square_url,taxon.rank"
}, optsWithAuth )
);
const navigation = useNavigation( );
const renderBackdrop = props => (

View File

@@ -1,181 +0,0 @@
// @flow
import { useNavigation } from "@react-navigation/native";
import PlaceholderText from "components/PlaceholderText";
import Button from "components/SharedComponents/Buttons/Button";
import PhotoCarousel from "components/SharedComponents/PhotoCarousel";
import ViewNoFooter from "components/SharedComponents/ViewNoFooter";
import { t } from "i18next";
import { ObsEditContext } from "providers/contexts";
import type { Node } from "react";
import React, { useContext, useState } from "react";
import {
ActivityIndicator, FlatList, Image, Pressable, Text, View
} from "react-native";
import { Searchbar } from "react-native-paper";
import useLoggedIn from "sharedHooks/useLoggedIn";
import useRemoteObsEditSearchResults from "sharedHooks/useRemoteSearchResults";
import { textStyles, viewStyles } from "styles/obsEdit/cvSuggestions";
import useCVSuggestions from "./hooks/useCVSuggestions";
const CVSuggestions = ( ): Node => {
const {
observations,
currentObsIndex,
updateTaxon
} = useContext( ObsEditContext );
const navigation = useNavigation( );
const [showSeenNearby, setShowSeenNearby] = useState( true );
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState( 0 );
const [q, setQ] = React.useState( "" );
const list = useRemoteObsEditSearchResults( q, "taxa", "all" );
const isLoggedIn = useLoggedIn( );
const currentObs = observations[currentObsIndex];
const hasPhotos = currentObs.observationPhotos;
const { suggestions, status } = useCVSuggestions(
currentObs,
showSeenNearby,
selectedPhotoIndex
);
const renderNavButtons = ( updateIdentification, id ) => {
const navToTaxonDetails = ( ) => navigation.navigate( "TaxonDetails", { id } );
return (
<View>
<Pressable onPress={navToTaxonDetails}>
<PlaceholderText text="info" />
</Pressable>
<PlaceholderText text="compare tool" />
<Pressable onPress={updateIdentification}>
<PlaceholderText text="confirm id" />
</Pressable>
</View>
);
};
const renderSuggestions = ( { item } ) => {
const taxon = item && item.taxon;
// destructuring so this doesn't cause a crash
const mediumUrl = ( taxon && taxon.taxon_photos && taxon.taxon_photos[0].photo )
? taxon.taxon_photos[0].photo.medium_url
: null;
const uri = { uri: mediumUrl };
const updateIdentification = ( ) => updateTaxon( taxon );
return (
<View style={viewStyles.row}>
<Image
source={uri}
style={viewStyles.imageBackground}
/>
<View style={viewStyles.obsDetailsColumn}>
<Text style={textStyles.text}>{taxon.preferred_common_name}</Text>
<Text style={textStyles.text}>{taxon.name}</Text>
{showSeenNearby
&& <PlaceholderText style={[textStyles.greenText]} text="seen nearby" />}
</View>
{renderNavButtons( updateIdentification, taxon.id )}
</View>
);
};
const renderSearchResults = ( { item } ) => {
const uri = { uri: item.default_photo.square_url };
const newTaxon = {
name: item.name,
preferred_common_name: item.preferred_common_name,
id: item.id
};
const updateIdentification = ( ) => updateTaxon( newTaxon );
return (
<View style={viewStyles.row}>
<Image
source={uri}
style={viewStyles.imageBackground}
/>
<View style={viewStyles.obsDetailsColumn}>
<Text style={textStyles.text}>{item.preferred_common_name}</Text>
<Text style={textStyles.text}>{item.name}</Text>
</View>
{renderNavButtons( updateIdentification, item.id )}
</View>
);
};
const toggleSeenNearby = ( ) => setShowSeenNearby( !showSeenNearby );
const emptySuggestionsList = ( ) => {
if ( !isLoggedIn ) {
return (
<PlaceholderText
style={[textStyles.explainerText]}
text="you must be logged in to see computer vision suggestions"
/>
);
}
if ( status === "no_results" ) {
return (
<PlaceholderText
style={[textStyles.explainerText]}
text="no computervision suggestions found"
/>
);
}
return <ActivityIndicator />;
};
const displaySuggestions = ( ) => (
<FlatList
data={suggestions}
renderItem={renderSuggestions}
ListEmptyComponent={hasPhotos && emptySuggestionsList}
/>
);
const displaySearchResults = ( ) => (
<FlatList
data={list}
renderItem={renderSearchResults}
/>
);
const displayPhotos = ( ) => currentObs.observationPhotos.map( p => ( {
uri: p.photo?.url || p?.photo?.localFilePath
} ) );
return (
<ViewNoFooter>
<View>
{hasPhotos && (
<PhotoCarousel
photoUris={displayPhotos( )}
setSelectedPhotoIndex={setSelectedPhotoIndex}
selectedPhotoIndex={selectedPhotoIndex}
/>
)}
<Searchbar
placeholder={t( "Tap-to-search-for-taxa" )}
onChangeText={setQ}
value={q}
style={viewStyles.searchBar}
/>
</View>
{list.length > 0 ? displaySearchResults( ) : displaySuggestions( )}
<Button
level="primary"
onPress={toggleSeenNearby}
text={showSeenNearby ? "View species not seen nearby" : "View seen nearby"}
testID="CVSuggestions.toggleSeenNearby"
/>
</ViewNoFooter>
);
};
export default CVSuggestions;

View File

@@ -30,7 +30,6 @@ const IdentificationSection = ( ): Node => {
const updateIdentification = taxon => updateTaxon( taxon );
const onIDAdded = async id => {
console.log( "onIDAdded", id );
updateIdentification( id.taxon );
};

View File

@@ -43,6 +43,7 @@ const ObsEdit = ( ): Node => {
currentObsIndex,
setCurrentObsIndex,
observations,
openSavedObservation,
saveObservation,
saveAndUploadObservation,
setObservations,
@@ -226,6 +227,14 @@ const ObsEdit = ( ): Node => {
setPhotoUris( uris );
}, [currentObs] );
useEffect( ( ) => {
if ( currentObs ) return;
if ( !params?.uuid ) return;
// This should set the current obs in the context
openSavedObservation( params?.uuid );
}, [currentObs, openSavedObservation, params?.uuid] );
const addEvidence = () => {
bottomSheetModalRef.current?.present();
};

View File

@@ -1,79 +0,0 @@
// @flow
import InputField from "components/SharedComponents/InputField";
import * as React from "react";
import {
FlatList, Image, Pressable, Text
} from "react-native";
import useRemoteObsEditSearchResults from "sharedHooks/useRemoteSearchResults";
import { imageStyles, viewStyles } from "styles/search/search";
type Props = {
source: string,
handlePress: Function
}
const ObsEditSearch = ( {
source,
handlePress
}: Props ): React.Node => {
const [q, setQ] = React.useState( "" );
// choose users or taxa
const list = useRemoteObsEditSearchResults( q, source, "all" );
// TODO: when UI is finalized, make sure these list results are not duplicate UI
// with Search or Projects; share components if possible
const renderItem = ( { item } ) => {
if ( source === "taxa" ) {
const imageUrl = ( item && item.default_photo ) && { uri: item.default_photo.square_url };
return (
<Pressable
onPress={( ) => handlePress( item.id )}
style={viewStyles.row}
testID={`ObsEditSearch.taxa.${item.id}`}
>
<Image
source={imageUrl}
style={imageStyles.squareImage}
testID={`ObsEditSearch.taxa.${item.id}.photo`}
/>
<Text>{`${item.preferred_common_name} (${item.rank} ${item.name})`}</Text>
</Pressable>
);
}
return (
<Pressable
onPress={( ) => handlePress( item.id )}
style={viewStyles.row}
testID={`ObsEditSearch.project.${item.id}`}
>
<Image
source={{ uri: item.icon }}
style={imageStyles.squareImage}
testID={`ObsEditSearch.project.${item.id}.photo`}
/>
<Text>{item.title}</Text>
</Pressable>
);
};
return (
<>
<InputField
handleTextChange={setQ}
placeholder={source === "taxa" ? "search for taxa" : "search for projects"}
text={q}
type="none"
/>
{list.length > 0 && (
<FlatList
data={list}
renderItem={renderItem}
testID="ObsEditSearch.listView"
/>
)}
</>
);
};
export default ObsEditSearch;

View File

@@ -1,93 +0,0 @@
// @flow
import { getJWTToken } from "components/LoginSignUp/AuthenticationService";
import inatjs, { FileUpload } from "inaturalistjs";
import { useEffect, useState } from "react";
import Photo from "../../../models/Photo";
const TAXON_FIELDS = {
name: true,
preferred_common_name: true
};
const PHOTO_FIELDS = {
medium_url: true
};
const FIELDS = {
taxon: {
...TAXON_FIELDS,
taxon_photos: {
photo: PHOTO_FIELDS
}
}
};
const useCVSuggestions = (
currentObs: Object,
showSeenNearby: boolean,
selectedPhoto: number
): Object => {
const [suggestions, setSuggestions] = useState( [] );
const [status, setStatus] = useState( null );
useEffect( ( ) => {
if ( !currentObs || !currentObs.observationPhotos ) { return ( ) => { }; }
const uri = currentObs.observationPhotos && currentObs.observationPhotos[selectedPhoto].uri;
const { latitude } = currentObs;
const { longitude } = currentObs;
let isCurrent = true;
const fetchCVSuggestions = async ( ): Promise<Object> => {
try {
setSuggestions( [] );
// observed_on: new Date( time * 1000 ).toISOString(),
const apiToken = await getJWTToken( false );
const resizedPhoto = await Photo.resizeImageForUpload( uri );
const params = {
image: new FileUpload( {
uri: resizedPhoto,
name: "photo.jpeg",
type: "image/jpeg"
} ),
fields: FIELDS
};
if ( showSeenNearby ) {
// $FlowFixMe
params.latitude = latitude;
// $FlowFixMe
params.longitude = longitude;
}
const options = {
api_token: apiToken
};
const r = await inatjs.computervision.score_image( params, options );
if ( r.total_results > 0 ) {
setSuggestions( r.results );
} else {
setStatus( "no_results" );
}
if ( !isCurrent ) { return; }
} catch ( e ) {
console.log( JSON.stringify( e.response ), "couldn't fetch CV suggestions" );
}
};
fetchCVSuggestions( );
return ( ) => {
isCurrent = false;
};
}, [currentObs, showSeenNearby, selectedPhoto] );
return {
suggestions,
status
};
};
export default useCVSuggestions;

View File

@@ -1,21 +1,32 @@
// @flow
import { useRoute } from "@react-navigation/native";
import ViewWithFooter from "components/SharedComponents/ViewWithFooter";
import { fetchProjects } from "api/projects";
import ScrollWithFooter from "components/SharedComponents/ScrollWithFooter";
import * as React from "react";
import { Image, ImageBackground, Text } from "react-native";
import {
Image, ImageBackground, Text
} from "react-native";
import useAuthenticatedQuery from "sharedHooks/useAuthenticatedQuery";
import { imageStyles, textStyles } from "styles/projects/projectDetails";
import useProjectDetails from "./hooks/useProjectDetails";
import ProjectObservations from "./ProjectObservations";
const ProjectDetails = ( ): React.Node => {
const { params } = useRoute( );
const { id } = params;
const project = useProjectDetails( id );
const {
data: project
} = useAuthenticatedQuery(
["fetchProjects", id],
optsWithAuth => fetchProjects( id, { }, optsWithAuth )
);
if ( !project ) { return null; }
return (
<ViewWithFooter>
<ScrollWithFooter>
<ImageBackground
source={{ uri: project.header_image_url }}
// $FlowFixMe
@@ -32,7 +43,7 @@ const ProjectDetails = ( ): React.Node => {
<Text style={textStyles.descriptionText}>{project.description}</Text>
{/* TODO: support joining or leaving projects once oauth is set up */}
<ProjectObservations id={id} />
</ViewWithFooter>
</ScrollWithFooter>
);
};

View File

@@ -1,18 +1,24 @@
// @flow
import { useNavigation } from "@react-navigation/native";
import { searchObservations } from "api/observations";
import GridItem from "components/SharedComponents/ObservationViews/GridItem";
import * as React from "react";
import { FlatList } from "react-native";
import useProjectObservations from "./hooks/useProjectObservations";
import useAuthenticatedQuery from "sharedHooks/useAuthenticatedQuery";
type Props = {
id: number
}
const ProjectObservations = ( { id }: Props ): React.Node => {
const observations = useProjectObservations( id );
const {
data: observations
} = useAuthenticatedQuery(
["searchObservations", id],
optsWithAuth => searchObservations( { project_id: id }, optsWithAuth )
);
const navigation = useNavigation( );
const navToObsDetails = observation => {
navigation.navigate( "ObsDetails", { uuid: observation.uuid } );

View File

@@ -1,9 +1,10 @@
// @flow
import fetchSearchResults from "api/search";
import PlaceholderText from "components/PlaceholderText";
import * as React from "react";
import { Pressable } from "react-native";
import useRemoteSearchResults from "sharedHooks/useRemoteSearchResults";
import useAuthenticatedQuery from "sharedHooks/useAuthenticatedQuery";
import ProjectList from "./ProjectList";
@@ -13,7 +14,15 @@ type Props = {
}
const ProjectSearch = ( { q, clearSearch }: Props ): React.Node => {
const projectSearchResults = useRemoteSearchResults( q, "projects", "all" );
const {
data: projectSearchResults
} = useAuthenticatedQuery(
["fetchSearchResults", q],
optsWithAuth => fetchSearchResults( {
q,
sources: "projects"
}, optsWithAuth )
);
if ( q === "" ) {
return null;

View File

@@ -1,22 +1,31 @@
// @flow
import { searchProjects } from "api/projects";
import { t } from "i18next";
import * as React from "react";
import { Pressable, Text, View } from "react-native";
import useAuthenticatedQuery from "sharedHooks/useAuthenticatedQuery";
import useUserLocation from "sharedHooks/useUserLocation";
import { viewStyles } from "styles/projects/projects";
import useMemberId from "./hooks/useMemberId";
import useProjects from "./hooks/useProjects";
import ProjectList from "./ProjectList";
const ProjectTabs = ( ): React.Node => {
const memberId = useMemberId( );
type Props = {
memberId: number
}
const ProjectTabs = ( { memberId }: Props ): React.Node => {
const userJoined = { member_id: memberId };
const [apiParams, setApiParams] = React.useState( userJoined );
const latLng = useUserLocation( );
const projects = useProjects( apiParams );
const {
data: projects
} = useAuthenticatedQuery(
["searchProjects", apiParams],
optsWithAuth => searchProjects( apiParams, optsWithAuth )
);
const fetchProjectsByLatLng = ( ) => {
setApiParams( {

View File

@@ -1,8 +1,10 @@
// @flow
import { fetchUserMe } from "api/users";
import InputField from "components/SharedComponents/InputField";
import ViewWithFooter from "components/SharedComponents/ViewWithFooter";
import * as React from "react";
import useAuthenticatedQuery from "sharedHooks/useAuthenticatedQuery";
import ProjectSearch from "./ProjectSearch";
import ProjectTabs from "./ProjectTabs";
@@ -12,6 +14,15 @@ const Projects = ( ): React.Node => {
const clearSearch = ( ) => setQ( "" );
const {
data: user
} = useAuthenticatedQuery(
["fetchUserMe"],
optsWithAuth => fetchUserMe( { }, optsWithAuth )
);
const memberId = user?.id;
return (
<ViewWithFooter testID="Projects">
<InputField
@@ -24,7 +35,7 @@ const Projects = ( ): React.Node => {
{/* TODO: make project search a separate screen or a modal?
not sure what the final designs will look like but unlikely
tabs and search will both be on the same screen */}
<ProjectTabs />
{memberId && <ProjectTabs memberId={memberId} />}
<ProjectSearch q={q} clearSearch={clearSearch} />
</ViewWithFooter>
);

View File

@@ -1,36 +0,0 @@
// @flow
import { getJWTToken } from "components/LoginSignUp/AuthenticationService";
import inatjs from "inaturalistjs";
import { useEffect, useState } from "react";
const useMemberId = ( ): ?number => {
const [memberId, setMemberId] = useState( null );
useEffect( ( ) => {
let isCurrent = true;
const fetchMemberId = async ( ) => {
const apiToken = await getJWTToken( );
try {
const options = {
api_token: apiToken
};
const { results } = await inatjs.users.me( options );
if ( !isCurrent ) { return; }
setMemberId( results[0].id );
} catch ( e ) {
if ( !isCurrent ) { return; }
console.log( "Couldn't fetch current member id:", JSON.stringify( e.response ) );
}
};
fetchMemberId( );
return ( ) => {
isCurrent = false;
};
}, [] );
return memberId;
};
export default useMemberId;

View File

@@ -1,42 +0,0 @@
// @flow
import inatjs from "inaturalistjs";
import { useEffect, useState } from "react";
const FIELDS = {
title: true,
icon: true,
header_image_url: true,
description: true
};
const useProjectDetails = ( id: number ): Object => {
const [projectDetails, setProjectDetails] = useState( [] );
useEffect( ( ) => {
let isCurrent = true;
const fetchProjectDetails = async ( ) => {
try {
const params = {
fields: FIELDS
};
const response = await inatjs.projects.fetch( [id], params );
const { results } = response;
if ( !isCurrent ) { return; }
setProjectDetails( results[0] );
} catch ( e ) {
if ( !isCurrent ) { return; }
console.log( `Couldn't fetch project details for project_id ${id}:`, e.message );
}
};
fetchProjectDetails( );
return ( ) => {
isCurrent = false;
};
}, [id] );
return projectDetails;
};
export default useProjectDetails;

View File

@@ -1,60 +0,0 @@
// @flow
import inatjs from "inaturalistjs";
import { useEffect, useState } from "react";
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 useProjectObservations = ( id: number ): Object => {
const [projectObservations, setProjectObservations] = useState( [] );
useEffect( ( ) => {
let isCurrent = true;
const fetchProjectObservations = async ( ) => {
try {
const params = {
per_page: 25,
fields: FIELDS
};
const response = await inatjs.observations.search( params );
const { results } = response;
if ( !isCurrent ) { return; }
setProjectObservations( results );
} catch ( e ) {
if ( !isCurrent ) { return; }
console.log( `Couldn't fetch project observations for project_id ${id}:`, e.message );
}
};
fetchProjectObservations( );
return ( ) => {
isCurrent = false;
};
}, [id] );
return projectObservations;
};
export default useProjectObservations;

View File

@@ -1,44 +0,0 @@
// @flow
import inatjs from "inaturalistjs";
import { useEffect, useState } from "react";
const FIELDS = {
title: true,
icon: true
};
const useProjects = ( apiParams: Object ): Array<Object> => {
const [projects, setProjects] = useState( [] );
// TODO: check with team on whether this is the best endpoint
// for joined projects. otherwise, user/:id/membership?
useEffect( ( ) => {
let isCurrent = true;
const fetchProjects = async ( ) => {
try {
const params = {
per_page: 10,
...apiParams,
fields: FIELDS
};
const response = await inatjs.projects.search( params );
const { results } = response;
if ( !isCurrent ) { return; }
setProjects( results );
} catch ( e ) {
if ( !isCurrent ) { return; }
console.log( "Couldn't fetch projects:", e.message );
}
};
fetchProjects( );
return ( ) => {
isCurrent = false;
};
}, [apiParams] );
return projects;
};
export default useProjects;

View File

@@ -1,22 +1,32 @@
// @flow
import { useNavigation } from "@react-navigation/native";
import fetchSearchResults from "api/search";
import PlaceholderText from "components/PlaceholderText";
import InputField from "components/SharedComponents/InputField";
import ViewWithFooter from "components/SharedComponents/ViewWithFooter";
import * as React from "react";
import {
ActivityIndicator,
FlatList, Image, Pressable, Text, View
} from "react-native";
import useRemoteSearchResults from "sharedHooks/useRemoteSearchResults";
import useAuthenticatedQuery from "sharedHooks/useAuthenticatedQuery";
import { imageStyles, viewStyles } from "styles/search/search";
const Search = ( ): React.Node => {
const navigation = useNavigation( );
const [q, setQ] = React.useState( "" );
const [queryType, setQueryType] = React.useState( "taxa" );
// choose users or taxa
const list = useRemoteSearchResults( q, queryType, "all" );
const {
data, isLoading
} = useAuthenticatedQuery(
["fetchSearchResults", q],
optsWithAuth => fetchSearchResults( {
q,
sources: queryType
}, optsWithAuth )
);
const renderItem = ( { item } ) => {
// TODO: make sure TaxonDetails navigates back to Search
@@ -84,11 +94,15 @@ const Search = ( ): React.Node => {
text={q}
type="none"
/>
<FlatList
data={list}
renderItem={renderItem}
testID="Search.listView"
/>
{isLoading
? <ActivityIndicator />
: (
<FlatList
data={data}
renderItem={renderItem}
testID="Search.listView"
/>
)}
</ViewWithFooter>
);
};

View File

@@ -1,26 +1,47 @@
import { useQueryClient } from "@tanstack/react-query";
import fetchPlace from "api/places";
import fetchSearchResults from "api/search";
import inatPlaceTypes from "dictionaries/places";
import React, { useEffect } from "react";
import {
Image, Text, TextInput, View
} from "react-native";
import Pressable from "react-native/Libraries/Components/Pressable/Pressable";
import useAuthenticatedQuery from "sharedHooks/useAuthenticatedQuery";
import { textStyles, viewStyles } from "styles/settings/settings";
import { useDebounce } from "use-debounce";
import usePlaceDetails from "./hooks/usePlaceDetails";
import usePlaces from "./hooks/usePlaces";
const PlaceSearchInput = ( { placeId, onPlaceChanged } ): React.Node => {
const [hideResults, setHideResults] = React.useState( true );
const [placeSearch, setPlaceSearch] = React.useState( "" );
// So we'll start searching only once the user finished typing
const [finalPlaceSearch] = useDebounce( placeSearch, 500 );
const placeResults = usePlaces( finalPlaceSearch );
const placeDetails = usePlaceDetails( placeId );
const queryClient = useQueryClient( );
// this seems necessary for clearing the cache between searches
queryClient.invalidateQueries( ["fetchSearchResults"] );
const {
data: placeResults
} = useAuthenticatedQuery(
["fetchSearchResults", finalPlaceSearch],
optsWithAuth => fetchSearchResults( {
q: finalPlaceSearch,
sources: "places",
fields: "place,place.display_name,place.place_type"
}, optsWithAuth )
);
const {
data: placeDetails
} = useAuthenticatedQuery(
["fetchPlace", placeId],
optsWithAuth => fetchPlace( placeId, optsWithAuth )
);
useEffect( () => {
if ( placeDetails ) {
console.log( "Place details", placeDetails );
setPlaceSearch( placeDetails.display_name );
} else {
setPlaceSearch( "" );
@@ -53,7 +74,7 @@ const PlaceSearchInput = ( { placeId, onPlaceChanged } ): React.Node => {
/>
</Pressable>
</View>
{!hideResults && finalPlaceSearch.length > 0 && placeResults.map( place => (
{!hideResults && finalPlaceSearch.length > 0 && placeResults?.map( place => (
<Pressable
key={place.id}
style={[viewStyles.row, viewStyles.placeResultContainer]}

View File

@@ -1,4 +1,5 @@
import { useFocusEffect } from "@react-navigation/native";
import { fetchUserMe } from "api/users";
import { getAPIToken } from "components/LoginSignUp/AuthenticationService";
import ViewWithFooter from "components/SharedComponents/ViewWithFooter";
import { t } from "i18next";
@@ -16,9 +17,9 @@ import {
Text,
View
} from "react-native";
import useAuthenticatedQuery from "sharedHooks/useAuthenticatedQuery";
import { textStyles, viewStyles } from "styles/settings/settings";
import useUserMe from "./hooks/useUserMe";
import SettingsAccount from "./SettingsAccount";
import SettingsApplications from "./SettingsApplications";
import SettingsContentDisplay from "./SettingsContentDisplay";
@@ -150,15 +151,19 @@ const Settings = ( { children: _children }: Props ): Node => {
const [activeTab, setActiveTab] = useState( TAB_TYPE_PROFILE );
const [settings, setSettings] = useState( {} );
const [accessToken, setAccessToken] = useState( null );
const [isLoading, setIsLoading] = useState( true );
const [isSaving, setIsSaving] = useState( false );
const user = useUserMe( accessToken );
const {
data: user,
isLoading
} = useAuthenticatedQuery(
["fetchUserMe"],
optsWithAuth => fetchUserMe( { }, optsWithAuth )
);
const fetchProfile = useCallback( async () => {
if ( user ) {
console.log( "User object", user );
setSettings( user );
setIsLoading( false );
}
}, [user] );
@@ -213,7 +218,6 @@ const Settings = ( { children: _children }: Props ): Node => {
console.log( "Updated user", response );
const userResponse = await inatjs.users.me( { api_token: accessToken, fields: "all" } );
console.log( "User object", userResponse.results[0] );
setSettings( userResponse.results[0] );
setIsSaving( false );
};

View File

@@ -1,29 +1,35 @@
// @flow
import fetchAuthorizedApplications from "api/authorizedApplications";
import fetchProviderAuthorizations from "api/providerAuthorizations";
import inatProviders from "dictionaries/providers";
import { t } from "i18next";
import inatjs from "inaturalistjs";
import type { Node } from "react";
import React, { useEffect, useState } from "react";
import React from "react";
import { Alert, Text, View } from "react-native";
import Pressable from "react-native/Libraries/Components/Pressable/Pressable";
import useAuthenticatedQuery from "sharedHooks/useAuthenticatedQuery";
import { textStyles, viewStyles } from "styles/settings/settings";
import useAuthorizedApplications from "./hooks/useAuthorizedApplications";
import useProviderAuthorizations from "./hooks/useProviderAuthorizations";
type Props = {
accessToken: string
}
const SettingsApplications = ( { accessToken }: Props ): Node => {
const currentAuthorizedApps = useAuthorizedApplications( accessToken );
const [authorizedApps, setAuthorizedApps] = useState( [] );
const providerAuthorizations = useProviderAuthorizations( accessToken );
const {
data: authorizedApps
} = useAuthenticatedQuery(
["fetchAuthorizedApplications"],
optsWithAuth => fetchAuthorizedApplications( { }, optsWithAuth )
);
useEffect( () => {
setAuthorizedApps( currentAuthorizedApps );
}, [currentAuthorizedApps] );
const {
data: providerAuthorizations
} = useAuthenticatedQuery(
["fetchProviderAuthorizations"],
optsWithAuth => fetchProviderAuthorizations( { }, optsWithAuth )
);
const revokeApp = async appId => {
const response = await inatjs.authorized_applications.delete(
@@ -34,7 +40,6 @@ const SettingsApplications = ( { accessToken }: Props ): Node => {
// Refresh authorized applications
const apps = await inatjs.authorized_applications.search( {}, { api_token: accessToken } );
console.log( "Authorized Applications", apps.results );
setAuthorizedApps( apps.results );
};
const askToRevokeApp = app => {
@@ -53,7 +58,7 @@ const SettingsApplications = ( { accessToken }: Props ): Node => {
return (
<View style={viewStyles.column}>
<Text style={textStyles.title}>{t( "iNaturalist-Applications" )}</Text>
{authorizedApps.filter( app => app.application.official ).map( app => (
{authorizedApps?.filter( app => app.application.official ).map( app => (
<Text key={app.application.id}>
{t( "authorized-on-date", { appName: app.application.name, date: app.created_at } )}
</Text>
@@ -61,7 +66,7 @@ const SettingsApplications = ( { accessToken }: Props ): Node => {
<Text style={[textStyles.title, textStyles.marginTop]}>{t( "Connected-Accounts" )}</Text>
{Object.keys( inatProviders ).map( providerKey => {
const connectedProvider = providerAuthorizations.find(
const connectedProvider = providerAuthorizations?.find(
x => x.provider_name === providerKey
);
return (
@@ -76,7 +81,7 @@ const SettingsApplications = ( { accessToken }: Props ): Node => {
} )}
<Text style={[textStyles.title, textStyles.marginTop]}>{t( "External-Applications" )}</Text>
{authorizedApps.filter( app => !app.application.official ).map( app => (
{authorizedApps?.filter( app => !app.application.official ).map( app => (
<View key={app.application.id} style={[viewStyles.row, viewStyles.applicationRow]}>
<Text style={textStyles.applicationName}>
{t( "authorized-on-date", { appName: app.application.name, date: app.created_at } )}

View File

@@ -1,20 +1,23 @@
// @flow
import { Picker } from "@react-native-picker/picker";
import fetchRelationships from "api/relationships";
import { fetchRemoteUsers } from "api/users";
import { t } from "i18next";
import inatjs from "inaturalistjs";
import type { Node } from "react";
import React, { useCallback, useEffect } from "react";
import React, { useCallback } from "react";
import {
Alert, Image, Text, TextInput, View
Alert, Image, ScrollView,
Text, TextInput, View
} from "react-native";
import Pressable from "react-native/Libraries/Components/Pressable/Pressable";
import useAuthenticatedQuery from "sharedHooks/useAuthenticatedQuery";
import colors from "styles/colors";
import { textStyles, viewStyles } from "styles/settings/settings";
import { useDebounce } from "use-debounce";
import BlockedUser from "./BlockedUser";
import useRelationships from "./hooks/useRelationships";
import MutedUser from "./MutedUser";
import Relationship from "./Relationship";
import UserSearchInput from "./UserSearchInput";
@@ -50,10 +53,8 @@ const SettingsRelationships = ( { accessToken, settings, onRefreshUser }: Props
const [finalUserSearch] = useDebounce( userSearch, 500 );
const [following, setFollowing] = React.useState( "all" );
const [trusted, setTrusted] = React.useState( "all" );
const [sortBy, setSortBy] = React.useState( "desc" );
const [sortBy, setSortBy] = React.useState( "z_to_a" );
const [page, setPage] = React.useState( 1 );
const [blockedUsers, setBlockedUsers] = React.useState( [] );
const [mutedUsers, setMutedUsers] = React.useState( [] );
const [refreshRelationships, setRefreshRelationships] = React.useState( Math.random() );
let orderBy;
@@ -77,71 +78,37 @@ const SettingsRelationships = ( { accessToken, settings, onRefreshUser }: Props
trusted,
order_by: orderBy,
order,
per_page: 10,
page,
random: refreshRelationships
};
const [
relationshipResults,
perPage,
totalResults
] = useRelationships( accessToken, relationshipParams );
const {
data
} = useAuthenticatedQuery(
["fetchRelationships", finalUserSearch],
optsWithAuth => fetchRelationships( relationshipParams, optsWithAuth )
);
const {
data: blockedUsers
} = useAuthenticatedQuery(
["fetchRemoteUsers", settings.blocked_user_ids],
optsWithAuth => fetchRemoteUsers( settings.blocked_user_ids, { }, optsWithAuth )
);
const {
data: mutedUsers
} = useAuthenticatedQuery(
["fetchRemoteUsers", settings.muted_user_ids],
optsWithAuth => fetchRemoteUsers( settings.muted_user_ids, { }, optsWithAuth )
);
const relationshipResults = data?.results;
const perPage = data?.per_page;
const totalResults = data?.total_results;
const totalPages = totalResults > 0 && perPage > 0 ? Math.ceil( totalResults / perPage ) : 1;
useEffect( () => {
const getBlockedUsers = async () => {
try {
const responses = await Promise.all(
settings.blocked_user_ids.map(
userId => inatjs.users.fetch( userId, { fields: "icon,login,name" } )
)
);
setBlockedUsers( responses.map( r => r.results[0] ) );
} catch ( e ) {
console.error( e );
Alert.alert(
"Error",
"Couldn't retrieve blocked users!",
[{ text: "OK" }],
{
cancelable: true
}
);
}
};
if ( settings.blocked_user_ids.length > 0 ) {
getBlockedUsers();
} else {
setBlockedUsers( [] );
}
const getMutedUsers = async () => {
try {
const responses = await Promise.all(
settings.muted_user_ids.map(
userId => inatjs.users.fetch( userId, { fields: "icon,login,name" } )
)
);
setMutedUsers( responses.map( r => r.results[0] ) );
} catch ( e ) {
console.error( e );
Alert.alert(
"Error",
"Couldn't retrieve muted users!",
[{ text: "OK" }],
{
cancelable: true
}
);
}
};
if ( settings.muted_user_ids.length > 0 ) {
getMutedUsers();
} else {
setMutedUsers( [] );
}
}, [settings] );
const updateRelationship = useCallback( async ( relationship, update ) => {
let response;
try {
@@ -300,7 +267,7 @@ const SettingsRelationships = ( { accessToken, settings, onRefreshUser }: Props
return (
// $FlowFixMe
<View style={viewStyles.column}>
<ScrollView contentContainerStyle={viewStyles.column}>
<Text style={textStyles.title}>{t( "Relationships" )}</Text>
<View style={viewStyles.row}>
<TextInput
@@ -383,7 +350,7 @@ const SettingsRelationships = ( { accessToken, settings, onRefreshUser }: Props
</View>
</View>
{relationshipResults.map( relationship => (
{relationshipResults?.map( relationship => (
<Relationship
key={relationship.id}
relationship={relationship}
@@ -421,16 +388,16 @@ const SettingsRelationships = ( { accessToken, settings, onRefreshUser }: Props
<Text style={textStyles.title}>{t( "Blocked-Users" )}</Text>
<UserSearchInput userId={0} onUserChanged={u => blockUser( u )} />
{blockedUsers.map( user => (
{blockedUsers?.map( user => (
<BlockedUser key={user.id} user={user} unblockUser={unblockUser} />
) )}
<Text style={textStyles.title}>{t( "Muted-Users" )}</Text>
<UserSearchInput userId={0} onUserChanged={u => muteUser( u )} />
{mutedUsers.map( user => (
{mutedUsers?.map( user => (
<MutedUser key={user.id} user={user} unmuteUser={unmuteUser} />
) )}
</View>
</ScrollView>
);
};

View File

@@ -1,9 +1,10 @@
import fetchSearchResults from "api/search";
import React, { useEffect } from "react";
import {
Image, Text, TextInput, View
} from "react-native";
import Pressable from "react-native/Libraries/Components/Pressable/Pressable";
import useRemoteSearchResults from "sharedHooks/useRemoteSearchResults";
import useAuthenticatedQuery from "sharedHooks/useAuthenticatedQuery";
import { textStyles, viewStyles } from "styles/settings/settings";
import { useDebounce } from "use-debounce";
@@ -12,11 +13,16 @@ const UserSearchInput = ( { onUserChanged } ): React.Node => {
const [userSearch, setUserSearch] = React.useState( "" );
// So we'll start searching only once the user finished typing
const [finalUserSearch] = useDebounce( userSearch, 500 );
const userResults = useRemoteSearchResults(
finalUserSearch,
"users",
"user.login,user.name,user.icon"
).map( r => r.user );
const {
data: userResults
} = useAuthenticatedQuery(
["fetchSearchResults", finalUserSearch],
optsWithAuth => fetchSearchResults( {
q: finalUserSearch,
sources: "users",
fields: "user.login,user.name,user.icon"
}, optsWithAuth )
);
useEffect( () => {
if ( finalUserSearch.length === 0 ) {
@@ -50,7 +56,7 @@ const UserSearchInput = ( { onUserChanged } ): React.Node => {
/>
</Pressable>
</View>
{!hideResults && finalUserSearch.length > 0 && userResults.map( result => (
{!hideResults && finalUserSearch.length > 0 && userResults?.map( result => (
<Pressable
key={result.id}
style={[viewStyles.row, viewStyles.placeResultContainer]}

View File

@@ -1,51 +0,0 @@
import inatjs from "inaturalistjs";
import { useEffect, useState } from "react";
import { Alert } from "react-native";
const useAuthorizedApplications = ( accessToken: string ): Array<Object> => {
const [searchResults, setSearchResults] = useState( [] );
useEffect( () => {
let isCurrent = true;
const cleanUp = ( ) => {
isCurrent = false;
};
const fetchSearchResults = async () => {
try {
const response = await inatjs.authorized_applications.search(
{ fields: "application.official,application.name,created_at" },
{ api_token: accessToken }
);
const { results } = response;
if ( !isCurrent ) {
return;
}
setSearchResults( results );
} catch ( e ) {
if ( !isCurrent ) {
return;
}
console.error( e );
Alert.alert(
"Error",
"Couldn't retrieve authorized applications!",
[{ text: "OK" }],
{
cancelable: true
}
);
}
};
// don't bother to fetch search results if there isn't a query
if ( !accessToken ) {
return cleanUp;
}
fetchSearchResults();
return cleanUp;
}, [accessToken] );
return searchResults;
};
export default useAuthorizedApplications;

View File

@@ -1,42 +0,0 @@
import inatjs from "inaturalistjs";
import { useEffect, useState } from "react";
import { Alert } from "react-native";
const usePlaceDetails = ( placeId: string ): Array<Object> => {
const [searchResult, setSearchResult] = useState( null );
useEffect( ( ) => {
let isCurrent = true;
const cleanUp = ( ) => {
isCurrent = false;
};
const fetchSearchResults = async ( ) => {
try {
const response = await inatjs.places.fetch( placeId, { fields: "display_name" } );
const result = response.results[0];
if ( !isCurrent ) { return; }
setSearchResult( result );
} catch ( e ) {
if ( !isCurrent ) { return; }
console.error( e );
Alert.alert(
"Error",
"Couldn't retrieve place details!",
[{ text: "OK" }],
{
cancelable: true
}
);
}
};
// don't bother to fetch search results if there isn't a query
if ( !placeId ) { return cleanUp; }
fetchSearchResults( );
return cleanUp;
}, [placeId] );
return searchResult;
};
export default usePlaceDetails;

View File

@@ -1,48 +0,0 @@
import inatjs from "inaturalistjs";
import { useEffect, useState } from "react";
import { Alert } from "react-native";
const usePlaces = ( q: string ): Array<Object> => {
const [searchResults, setSearchResults] = useState( [] );
useEffect( ( ) => {
let isCurrent = true;
const cleanUp = ( ) => {
isCurrent = false;
};
const fetchSearchResults = async ( ) => {
try {
const params = {
per_page: 10,
q,
sources: "places",
fields: "place,place.display_name,place.place_type"
};
const response = await inatjs.search( params );
const { results } = response;
if ( !isCurrent ) { return; }
setSearchResults( results.map( r => r.place ) );
} catch ( e ) {
if ( !isCurrent ) { return; }
console.error( e );
Alert.alert(
"Error",
"Couldn't retrieve places!",
[{ text: "OK" }],
{
cancelable: true
}
);
}
};
// don't bother to fetch search results if there isn't a query
if ( q === "" ) { return cleanUp; }
fetchSearchResults( );
return cleanUp;
}, [q] );
return searchResults;
};
export default usePlaces;

View File

@@ -1,51 +0,0 @@
import inatjs from "inaturalistjs";
import { useEffect, useState } from "react";
import { Alert } from "react-native";
const useProviderAuthorizations = ( accessToken: string ): Array<Object> => {
const [searchResults, setSearchResults] = useState( [] );
useEffect( () => {
let isCurrent = true;
const cleanUp = () => {
isCurrent = false;
};
const fetchSearchResults = async () => {
try {
const response = await inatjs.provider_authorizations.search(
{ fields: "provider_name,created_at" },
{ api_token: accessToken }
);
const { results } = response;
if ( !isCurrent ) {
return;
}
setSearchResults( results );
} catch ( e ) {
if ( !isCurrent ) {
return;
}
console.error( e );
Alert.alert(
"Error",
"Couldn't retrieve provider authorizations!",
[{ text: "OK" }],
{
cancelable: true
}
);
}
};
// don't bother to fetch search results if there isn't a query
if ( !accessToken ) {
return cleanUp;
}
fetchSearchResults();
return cleanUp;
}, [accessToken] );
return searchResults;
};
export default useProviderAuthorizations;

View File

@@ -1,74 +0,0 @@
import inatjs from "inaturalistjs";
import { useEffect, useState } from "react";
import { Alert } from "react-native";
const useRelationships = ( accessToken, {
following,
order,
order_by, // eslint-disable-line camelcase
page,
per_page, // eslint-disable-line camelcase
q,
random,
trusted
} ): Array<Object> => {
const [searchResults, setSearchResults] = useState( [] );
const [perPage, setPerPage] = useState( 0 );
const [totalResults, setTotalResults] = useState( 0 );
useEffect( ( ) => {
let isCurrent = true;
const cleanUp = ( ) => {
isCurrent = false;
};
const fetchSearchResults = async ( ) => {
try {
const response = await inatjs.relationships.search( {
q,
following,
trusted,
order_by,
order,
per_page,
page,
fields: "all"
}, { api_token: accessToken } );
const { results } = response;
if ( !isCurrent ) { return; }
setSearchResults( results );
setPerPage( response.per_page );
setTotalResults( response.total_results );
} catch ( e ) {
if ( !isCurrent ) { return; }
console.error( e );
Alert.alert(
"Error",
"Couldn't retrieve relationships!",
[{ text: "OK" }],
{
cancelable: true
}
);
}
};
// don't bother to fetch search results if there isn't a query
if ( !accessToken ) { return cleanUp; }
fetchSearchResults( );
return cleanUp;
}, [
accessToken,
following,
order,
order_by, // eslint-disable-line camelcase
page,
per_page, // eslint-disable-line camelcase
q,
random,
trusted
] );
return [searchResults, perPage, totalResults];
};
export default useRelationships;

View File

@@ -1,47 +0,0 @@
// @flow
import inatjs from "inaturalistjs";
import { useEffect, useState } from "react";
import { Alert } from "react-native";
const useUserMe = ( accessToken: string ): Array<Object> | null => {
const [result, setResult] = useState( null );
useEffect( ( ): function => {
let isCurrent = true;
const cleanUp = ( ) => {
isCurrent = false;
};
const fetchSearchResults = async ( ) => {
try {
const response = await inatjs.users.me( {
api_token: accessToken,
fields:
"all"
} );
if ( !isCurrent ) { return; }
setResult( response.results[0] );
} catch ( e ) {
if ( !isCurrent ) { return; }
console.error( e );
Alert.alert(
"Error",
"Couldn't retrieve user details!",
[{ text: "OK" }],
{
cancelable: true
}
);
}
};
// don't bother to fetch search results if there isn't a query
if ( !accessToken ) { return cleanUp; }
fetchSearchResults( );
return cleanUp;
}, [accessToken] );
return result;
};
export default useUserMe;

View File

@@ -1,21 +0,0 @@
// @flow
import type { Node } from "react";
import React from "react";
import { useTranslation } from "react-i18next";
import { Text } from "react-native";
import { textStyles, viewStyles } from "styles/sharedComponents/customHeader";
type Props = {
headerText: string
}
const CustomHeaderWithTranslation = ( { headerText }: Props ): Node => {
const { t } = useTranslation( );
return (
<Text style={[viewStyles.element, textStyles.text]}>{t( headerText )}</Text>
);
};
export default CustomHeaderWithTranslation;

View File

@@ -108,7 +108,7 @@ const ObservationViews = ( {
);
const navToObsDetails = async observation => {
navigation.navigate( "ObsDetails", { observation } );
navigation.navigate( "ObsDetails", { uuid: observation.uuid } );
};
const renderItem = ( { item } ) => (

View File

@@ -1,13 +1,14 @@
// @flow
import { useNavigation } from "@react-navigation/native";
import { fetchRemoteUser } from "api/users";
import TranslatedText from "components/SharedComponents/TranslatedText";
import UserIcon from "components/SharedComponents/UserIcon";
import useRemoteUser from "components/UserProfile/hooks/useRemoteUser";
import type { Node } from "react";
import React from "react";
import { Pressable, Text, View } from "react-native";
import IconMaterial from "react-native-vector-icons/MaterialIcons";
import useAuthenticatedQuery from "sharedHooks/useAuthenticatedQuery";
import useCurrentUser from "sharedHooks/useCurrentUser";
import colors from "styles/colors";
import { textStyles, viewStyles } from "styles/observations/userCard";
@@ -16,7 +17,15 @@ import User from "../../../models/User";
const UserCard = ( ): Node => {
const user = useCurrentUser( );
const { user: remoteUser } = useRemoteUser( user?.id );
const userId = user?.id;
const {
data: remoteUser
} = useAuthenticatedQuery(
["fetchRemoteUser", userId],
optsWithAuth => fetchRemoteUser( userId, { }, optsWithAuth )
);
// TODO: this currently doesn't show up on initial login
// because user id can't be fetched
const navigation = useNavigation( );

View File

@@ -1,7 +1,7 @@
// @flow
import { useRoute } from "@react-navigation/native";
// import useNetworkSite from "./hooks/useNetworkSite";
import { fetchRemoteUser } from "api/users";
import Button from "components/SharedComponents/Buttons/Button";
import CustomHeader from "components/SharedComponents/CustomHeader";
import UserIcon from "components/SharedComponents/UserIcon";
@@ -10,20 +10,26 @@ import { t } from "i18next";
import * as React from "react";
import { Text, useWindowDimensions, View } from "react-native";
import HTML from "react-native-render-html";
import useAuthenticatedQuery from "sharedHooks/useAuthenticatedQuery";
import { textStyles, viewStyles } from "styles/userProfile/userProfile";
import User from "../../models/User";
// import useNetworkSite from "./hooks/useNetworkSite";
import updateRelationship from "./helpers/updateRelationship";
import useRemoteUser from "./hooks/useRemoteUser";
import UserProjects from "./UserProjects";
const UserProfile = ( ): React.Node => {
const { params } = useRoute( );
const { userId } = params;
const { user, currentUser } = useRemoteUser( userId );
const { width } = useWindowDimensions( );
// const site = useNetworkSite( );
const {
data: remoteUser
} = useAuthenticatedQuery(
["fetchRemoteUser", userId],
optsWithAuth => fetchRemoteUser( userId, { }, optsWithAuth )
);
const user = remoteUser ? remoteUser[0] : null;
const showCount = ( count, label ) => (
<View style={viewStyles.countBox}>
@@ -34,7 +40,7 @@ const UserProfile = ( ): React.Node => {
if ( !user ) { return null; }
const showUserRole = user.roles.length > 0 && <Text>{`iNaturalist ${user.roles[0]}`}</Text>;
const showUserRole = user?.roles?.length > 0 && <Text>{`iNaturalist ${user.roles[0]}`}</Text>;
const followUser = ( ) => updateRelationship( { id: userId, relationship: { following: true } } );
@@ -51,26 +57,25 @@ const UserProfile = ( ): React.Node => {
<Text>{`${t( "Affiliation-colon" )} ${user.site_id}`}</Text>
</View>
</View>
{!currentUser && (
<View style={viewStyles.buttonRow}>
<View style={viewStyles.button}>
<Button
level="primary"
text="Follow"
onPress={followUser}
testID="UserProfile.followButton"
/>
</View>
<View style={viewStyles.button}>
<Button
level="primary"
text="Messages"
onPress={( ) => console.log( "open messages" )}
testID="UserProfile.messagesButton"
/>
</View>
{/* TODO: hide follow and messages for current user */}
<View style={viewStyles.buttonRow}>
<View style={viewStyles.button}>
<Button
level="primary"
text="Follow"
onPress={followUser}
testID="UserProfile.followButton"
/>
</View>
)}
<View style={viewStyles.button}>
<Button
level="primary"
text="Messages"
onPress={( ) => console.log( "open messages" )}
testID="UserProfile.messagesButton"
/>
</View>
</View>
<View style={viewStyles.countRow}>
{showCount( user.observations_count, t( "Observations" ) )}
{showCount( user.species_count, t( "Species" ) )}

View File

@@ -1,16 +1,21 @@
// @flow
import { fetchMemberProjects } from "api/users";
import ProjectList from "components/Projects/ProjectList";
import * as React from "react";
import useMemberProjects from "./hooks/useMemberProjects";
import useAuthenticatedQuery from "sharedHooks/useAuthenticatedQuery";
type Props = {
userId: number
}
const UserProjects = ( { userId }: Props ): React.Node => {
const projects = useMemberProjects( userId );
const {
data: projects
} = useAuthenticatedQuery(
["fetchMemberProjects", userId],
optsWithAuth => fetchMemberProjects( { id: userId }, optsWithAuth )
);
return (
<ProjectList data={projects} />

View File

@@ -1,43 +0,0 @@
// @flow
import inatjs from "inaturalistjs";
import { useEffect, useState } from "react";
const FIELDS = {
title: true,
icon: true
};
const useMemberProjects = ( userId: number ): Array<Object> => {
const [projects, setProjects] = useState( [] );
useEffect( ( ) => {
let isCurrent = true;
const fetchMemberProjects = async ( ) => {
try {
const params = {
per_page: 10,
id: userId,
fields: FIELDS
};
const response = await inatjs.users.projects( params );
const { results } = response;
if ( !isCurrent ) { return; }
setProjects( results );
} catch ( e ) {
if ( !isCurrent ) { return; }
console.log( "Couldn't fetch member projects:", e.message );
}
};
fetchMemberProjects( );
return ( ) => {
isCurrent = false;
};
}, [userId] );
return projects;
};
export default useMemberProjects;

View File

@@ -1,43 +0,0 @@
// // @flow
// import { useEffect, useState } from "react";
// import inatjs from "inaturalistjs";
// const FIELDS = {
// title: true,
// icon: true
// };
// const useNetworkSite = ( ): Array<Object> => {
// // const [projects, setProjects] = useState( [] );
// useEffect( ( ) => {
// let isCurrent = true;
// const fetchSite = async ( ) => {
// try {
// // const params = {
// // per_page: 10,
// // id: userId,
// // fields: FIELDS
// // };
// const response = await inatjs.sites.fetch( );
// const { results } = response;
// console.log( response, "response sites" );
// if ( !isCurrent ) { return; }
// } catch ( e ) {
// if ( !isCurrent ) { return; }
// console.log( "Couldn't fetch network sites:", e.message, );
// }
// };
// fetchSite( );
// return ( ) => {
// isCurrent = false;
// };
// }, [] );
// return [];
// };
// export default useNetworkSite;

View File

@@ -1,56 +0,0 @@
// @flow
import inatjs from "inaturalistjs";
import { useEffect, useState } from "react";
const 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 useRemoteUser = ( userId: number ): Object => {
const [user, setUser] = useState( null );
useEffect( ( ) => {
let isCurrent = true;
const fetchUserProfile = async ( ) => {
if ( !userId ) {
setUser( null );
return;
}
let response;
try {
response = await inatjs.users.fetch( userId, { fields: USER_FIELDS } );
} catch ( e ) {
console.log( "Failed to fetch current user: ", JSON.stringify( e.response ) );
setUser( null );
return;
}
const { results } = response;
if ( !isCurrent ) { return; }
setUser( results[0] );
};
fetchUserProfile( );
return ( ) => {
isCurrent = false;
};
}, [userId] );
return {
user
};
};
export default useRemoteUser;

View File

@@ -1,5 +1,5 @@
import { Realm } from "@realm/react";
import inatjs from "inaturalistjs";
import Realm from "realm";
import User from "./User";

View File

@@ -1,4 +1,4 @@
import Realm from "realm";
import { Realm } from "@realm/react";
import Taxon from "./Taxon";
import User from "./User";

View File

@@ -1,6 +1,6 @@
import { Realm } from "@realm/react";
import inatjs from "inaturalistjs";
import uuid from "react-native-uuid";
import Realm from "realm";
import { createObservedOnStringForUpload, formatDateAndTime } from "sharedHelpers/dateAndTime";
import Comment from "./Comment";
@@ -10,9 +10,6 @@ import ObservationSound from "./ObservationSound";
import Taxon from "./Taxon";
import User from "./User";
// noting that methods like .toJSON( ) are only accessible when the model
// class is extended with Realm.Object per this issue:
// https://github.com/realm/realm-js/issues/3600#issuecomment-785828614
class Observation extends Realm.Object {
static FIELDS = {
captive: true,
@@ -189,8 +186,8 @@ class Observation extends Realm.Object {
const observationSounds = addTimestampsToEvidence( obs.observationSounds );
const obsToSave = {
// just ...obs causes problems when obs is a realm object
...obs.toJSON( ),
// causes problems without toJSON when obs is a realm object
...( obs.toJSON ? obs.toJSON( ) : obs ),
...timestamps,
taxon,
observationPhotos,

View File

@@ -1,6 +1,6 @@
import { Realm } from "@realm/react";
import { FileUpload } from "inaturalistjs";
import uuid from "react-native-uuid";
import Realm from "realm";
import Photo from "./Photo";

View File

@@ -1,8 +1,8 @@
import { Realm } from "@realm/react";
import { FileUpload } from "inaturalistjs";
import { Platform } from "react-native";
import RNFS from "react-native-fs";
import uuid from "react-native-uuid";
import Realm from "realm";
class ObservationSound extends Realm.Object {
static async moveFromCacheToDocumentDirectory( soundUUID ) {

View File

@@ -1,7 +1,7 @@
import { createResizedImage } from "@bam.tech/react-native-image-resizer";
import { Realm } from "@realm/react";
import { Platform } from "react-native";
import RNFS from "react-native-fs";
import Realm from "realm";
class Photo extends Realm.Object {
static PHOTO_FIELDS = {

View File

@@ -1,4 +1,4 @@
import Realm from "realm";
import { Realm } from "@realm/react";
import Photo from "./Photo";

View File

@@ -1,4 +1,4 @@
import Realm from "realm";
import { Realm } from "@realm/react";
class User extends Realm.Object {
static USER_FIELDS = {

View File

@@ -9,13 +9,10 @@ import ExploreLanding from "components/Explore/ExploreLanding";
import Messages from "components/Messages/Messages";
import ObsDetails from "components/ObsDetails/ObsDetails";
import AddID from "components/ObsEdit/AddID";
import CVSuggestions from "components/ObsEdit/CVSuggestions";
import ObsEdit from "components/ObsEdit/ObsEdit";
import ObsList from "components/Observations/ObsList";
import GroupPhotos from "components/PhotoLibrary/GroupPhotos";
import PhotoGallery from "components/PhotoLibrary/PhotoGallery";
import CustomHeaderWithTranslation from
"components/SharedComponents/CustomHeaderWithTranslation";
import Mortal from "components/SharedComponents/Mortal";
import PermissionGate from "components/SharedComponents/PermissionGate";
import SoundRecorder from "components/SoundRecorder/SoundRecorder";
@@ -120,14 +117,6 @@ const MainStackNavigation = ( ): React.Node => (
name="StandardCamera"
component={StandardCameraWithPermission}
/>
<Stack.Screen
name="Suggestions"
component={CVSuggestions}
options={{
headerTitle: <CustomHeaderWithTranslation headerText="IDENTIFICATION" />,
headerShown: true
}}
/>
<Stack.Screen
name="AddID"
component={AddID}

View File

@@ -1,7 +1,8 @@
// @flow
import inatjs from "inaturalistjs";
import { searchObservations } from "api/observations";
import type { Node } from "react";
import React, { useEffect, useMemo, useState } from "react";
import React, { useMemo, useState } from "react";
import useAuthenticatedQuery from "sharedHooks/useAuthenticatedQuery";
import Observation from "../models/Observation";
import { ExploreContext } from "./contexts";
@@ -35,7 +36,6 @@ const initialFilters = {
};
const ExploreProvider = ( { children }: Props ): Node => {
const [exploreList, setExploreList] = useState( [] );
const [exploreFilters, setExploreFilters] = useState( {
...initialOptions,
...initialFilters
@@ -43,48 +43,26 @@ const ExploreProvider = ( { children }: Props ): Node => {
const [unappliedFilters, setUnappliedFilters] = useState( {
...initialFilters
} );
const [loadingExplore, setLoadingExplore] = useState( false );
const [taxon, setTaxon] = useState( "" );
const [location, setLocation] = useState( "" );
const [totalObservations, setTotalObservations] = useState( null );
useEffect( ( ) => {
let isCurrent = true;
// create filters object excluding keys with null values
const filters = Object.fromEntries(
Object.entries( exploreFilters ).filter( ( [_, v] ) => v != null )
);
if ( !loadingExplore ) { return ( ) => { }; }
const searchParams = {
...filters,
fields: Observation.FIELDS
};
const fetchExplore = async ( ) => {
// create filters object excluding keys with null values
const filters = Object.fromEntries(
Object.entries( exploreFilters ).filter( ( [_, v] ) => v != null )
);
try {
const params = {
...filters,
fields: Observation.FIELDS
};
const response = await inatjs.observations.search( params );
const totalResults = response.total_results;
const { results } = await response;
if ( !isCurrent ) { return; }
setExploreList( results.map( obs => Observation.mimicRealmMappedPropertiesSchema( obs ) ) );
setLoadingExplore( false );
setTotalObservations( totalResults );
} catch ( e ) {
if ( !isCurrent ) { return; }
setLoadingExplore( false );
console.log( "Couldn't fetch explore observations:", e.message );
}
};
fetchExplore( );
return ( ) => {
isCurrent = false;
};
}, [exploreFilters, loadingExplore] );
const setLoading = ( ) => setLoadingExplore( true );
const {
data: exploreList,
isLoading: loadingExplore
} = useAuthenticatedQuery(
["searchObservations"],
optsWithAuth => searchObservations( searchParams, optsWithAuth )
);
const resetUnappliedFilters = ( ) => setUnappliedFilters( {
...initialFilters
@@ -97,9 +75,7 @@ const ExploreProvider = ( { children }: Props ): Node => {
} );
const applyFilters = ( ) => {
setLoadingExplore( true );
const applied = Object.assign( exploreFilters, unappliedFilters );
console.log( applied, "applied" );
setExploreFilters( applied );
};
return {
@@ -111,12 +87,10 @@ const ExploreProvider = ( { children }: Props ): Node => {
resetFilters,
resetUnappliedFilters,
setExploreFilters,
setLoading,
setLocation,
setTaxon,
setUnappliedFilters,
taxon,
totalObservations,
unappliedFilters
};
}, [
@@ -125,7 +99,6 @@ const ExploreProvider = ( { children }: Props ): Node => {
loadingExplore,
location,
taxon,
totalObservations,
unappliedFilters
] );

View File

@@ -46,30 +46,31 @@ const ObsEditProvider = ( { children }: Props ): Node => {
};
const updateObservationKey = ( key, value ) => {
const updatedObs = observations.map( ( obs, index ) => {
const updatedObservations = observations.map( ( obs, index ) => {
if ( index === currentObsIndex ) {
return {
...obs,
...( obs.toJSON ? obs.toJSON( ) : obs ),
// $FlowFixMe
[key]: value
};
}
return obs;
} );
setObservations( updatedObs );
setObservations( updatedObservations );
};
const updateObservationKeys = keysAndValues => {
const updatedObs = observations.map( ( obs, index ) => {
const updatedObservations = observations.map( ( obs, index ) => {
if ( index === currentObsIndex ) {
return {
...obs,
const updatedObservation = {
...( obs.toJSON ? obs.toJSON( ) : obs ),
...keysAndValues
};
return updatedObservation;
}
return obs;
} );
setObservations( updatedObs );
setObservations( updatedObservations );
};
const updateTaxon = taxon => {

View File

@@ -1,10 +0,0 @@
// @flow
import User from "../models/User";
export default {
subject: true,
body: true,
from_user: User.USER_FIELDS,
to_user: User.USER_FIELDS
};

View File

@@ -0,0 +1,24 @@
// @flow
import { useMutation } from "@tanstack/react-query";
import { getJWTToken } from "components/LoginSignUp/AuthenticationService";
// Should work like React Query's useMutation except it calls the queryFunction
// with an object that includes the JWT
const useAuthenticatedMutation = (
queryFunction: Function,
handleCallback: Function,
queryOptions: Object = {}
): any => useMutation( async ( ) => {
// Note, getJWTToken() takes care of fetching a new token if the existing
// one is expired. We *could* store the token in state with useState if
// fetching from RNSInfo becomes a performance issue
const apiToken = await getJWTToken( );
const options = {
...queryOptions,
api_token: apiToken
};
return queryFunction( options );
}, handleCallback );
export default useAuthenticatedMutation;

View File

@@ -1,54 +0,0 @@
// @flow
import inatjs from "inaturalistjs";
import { useEffect, useState } from "react";
const useRemoteSearchResults = ( q: string, sources: string, fields: string ): Array<Object> => {
const [searchResults, setSearchResults] = useState( [] );
useEffect( ( ) => {
let isCurrent = true;
const fetchSearchResults = async ( ) => {
try {
const params = {
per_page: 10,
q,
sources,
fields: fields || "all"
};
const { results } = await inatjs.search( params );
const records = results.map( result => {
if ( sources === "taxa" ) {
return result.taxon;
}
if ( sources === "places" ) {
return result.place;
}
if ( sources === "users" ) {
return result.user;
}
if ( sources === "projects" ) {
return result.project;
}
return null;
} );
if ( !isCurrent ) { return; }
setSearchResults( records );
} catch ( e ) {
if ( !isCurrent ) { return; }
console.log( `Couldn't fetch search results with sources ${sources}:`, e.message );
}
};
// don't bother to fetch search results if there isn't a query
if ( q === "" ) { return ( ) => {}; }
fetchSearchResults( );
return ( ) => {
isCurrent = false;
};
}, [q, sources, fields] );
return searchResults;
};
export default useRemoteSearchResults;

View File

@@ -1,44 +0,0 @@
// @flow strict-local
import { Dimensions, StyleSheet } from "react-native";
import type { TextStyleProp, ViewStyleProp } from "react-native/Libraries/StyleSheet/StyleSheet";
import colors from "styles/colors";
const { width } = Dimensions.get( "screen" );
const viewStyles: { [string]: ViewStyleProp } = StyleSheet.create( {
imageBackground: {
width: 75,
height: 75,
borderRadius: 10,
backgroundColor: colors.black,
marginHorizontal: 20
},
obsDetailsColumn: {
width: width / 3
},
row: {
flexDirection: "row",
flexWrap: "nowrap",
marginVertical: 10
},
searchBar: {
marginHorizontal: 10
}
} );
const textStyles: { [string]: TextStyleProp } = StyleSheet.create( {
text: { },
greenText: {
color: colors.inatGreen
},
explainerText: {
marginHorizontal: 20,
marginBottom: 20
}
} );
export {
textStyles,
viewStyles
};

View File

@@ -33,8 +33,7 @@ const viewStyles: { [string]: ViewStyleProp } = StyleSheet.create( {
flexDirection: "row"
},
column: {
flexDirection: "column",
justifyContent: "space-evenly"
paddingBottom: 200
},
profileImage: {
height: 130,
@@ -45,7 +44,7 @@ const viewStyles: { [string]: ViewStyleProp } = StyleSheet.create( {
width: 60
},
textInput: {
backgroundColor: "#000000",
backgroundColor: colors.white,
borderWidth: 1,
flexGrow: 1
},

View File

@@ -10,19 +10,9 @@ import AccessibilityEngine from "react-native-accessibility-engine";
import factory, { makeResponse } from "../factory";
const testUser = factory( "RemoteUser" );
const mockExpected = testUser;
// Mock inaturalistjs so we can make some fake responses
jest.mock( "inaturalistjs" );
jest.mock( "../../src/components/UserProfile/hooks/useRemoteUser", ( ) => ( {
__esModule: true,
default: ( ) => ( {
user: mockExpected
} )
} ) );
jest.mock( "@react-navigation/native", ( ) => {
const actualNav = jest.requireActual( "@react-navigation/native" );
return {

View File

@@ -1,4 +1,8 @@
import { NavigationContainer } from "@react-navigation/native";
import {
QueryClient,
QueryClientProvider
} from "@tanstack/react-query";
import { fireEvent, render, waitFor } from "@testing-library/react-native";
import AddID from "components/ObsEdit/AddID";
import inatjs from "inaturalistjs";
@@ -21,22 +25,45 @@ jest.mock( "@react-navigation/native", ( ) => {
};
} );
const testTaxaList = [
{ taxon: factory( "RemoteTaxon" ) },
{ taxon: factory( "RemoteTaxon" ) },
{ taxon: factory( "RemoteTaxon" ) }
const mockTaxaList = [
factory( "RemoteTaxon" ),
factory( "RemoteTaxon" ),
factory( "RemoteTaxon" )
];
const mockExpected = testTaxaList;
jest.mock( "sharedHooks/useAuthenticatedQuery", ( ) => ( {
__esModule: true,
default: ( ) => ( {
data: mockTaxaList
} )
} ) );
jest.mock( "react-native-vector-icons/MaterialIcons", ( ) => {
const InnerReact = require( "react" );
class MaterialIcons extends InnerReact.Component {
static getImageSourceSync( _thing, _number, _color ) {
return { uri: "foo" };
}
render( ) {
return InnerReact.createElement( "MaterialIcons", this.props, this.props.children );
}
}
return MaterialIcons;
} );
const queryClient = new QueryClient( );
const renderAddID = route => render(
<NavigationContainer>
<AddID route={route} />
</NavigationContainer>
<QueryClientProvider client={queryClient}>
<NavigationContainer>
<AddID route={route} />
</NavigationContainer>
</QueryClientProvider>
);
test( "renders taxon search results", async ( ) => {
inatjs.search.mockResolvedValue( makeResponse( mockExpected ) );
inatjs.search.mockResolvedValue( makeResponse( mockTaxaList ) );
const route = { params: { } };
const { getByTestId } = renderAddID( route );
@@ -45,7 +72,7 @@ test( "renders taxon search results", async ( ) => {
fireEvent.changeText( input, "Some taxon" );
} );
const { taxon } = testTaxaList[0];
const taxon = mockTaxaList[0];
expect( getByTestId( `Search.taxa.${taxon.id}` ) ).toBeTruthy( );
expect(

View File

@@ -1,4 +1,8 @@
import { NavigationContainer } from "@react-navigation/native";
import {
QueryClient,
QueryClientProvider
} from "@tanstack/react-query";
import { fireEvent, render } from "@testing-library/react-native";
import Explore from "components/Explore/Explore";
import { ExploreContext } from "providers/contexts";
@@ -12,6 +16,13 @@ const mockLatLng = {
longitude: -122.42
};
const mockUser = factory( "LocalUser" );
jest.mock( "sharedHooks/useCurrentUser", ( ) => ( {
__esModule: true,
default: ( ) => mockUser
} ) );
jest.mock( "../../../../src/sharedHooks/useLoggedIn", ( ) => ( {
__esModule: true,
default: ( ) => true
@@ -19,22 +30,21 @@ jest.mock( "../../../../src/sharedHooks/useLoggedIn", ( ) => ( {
// Mock the hooks we use on Map since we're not trying to test them here
jest.mock( "../../../../src/sharedHooks/useUserLocation", ( ) => ( {
default: ( ) => mockLatLng,
__esModule: true
__esModule: true,
default: ( ) => mockLatLng
} ) );
jest.mock( "../../../../src/sharedHooks/useLoggedIn", ( ) => ( {
default: ( ) => false,
__esModule: true
// Some of the search inputs seem to query the API for some defaults, so this
// tries to make sure they get nothing. It does so for all uses of
// useAuthenticatedQuery, so watch this for unexpected behavior (but a unit
// test really should not be making any network requests)
jest.mock( "sharedHooks/useAuthenticatedQuery", ( ) => ( {
__esModule: true,
default: ( ) => ( { data: null } )
} ) );
jest.mock( "../../../../src/providers/ExploreProvider" );
jest.mock( "../../../../src/sharedHooks/useLoggedIn", ( ) => ( {
__esModule: true,
default: ( ) => true
} ) );
// Mock ExploreProvider so it provides a specific array of observations
// without any current observation or ability to update or fetch
// observations
@@ -66,12 +76,16 @@ jest.mock( "@react-navigation/native", ( ) => {
};
} );
const queryClient = new QueryClient( );
const renderExplore = ( ) => render(
<NavigationContainer>
<ExploreProvider>
<Explore />
</ExploreProvider>
</NavigationContainer>
<QueryClientProvider client={queryClient}>
<NavigationContainer>
<ExploreProvider>
<Explore />
</ExploreProvider>
</NavigationContainer>
</QueryClientProvider>
);
// the next three tests are duplicates from ObsList.test.js, with Explore data

View File

@@ -1,4 +1,8 @@
import { NavigationContainer } from "@react-navigation/native";
import {
QueryClient,
QueryClientProvider
} from "@tanstack/react-query";
import { fireEvent, render } from "@testing-library/react-native";
import ObsDetails from "components/ObsDetails/ObsDetails";
import { ObsEditContext } from "providers/contexts";
@@ -9,19 +13,14 @@ import factory from "../../../factory";
const mockedNavigate = jest.fn( );
const mockObservation = factory( "LocalObservation" );
const mockUser = factory( "LocalUser" );
jest.mock( "../../../../src/providers/ObsEditProvider" );
jest.mock(
"../../../../src/components/ObsDetails/hooks/useRemoteObservation",
( ) => ( {
__esModule: true,
default: ( _observation, _refetch ) => ( {
remoteObservation: mockObservation,
currentUserFaved: false
} )
} )
);
jest.mock( "sharedHooks/useCurrentUser", ( ) => ( {
__esModule: true,
default: ( ) => mockUser
} ) );
jest.mock( "@react-navigation/native", ( ) => {
const actualNav = jest.requireActual( "@react-navigation/native" );
@@ -39,6 +38,13 @@ jest.mock( "@react-navigation/native", ( ) => {
};
} );
jest.mock( "sharedHooks/useAuthenticatedQuery", ( ) => ( {
__esModule: true,
default: ( ) => ( {
data: mockObservation
} )
} ) );
jest.mock( "../../../../src/components/LoginSignUp/AuthenticationService", ( ) => ( {
getUserId: ( ) => mockObservation.user.id
} ) );
@@ -53,12 +59,16 @@ const mockObsEditProviderWithObs = ( ) => ObsEditProvider.mockImplementation( (
</ObsEditContext.Provider>
) );
const queryClient = new QueryClient( );
const renderObsDetails = ( ) => render(
<NavigationContainer>
<ObsEditProvider>
<ObsDetails />
</ObsEditProvider>
</NavigationContainer>
<QueryClientProvider client={queryClient}>
<NavigationContainer>
<ObsEditProvider>
<ObsDetails />
</ObsEditProvider>
</NavigationContainer>
</QueryClientProvider>
);
test( "renders obs details from remote call", ( ) => {

View File

@@ -1,4 +1,8 @@
import { NavigationContainer } from "@react-navigation/native";
import {
QueryClient,
QueryClientProvider
} from "@tanstack/react-query";
import { render } from "@testing-library/react-native";
import ProjectDetails from "components/Projects/ProjectDetails";
import React from "react";
@@ -6,16 +10,12 @@ import React from "react";
import factory from "../../../factory";
const mockProject = factory( "RemoteProject" );
const mockObservation = factory( "RemoteObservation" );
jest.mock( "../../../../src/components/Projects/hooks/useProjectDetails", ( ) => ( {
jest.mock( "sharedHooks/useAuthenticatedQuery", ( ) => ( {
__esModule: true,
default: ( ) => mockProject
} ) );
jest.mock( "../../../../src/components/Projects/hooks/useProjectObservations", ( ) => ( {
__esModule: true,
default: ( ) => [mockObservation]
default: ( ) => ( {
data: mockProject
} )
} ) );
jest.mock( "@react-navigation/native", ( ) => {
@@ -30,10 +30,14 @@ jest.mock( "@react-navigation/native", ( ) => {
};
} );
const queryClient = new QueryClient( );
const renderProjectDetails = ( ) => render(
<NavigationContainer>
<ProjectDetails />
</NavigationContainer>
<QueryClientProvider client={queryClient}>
<NavigationContainer>
<ProjectDetails />
</NavigationContainer>
</QueryClientProvider>
);
test( "displays project details", ( ) => {
@@ -48,11 +52,3 @@ test( "displays project details", ( ) => {
getByTestId( "ProjectDetails.projectIcon" ).props.source
).toStrictEqual( { uri: mockProject.icon } );
} );
test( "displays project observations", ( ) => {
const { getByTestId, getByText } = renderProjectDetails( );
expect( getByText( mockObservation.taxon.preferred_common_name ) ).toBeTruthy( );
expect( getByTestId( "ObsList.photo" ).props.source )
.toStrictEqual( { uri: mockObservation.observation_photos[0].photo.url } );
} );

View File

@@ -0,0 +1,50 @@
import { NavigationContainer } from "@react-navigation/native";
import {
QueryClient,
QueryClientProvider
} from "@tanstack/react-query";
import { render } from "@testing-library/react-native";
import ProjectDetails from "components/Projects/ProjectDetails";
import React from "react";
import factory from "../../../factory";
const mockProject = factory( "RemoteProject" );
const mockObservation = factory( "RemoteObservation" );
jest.mock( "@react-navigation/native", ( ) => {
const actualNav = jest.requireActual( "@react-navigation/native" );
return {
...actualNav,
useRoute: ( ) => ( {
params: {
id: mockProject.id
}
} )
};
} );
const queryClient = new QueryClient( );
const renderProjectDetails = ( ) => render(
<QueryClientProvider client={queryClient}>
<NavigationContainer>
<ProjectDetails />
</NavigationContainer>
</QueryClientProvider>
);
jest.mock( "sharedHooks/useAuthenticatedQuery", ( ) => ( {
__esModule: true,
default: ( ) => ( {
data: [mockObservation]
} )
} ) );
test( "displays project observations", ( ) => {
const { getByTestId, getByText } = renderProjectDetails( );
expect( getByText( mockObservation.taxon.preferred_common_name ) ).toBeTruthy( );
expect( getByTestId( "ObsList.photo" ).props.source )
.toStrictEqual( { uri: mockObservation.observation_photos[0].photo.url } );
} );

View File

@@ -1,4 +1,8 @@
import { NavigationContainer } from "@react-navigation/native";
import {
QueryClient,
QueryClientProvider
} from "@tanstack/react-query";
import { fireEvent, render } from "@testing-library/react-native";
import Projects from "components/Projects/Projects";
import React from "react";
@@ -13,6 +17,13 @@ const mockLatLng = {
longitude: -122.42
};
jest.mock( "sharedHooks/useAuthenticatedQuery", ( ) => ( {
__esModule: true,
default: ( ) => ( {
data: [mockProject]
} )
} ) );
jest.mock( "../../../../src/sharedHooks/useLoggedIn", ( ) => ( {
__esModule: true,
default: ( ) => true
@@ -24,11 +35,6 @@ jest.mock( "../../../../src/sharedHooks/useUserLocation", ( ) => ( {
__esModule: true
} ) );
jest.mock( "../../../../src/components/Projects/hooks/useProjects", ( ) => ( {
__esModule: true,
default: ( ) => [mockProject]
} ) );
jest.mock( "@react-navigation/native", ( ) => {
const actualNav = jest.requireActual( "@react-navigation/native" );
return {
@@ -39,10 +45,14 @@ jest.mock( "@react-navigation/native", ( ) => {
};
} );
const queryClient = new QueryClient( );
const renderProjects = () => render(
<NavigationContainer>
<Projects />
</NavigationContainer>
<QueryClientProvider client={queryClient}>
<NavigationContainer>
<Projects />
</NavigationContainer>
</QueryClientProvider>
);
test( "displays project search results", ( ) => {

View File

@@ -1,24 +1,25 @@
import { NavigationContainer } from "@react-navigation/native";
import {
QueryClient,
QueryClientProvider
} from "@tanstack/react-query";
import { fireEvent, render } from "@testing-library/react-native";
import Search from "components/Search/Search";
import React from "react";
import factory from "../../../factory";
const testTaxaList = [
factory( "RemoteTaxon" ),
factory( "RemoteTaxon" ),
factory( "RemoteTaxon" )
];
const mockExpected = testTaxaList;
jest.mock( "../../../../src/sharedHooks/useRemoteSearchResults", ( ) => ( {
__esModule: true,
default: ( ) => mockExpected
} ) );
const mockedNavigate = jest.fn( );
const mockTaxon = factory( "RemoteTaxon" );
jest.mock( "sharedHooks/useAuthenticatedQuery", ( ) => ( {
__esModule: true,
default: ( ) => ( {
data: [mockTaxon]
} )
} ) );
jest.mock( "@react-navigation/native", ( ) => {
const actualNav = jest.requireActual( "@react-navigation/native" );
return {
@@ -29,21 +30,23 @@ jest.mock( "@react-navigation/native", ( ) => {
};
} );
const queryClient = new QueryClient( );
const renderSearch = ( ) => render(
<NavigationContainer>
<Search />
</NavigationContainer>
<QueryClientProvider client={queryClient}>
<NavigationContainer>
<Search />
</NavigationContainer>
</QueryClientProvider>
);
test( "renders taxon search results from API call", ( ) => {
const { getByTestId, getByText } = renderSearch( );
const taxon = testTaxaList[0];
const commonName = taxon.preferred_common_name;
const commonName = mockTaxon.preferred_common_name;
expect( getByTestId( "Search.taxa" ) ).toBeTruthy( );
expect( getByTestId( `Search.${taxon.id}.photo` ).props.source )
.toStrictEqual( { uri: taxon.default_photo.square_url } );
expect( getByTestId( `Search.${mockTaxon.id}.photo` ).props.source )
.toStrictEqual( { uri: mockTaxon.default_photo.square_url } );
// using RegExp to be able to search within a string
expect( getByText( new RegExp( commonName ) ) ).toBeTruthy( );
} );
@@ -55,8 +58,6 @@ test.todo( "should not have accessibility errors" );
test( "navigates to TaxonDetails on button press", ( ) => {
const { getByTestId } = renderSearch( );
const taxon = testTaxaList[0];
fireEvent.press( getByTestId( `Search.taxa.${taxon.id}` ) );
expect( mockedNavigate ).toHaveBeenCalledWith( "TaxonDetails", { id: taxon.id } );
fireEvent.press( getByTestId( `Search.taxa.${mockTaxon.id}` ) );
expect( mockedNavigate ).toHaveBeenCalledWith( "TaxonDetails", { id: mockTaxon.id } );
} );

View File

@@ -1,4 +1,8 @@
import { NavigationContainer } from "@react-navigation/native";
import {
QueryClient,
QueryClientProvider
} from "@tanstack/react-query";
import { fireEvent, render } from "@testing-library/react-native";
import Search from "components/Search/Search";
import React from "react";
@@ -10,6 +14,15 @@ import factory from "../../../factory";
const mockedNavigate = jest.fn( );
const mockUser = factory( "RemoteUser" );
jest.mock( "sharedHooks/useAuthenticatedQuery", ( ) => ( {
__esModule: true,
default: ( ) => ( {
data: [mockUser]
} )
} ) );
jest.mock( "@react-navigation/native", ( ) => {
const actualNav = jest.requireActual( "@react-navigation/native" );
return {
@@ -20,43 +33,35 @@ jest.mock( "@react-navigation/native", ( ) => {
};
} );
const queryClient = new QueryClient( );
const renderSearch = ( ) => render(
<NavigationContainer>
<Search />
</NavigationContainer>
<QueryClientProvider client={queryClient}>
<NavigationContainer>
<Search />
</NavigationContainer>
</QueryClientProvider>
);
const testUserList = [
factory( "RemoteUser" )
];
const mockExpectedUsers = testUserList;
jest.mock( "../../../../src/sharedHooks/useRemoteSearchResults", ( ) => ( {
__esModule: true,
default: ( ) => mockExpectedUsers
} ) );
const { login } = mockUser;
test( "displays user search results on button press", ( ) => {
const { getByTestId, getByText } = renderSearch( );
const user = testUserList[0];
const { login } = user;
const button = getByTestId( "Search.users" );
fireEvent.press( button );
expect( getByTestId( `Search.user.${login}` ) ).toBeTruthy( );
expect( getByTestId( `Search.${login}.photo` ).props.source ).toStrictEqual( { uri: user.icon } );
expect( getByTestId( `Search.${login}.photo` ).props.source ).toStrictEqual( {
uri: mockUser.icon
} );
expect( getByText( new RegExp( login ) ) ).toBeTruthy( );
} );
test( "navigates to user profile on button press", ( ) => {
const { getByTestId } = renderSearch( );
const user = testUserList[0];
const { login } = user;
const button = getByTestId( "Search.users" );
fireEvent.press( button );
fireEvent.press( getByTestId( `Search.user.${login}` ) );
expect( mockedNavigate ).toHaveBeenCalledWith( "UserProfile", { userId: user.id } );
expect( mockedNavigate ).toHaveBeenCalledWith( "UserProfile", { userId: mockUser.id } );
} );

View File

@@ -1,4 +1,8 @@
import { NavigationContainer } from "@react-navigation/native";
import {
QueryClient,
QueryClientProvider
} from "@tanstack/react-query";
import { render } from "@testing-library/react-native";
import UserProfile from "components/UserProfile/UserProfile";
import React from "react";
@@ -6,12 +10,12 @@ import React from "react";
import factory from "../../../factory";
const testUser = factory( "RemoteUser" );
const mockExpected = testUser;
const mockUser = testUser;
jest.mock( "../../../../src/components/UserProfile/hooks/useRemoteUser", ( ) => ( {
jest.mock( "sharedHooks/useAuthenticatedQuery", ( ) => ( {
__esModule: true,
default: ( ) => ( {
user: mockExpected
data: [mockUser]
} )
} ) );
@@ -21,16 +25,20 @@ jest.mock( "@react-navigation/native", ( ) => {
...actualNav,
useRoute: ( ) => ( {
params: {
userId: mockExpected.id
userId: mockUser.id
}
} )
};
} );
const queryClient = new QueryClient( );
const renderUserProfile = ( ) => render(
<NavigationContainer>
<UserProfile />
</NavigationContainer>
<QueryClientProvider client={queryClient}>
<NavigationContainer>
<UserProfile />
</NavigationContainer>
</QueryClientProvider>
);
test( "renders user profile from API call", ( ) => {