mirror of
https://github.com/inaturalist/iNaturalistReactNative.git
synced 2026-04-19 06:23:12 -04:00
Merge branch 'main' of github.com:inaturalist/iNaturalistReactNative
This commit is contained in:
20
Gemfile.lock
20
Gemfile.lock
@@ -16,17 +16,17 @@ GEM
|
||||
artifactory (3.0.15)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.3.0)
|
||||
aws-partitions (1.883.0)
|
||||
aws-sdk-core (3.190.3)
|
||||
aws-partitions (1.888.0)
|
||||
aws-sdk-core (3.191.1)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.651.0)
|
||||
aws-sigv4 (~> 1.8)
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
aws-sdk-kms (1.76.0)
|
||||
aws-sdk-core (~> 3, >= 3.188.0)
|
||||
aws-sdk-kms (1.77.0)
|
||||
aws-sdk-core (~> 3, >= 3.191.0)
|
||||
aws-sigv4 (~> 1.1)
|
||||
aws-sdk-s3 (1.142.0)
|
||||
aws-sdk-core (~> 3, >= 3.189.0)
|
||||
aws-sdk-s3 (1.143.0)
|
||||
aws-sdk-core (~> 3, >= 3.191.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.8)
|
||||
aws-sigv4 (1.8.0)
|
||||
@@ -210,7 +210,7 @@ GEM
|
||||
minitest (5.21.2)
|
||||
molinillo (0.8.0)
|
||||
multi_json (1.15.0)
|
||||
multipart-post (2.3.0)
|
||||
multipart-post (2.4.0)
|
||||
nanaimo (0.3.0)
|
||||
nap (1.1.0)
|
||||
naturally (2.2.1)
|
||||
@@ -258,7 +258,7 @@ GEM
|
||||
uber (0.1.0)
|
||||
unicode-display_width (2.5.0)
|
||||
word_wrap (1.0.0)
|
||||
xcodeproj (1.23.0)
|
||||
xcodeproj (1.24.0)
|
||||
CFPropertyList (>= 2.3.3, < 4.0)
|
||||
atomos (~> 0.1.3)
|
||||
claide (>= 1.0.2, < 2.0)
|
||||
@@ -275,7 +275,7 @@ PLATFORMS
|
||||
|
||||
DEPENDENCIES
|
||||
activesupport (>= 6.1.7.3, < 7.1.0)
|
||||
cocoapods (>= 1.11.3)
|
||||
cocoapods (>= 1.13, < 1.15)
|
||||
fastlane
|
||||
nokogiri
|
||||
|
||||
@@ -283,4 +283,4 @@ RUBY VERSION
|
||||
ruby 2.7.5p203
|
||||
|
||||
BUNDLED WITH
|
||||
2.4.13
|
||||
2.3.9
|
||||
|
||||
@@ -123,8 +123,8 @@ android {
|
||||
applicationId "org.inaturalist.iNaturalistMobile"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 70
|
||||
versionName "0.21.1"
|
||||
versionCode 71
|
||||
versionName "0.22.0"
|
||||
setProperty("archivesBaseName", applicationId + "-v" + versionName + "+" + versionCode)
|
||||
manifestPlaceholders = [ GMAPS_API_KEY:project.env.get("GMAPS_API_KEY") ]
|
||||
// Detox Android setup
|
||||
|
||||
Binary file not shown.
@@ -3,7 +3,7 @@
|
||||
"data": [
|
||||
{
|
||||
"path": "assets/fonts/INatIcon.ttf",
|
||||
"sha1": "495181444f9a2d8275f8bd86ff4988cd54e1fcb4"
|
||||
"sha1": "bb88abe4cdee0812e5158400b1769607f685d310"
|
||||
},
|
||||
{
|
||||
"path": "assets/fonts/Whitney Book Regular.otf",
|
||||
|
||||
Binary file not shown.
@@ -402,8 +402,6 @@ lane :internal do
|
||||
upload_to_play_store(
|
||||
aab: aab_path,
|
||||
track: "internal",
|
||||
# TODO promote build to closed testing and jump through play store hoops
|
||||
release_status: "draft",
|
||||
version_name: last_tag
|
||||
)
|
||||
|
||||
|
||||
14
fastlane/metadata/android/en-US/changelogs/71.txt
Normal file
14
fastlane/metadata/android/en-US/changelogs/71.txt
Normal file
@@ -0,0 +1,14 @@
|
||||
NEW
|
||||
* Explore grid/list/map preference is now sticky
|
||||
* Tab bar shows indicator when you have unviewed updates
|
||||
|
||||
FIXED
|
||||
* Iconic taxon chooser resets when importing a batch
|
||||
* No flicker on log in
|
||||
* Notifications navigation improvements
|
||||
* Agree from Explore agrees with the ident taxon
|
||||
* Notifications for comments display as comments
|
||||
* Fixed photo count layout problems after scrolling
|
||||
* LocationPicker shows actual coordinates
|
||||
* Zoom resets when returning to camera
|
||||
* Top-cropped photo on ObsDetail
|
||||
3
index.js
3
index.js
@@ -25,6 +25,7 @@ import { reactQueryRetry } from "sharedHelpers/logging";
|
||||
import { name as appName } from "./app.json";
|
||||
import { log } from "./react-native-logs.config";
|
||||
import { USER_AGENT } from "./src/components/LoginSignUp/AuthenticationService";
|
||||
import { navigationRef } from "./src/navigation/navigationUtils";
|
||||
|
||||
enableLatestRenderer( );
|
||||
|
||||
@@ -87,7 +88,7 @@ const AppWithProviders = ( ) => (
|
||||
<GestureHandlerRootView className="flex-1">
|
||||
<BottomSheetModalProvider>
|
||||
{/* NavigationContainer needs to be nested above ObsEditProvider */}
|
||||
<NavigationContainer>
|
||||
<NavigationContainer ref={navigationRef}>
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -392,7 +392,7 @@ PODS:
|
||||
- React-perflogger (= 0.71.16)
|
||||
- ReactNativeExceptionHandler (2.10.10):
|
||||
- React-Core
|
||||
- RealmJS (12.3.0):
|
||||
- RealmJS (12.6.0):
|
||||
- React
|
||||
- RNAudioRecorderPlayer (3.6.0):
|
||||
- React-Core
|
||||
@@ -742,7 +742,7 @@ SPEC CHECKSUMS:
|
||||
React-runtimeexecutor: b5abe02558421897cd9f73d4f4b6adb4bc297083
|
||||
ReactCommon: a1a263d94f02a0dc8442f341d5a11b3d7a9cd44d
|
||||
ReactNativeExceptionHandler: b11ff67c78802b2f62eed0e10e75cb1ef7947c60
|
||||
RealmJS: 4c52a15602e70b64cd9230b0a17a9c12741371f4
|
||||
RealmJS: a62dc7a1f94b888fe9e8712cd650167ad97dc636
|
||||
RNAudioRecorderPlayer: 4690a7cd9e4fd8e58d9671936a7bc3b686e59051
|
||||
RNCAsyncStorage: f2974eca860c16a3e56eea5771fda8d12e2d2057
|
||||
RNCClipboard: 3f0451a8100393908bea5c5c5b16f96d45f30bfc
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
197A169E2A7C2567001A03DC /* taxonomy.json in Resources */ = {isa = PBXBuildFile; fileRef = 197A169C2A7C2567001A03DC /* taxonomy.json */; };
|
||||
20A80CB2AD058BDA23462D38 /* libPods-iNaturalistReactNative-ShareExtension.a in Frameworks */ = {isa = PBXBuildFile; fileRef = BBAA404A663814006B0F659A /* libPods-iNaturalistReactNative-ShareExtension.a */; };
|
||||
374CB22F29943E63005885ED /* Whitney-BookItalic-Pro.otf in Resources */ = {isa = PBXBuildFile; fileRef = 374CB22E29943E63005885ED /* Whitney-BookItalic-Pro.otf */; };
|
||||
3922DED6305249D5BBFFBC9E /* INatIcon.ttf in Resources */ = {isa = PBXBuildFile; fileRef = CCD593FC02054019A624FF88 /* INatIcon.ttf */; };
|
||||
4FB3B444D46A4115B867B9CC /* inaturalisticons.ttf in Resources */ = {isa = PBXBuildFile; fileRef = EE004FD2EC174086A7AB2908 /* inaturalisticons.ttf */; };
|
||||
5A8D64AB921678B40E0229C8 /* BuildFile in Frameworks */ = {isa = PBXBuildFile; };
|
||||
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; };
|
||||
@@ -24,7 +25,6 @@
|
||||
8B65ED3B29F575FE0054CCEF /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B65ED3A29F575FE0054CCEF /* ShareViewController.swift */; };
|
||||
A019DB3A4661689827F5BB56 /* libPods-iNaturalistReactNative.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 486ED9661FEC89EDDBE3DA02 /* libPods-iNaturalistReactNative.a */; };
|
||||
A252B2AEA64E47C9AC1D20E8 /* Whitney-Light-Pro.otf in Resources */ = {isa = PBXBuildFile; fileRef = BA9D41ECEBFA4C38B74009B3 /* Whitney-Light-Pro.otf */; };
|
||||
AA9A3911F5194C2F920F069F /* INatIcon.ttf in Resources */ = {isa = PBXBuildFile; fileRef = A981D9D5B83D49AF888D422C /* INatIcon.ttf */; };
|
||||
BA2479FA3D7B40A7BEF7B3CD /* Whitney-Medium-Pro.otf in Resources */ = {isa = PBXBuildFile; fileRef = D09FA3A0162844FF80A5EF96 /* Whitney-Medium-Pro.otf */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
@@ -85,9 +85,9 @@
|
||||
8B65ED3A29F575FE0054CCEF /* ShareViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = ShareViewController.swift; path = "../../node_modules/react-native-share-menu/ios/ShareViewController.swift"; sourceTree = "<group>"; };
|
||||
8B65ED3C29F576D00054CCEF /* iNaturalistReactNative-ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "iNaturalistReactNative-ShareExtension.entitlements"; sourceTree = "<group>"; };
|
||||
8B8BAD0429F54EB300CE5C9F /* iNaturalistReactNative.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = iNaturalistReactNative.entitlements; path = iNaturalistReactNative/iNaturalistReactNative.entitlements; sourceTree = "<group>"; };
|
||||
A981D9D5B83D49AF888D422C /* INatIcon.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = undefined; includeInIndex = 0; lastKnownFileType = unknown; name = INatIcon.ttf; path = ../assets/fonts/INatIcon.ttf; sourceTree = "<group>"; };
|
||||
BA9D41ECEBFA4C38B74009B3 /* Whitney-Light-Pro.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "Whitney-Light-Pro.otf"; path = "../assets/fonts/Whitney-Light-Pro.otf"; sourceTree = "<group>"; };
|
||||
BBAA404A663814006B0F659A /* libPods-iNaturalistReactNative-ShareExtension.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-iNaturalistReactNative-ShareExtension.a"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
CCD593FC02054019A624FF88 /* INatIcon.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = undefined; includeInIndex = 0; lastKnownFileType = unknown; name = INatIcon.ttf; path = ../assets/fonts/INatIcon.ttf; sourceTree = "<group>"; };
|
||||
CEBBC55F32B65362EB71A4C6 /* Pods-iNaturalistReactNative-ShareExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iNaturalistReactNative-ShareExtension.debug.xcconfig"; path = "Target Support Files/Pods-iNaturalistReactNative-ShareExtension/Pods-iNaturalistReactNative-ShareExtension.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
D09FA3A0162844FF80A5EF96 /* Whitney-Medium-Pro.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "Whitney-Medium-Pro.otf"; path = "../assets/fonts/Whitney-Medium-Pro.otf"; sourceTree = "<group>"; };
|
||||
E67BC54FF5D9263C1DCFB23D /* Pods-iNaturalistReactNative-ShareExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-iNaturalistReactNative-ShareExtension.release.xcconfig"; path = "Target Support Files/Pods-iNaturalistReactNative-ShareExtension/Pods-iNaturalistReactNative-ShareExtension.release.xcconfig"; sourceTree = "<group>"; };
|
||||
@@ -234,7 +234,7 @@
|
||||
D09FA3A0162844FF80A5EF96 /* Whitney-Medium-Pro.otf */,
|
||||
374CB22E29943E63005885ED /* Whitney-BookItalic-Pro.otf */,
|
||||
EE004FD2EC174086A7AB2908 /* inaturalisticons.ttf */,
|
||||
A981D9D5B83D49AF888D422C /* INatIcon.ttf */,
|
||||
CCD593FC02054019A624FF88 /* INatIcon.ttf */,
|
||||
);
|
||||
name = Resources;
|
||||
sourceTree = "<group>";
|
||||
@@ -365,7 +365,7 @@
|
||||
A252B2AEA64E47C9AC1D20E8 /* Whitney-Light-Pro.otf in Resources */,
|
||||
BA2479FA3D7B40A7BEF7B3CD /* Whitney-Medium-Pro.otf in Resources */,
|
||||
4FB3B444D46A4115B867B9CC /* inaturalisticons.ttf in Resources */,
|
||||
AA9A3911F5194C2F920F069F /* INatIcon.ttf in Resources */,
|
||||
3922DED6305249D5BBFFBC9E /* INatIcon.ttf in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -627,7 +627,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = iNaturalistReactNative/iNaturalistReactNative.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 70;
|
||||
CURRENT_PROJECT_VERSION = 71;
|
||||
DEVELOPMENT_TEAM = N5J7L4P93Z;
|
||||
ENABLE_BITCODE = NO;
|
||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64;
|
||||
@@ -748,7 +748,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = iNaturalistReactNative/iNaturalistReactNativeRelease.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 70;
|
||||
CURRENT_PROJECT_VERSION = 71;
|
||||
DEVELOPMENT_TEAM = N5J7L4P93Z;
|
||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64;
|
||||
HEADER_SEARCH_PATHS = (
|
||||
@@ -1016,7 +1016,7 @@
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CODE_SIGN_ENTITLEMENTS = "iNaturalistReactNative-ShareExtension/iNaturalistReactNative-ShareExtension.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 70;
|
||||
CURRENT_PROJECT_VERSION = 71;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = N5J7L4P93Z;
|
||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "i386 arm64";
|
||||
@@ -1061,7 +1061,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 70;
|
||||
CURRENT_PROJECT_VERSION = 71;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = N5J7L4P93Z;
|
||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "i386 arm64";
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.21.1</string>
|
||||
<string>0.22.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
@@ -32,7 +32,7 @@
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>70</string>
|
||||
<string>71</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
|
||||
@@ -15,10 +15,10 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.21.1</string>
|
||||
<string>0.22.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>70</string>
|
||||
<string>71</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"data": [
|
||||
{
|
||||
"path": "assets/fonts/INatIcon.ttf",
|
||||
"sha1": "495181444f9a2d8275f8bd86ff4988cd54e1fcb4"
|
||||
"sha1": "bb88abe4cdee0812e5158400b1769607f685d310"
|
||||
},
|
||||
{
|
||||
"path": "assets/fonts/Whitney Book Regular.otf",
|
||||
|
||||
52
package-lock.json
generated
52
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "inaturalistreactnative",
|
||||
"version": "0.21.1",
|
||||
"version": "0.22.0",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "inaturalistreactnative",
|
||||
"version": "0.21.1",
|
||||
"version": "0.22.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@bam.tech/react-native-image-resizer": "^3.0.7",
|
||||
@@ -93,7 +93,7 @@
|
||||
"react-native-vision-camera": "github:inaturalist/react-native-vision-camera#our-main-fork-2",
|
||||
"react-native-webview": "^11.26.1",
|
||||
"react-native-worklets-core": "^0.2.0",
|
||||
"realm": "^12.3.0",
|
||||
"realm": "^12.6.0",
|
||||
"reassure": "^0.10.1",
|
||||
"sanitize-html": "^2.11.0",
|
||||
"use-debounce": "^9.0.4",
|
||||
@@ -23440,17 +23440,19 @@
|
||||
"integrity": "sha512-k2d6ACCkiNYz222Fs/iNze30rRJ1iIicW7JuX/7/cozvih6YCkFZH+J6mAFDVgv0dRBaAyr4jDqC95R2y4IADg=="
|
||||
},
|
||||
"node_modules/realm": {
|
||||
"version": "12.3.0",
|
||||
"resolved": "https://registry.npmjs.org/realm/-/realm-12.3.0.tgz",
|
||||
"integrity": "sha512-qlWu8RpgGQhCllwutGZUJ+B37AF+7RNNXlyo5SftZoQFiTKVlmhN5w6B2fBmTd7R9J7Tw+lJcYUbvCZuZ4es0w==",
|
||||
"version": "12.6.0",
|
||||
"resolved": "https://registry.npmjs.org/realm/-/realm-12.6.0.tgz",
|
||||
"integrity": "sha512-lwixjVE8uiHXqRggJ9DwCxy3P1I0SUGBFG3dLQnXT20o6PdDVpXsTgE82m0svviKyDLs8yb5hLim5HRcHkH5rA==",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"bson": "^4.7.2",
|
||||
"debug": "^4.3.4",
|
||||
"node-fetch": "^2.6.9",
|
||||
"node-machine-id": "^1.1.12",
|
||||
"prebuild-install": "^7.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react-native": ">=0.71.0"
|
||||
},
|
||||
@@ -23460,25 +23462,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/realm/node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"encoding": "^0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"encoding": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/reassure": {
|
||||
"version": "0.10.1",
|
||||
"resolved": "https://registry.npmjs.org/reassure/-/reassure-0.10.1.tgz",
|
||||
@@ -43041,25 +43024,14 @@
|
||||
"integrity": "sha512-k2d6ACCkiNYz222Fs/iNze30rRJ1iIicW7JuX/7/cozvih6YCkFZH+J6mAFDVgv0dRBaAyr4jDqC95R2y4IADg=="
|
||||
},
|
||||
"realm": {
|
||||
"version": "12.3.0",
|
||||
"resolved": "https://registry.npmjs.org/realm/-/realm-12.3.0.tgz",
|
||||
"integrity": "sha512-qlWu8RpgGQhCllwutGZUJ+B37AF+7RNNXlyo5SftZoQFiTKVlmhN5w6B2fBmTd7R9J7Tw+lJcYUbvCZuZ4es0w==",
|
||||
"version": "12.6.0",
|
||||
"resolved": "https://registry.npmjs.org/realm/-/realm-12.6.0.tgz",
|
||||
"integrity": "sha512-lwixjVE8uiHXqRggJ9DwCxy3P1I0SUGBFG3dLQnXT20o6PdDVpXsTgE82m0svviKyDLs8yb5hLim5HRcHkH5rA==",
|
||||
"requires": {
|
||||
"bson": "^4.7.2",
|
||||
"debug": "^4.3.4",
|
||||
"node-fetch": "^2.6.9",
|
||||
"node-machine-id": "^1.1.12",
|
||||
"prebuild-install": "^7.1.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"requires": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"reassure": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "inaturalistreactnative",
|
||||
"version": "0.21.1",
|
||||
"version": "0.22.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"android": "react-native run-android",
|
||||
@@ -118,7 +118,7 @@
|
||||
"react-native-vision-camera": "github:inaturalist/react-native-vision-camera#our-main-fork-2",
|
||||
"react-native-webview": "^11.26.1",
|
||||
"react-native-worklets-core": "^0.2.0",
|
||||
"realm": "^12.3.0",
|
||||
"realm": "^12.6.0",
|
||||
"reassure": "^0.10.1",
|
||||
"sanitize-html": "^2.11.0",
|
||||
"use-debounce": "^9.0.4",
|
||||
|
||||
@@ -37,7 +37,8 @@ async function handleError( e: Object, options: Object = {} ): Object {
|
||||
// TODO: this will log all errors handled here to the log file, in a production build
|
||||
// we probably don't want to do that, so change this back to console.error at one point
|
||||
logger.error(
|
||||
`Error requesting ${e.response.url} (status: ${e.response.status}): ${errorJson}`
|
||||
`Error requesting ${e.response.url} (status: ${e.response.status}):
|
||||
${JSON.stringify( errorJson )}`
|
||||
);
|
||||
if ( typeof ( options.onApiError ) === "function" ) {
|
||||
options.onApiError( error );
|
||||
|
||||
@@ -130,6 +130,21 @@ const fetchObservationUpdates = async (
|
||||
}
|
||||
};
|
||||
|
||||
const fetchUnviewedObservationUpdatesCount = async (
|
||||
opts: Object
|
||||
): Promise<Number> => {
|
||||
try {
|
||||
const { total_results: updatesCount } = await inatjs.observations.updates( {
|
||||
observations_by: "owner",
|
||||
viewed: false,
|
||||
per_page: 0
|
||||
}, opts );
|
||||
return updatesCount;
|
||||
} catch ( e ) {
|
||||
return handleError( e );
|
||||
}
|
||||
};
|
||||
|
||||
const deleteRemoteObservation = async (
|
||||
params: Object = {},
|
||||
opts: Object = {}
|
||||
@@ -187,6 +202,7 @@ export {
|
||||
fetchObservers,
|
||||
fetchRemoteObservation,
|
||||
fetchSpeciesCounts,
|
||||
fetchUnviewedObservationUpdatesCount,
|
||||
markAsReviewed,
|
||||
markObservationUpdatesViewed,
|
||||
searchObservations,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useNavigation } from "@react-navigation/native";
|
||||
import classnames from "classnames";
|
||||
import { INatIconButton } from "components/SharedComponents";
|
||||
import { Text, View } from "components/styledComponents";
|
||||
import { getCurrentRoute } from "navigation/navigationUtils";
|
||||
import * as React from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { useTheme } from "react-native-paper";
|
||||
@@ -31,6 +32,7 @@ const AddObsModal = ( { closeModal }: Props ): React.Node => {
|
||||
const navigation = useNavigation( );
|
||||
|
||||
const navAndCloseModal = async ( screen, params ) => {
|
||||
const currentRoute = getCurrentRoute();
|
||||
resetStore( );
|
||||
if ( screen === "ObsEdit" ) {
|
||||
const newObservation = await Observation.new( );
|
||||
@@ -39,7 +41,7 @@ const AddObsModal = ( { closeModal }: Props ): React.Node => {
|
||||
// access nested screen
|
||||
navigation.navigate( "CameraNavigator", {
|
||||
screen,
|
||||
params
|
||||
params: { ...params, previousScreen: currentRoute }
|
||||
} );
|
||||
closeModal( );
|
||||
};
|
||||
|
||||
@@ -1,29 +1,31 @@
|
||||
// @flow
|
||||
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { focusManager } from "@tanstack/react-query";
|
||||
import { signOut } from "components/LoginSignUp/AuthenticationService";
|
||||
import RootDrawerNavigator from "navigation/rootDrawerNavigator";
|
||||
import { RealmContext } from "providers/contexts";
|
||||
import type { Node } from "react";
|
||||
import React, { useCallback, useEffect } from "react";
|
||||
import {
|
||||
AppState, Linking, LogBox
|
||||
} from "react-native";
|
||||
import DeviceInfo from "react-native-device-info";
|
||||
import Orientation from "react-native-orientation-locker";
|
||||
import React, { useEffect } from "react";
|
||||
import { LogBox } from "react-native";
|
||||
import Realm from "realm";
|
||||
import { addARCameraFiles } from "sharedHelpers/cvModel";
|
||||
import { log } from "sharedHelpers/logger";
|
||||
import {
|
||||
useCurrentUser,
|
||||
useIconicTaxa,
|
||||
useObservationUpdatesWhenFocused,
|
||||
useShare,
|
||||
useTranslation,
|
||||
useUserMe
|
||||
useShare
|
||||
} from "sharedHooks";
|
||||
|
||||
import { log } from "../../react-native-logs.config";
|
||||
import useChangeLocale from "./hooks/useChangeLocale";
|
||||
import useFreshInstall from "./hooks/useFreshInstall";
|
||||
import useLinking from "./hooks/useLinking";
|
||||
import useLockOrientation from "./hooks/useLockOrientation";
|
||||
import useReactQueryRefetch from "./hooks/useReactQueryRefetch";
|
||||
|
||||
const { useRealm } = RealmContext;
|
||||
|
||||
const logger = log.extend( "App" );
|
||||
|
||||
Realm.setLogLevel( "warn" );
|
||||
|
||||
// Ignore warnings about 3rd parties that haven't implemented the new
|
||||
// NativeEventEmitter interface methods yet. As of 20230517, this is coming
|
||||
@@ -31,12 +33,6 @@ import { log } from "../../react-native-logs.config";
|
||||
// https://stackoverflow.com/questions/69538962
|
||||
LogBox.ignoreLogs( ["new NativeEventEmitter"] );
|
||||
|
||||
const logger = log.extend( "App" );
|
||||
|
||||
const isTablet = DeviceInfo.isTablet();
|
||||
|
||||
const { useRealm } = RealmContext;
|
||||
|
||||
type Props = {
|
||||
children?: any,
|
||||
};
|
||||
@@ -44,130 +40,28 @@ type Props = {
|
||||
// this children prop is here for the sake of testing with jest
|
||||
// normally we would never do this in code
|
||||
const App = ( { children }: Props ): Node => {
|
||||
const navigation = useNavigation( );
|
||||
const realm = useRealm( );
|
||||
logger.debug( "[App.js] Need to open Realm in another app?" );
|
||||
logger.debug( "[App.js] realm.path: ", realm?.path );
|
||||
const currentUser = useCurrentUser( );
|
||||
useIconicTaxa( { reload: true } );
|
||||
const { i18n } = useTranslation( );
|
||||
useReactQueryRefetch( );
|
||||
useFreshInstall( currentUser );
|
||||
useLinking( currentUser );
|
||||
useChangeLocale( currentUser );
|
||||
|
||||
useLockOrientation( );
|
||||
useShare( );
|
||||
useObservationUpdatesWhenFocused( );
|
||||
|
||||
// fetch current user from server and save to realm in useEffect
|
||||
// this is used for changing locale and also for showing UserCard
|
||||
const { remoteUser } = useUserMe( );
|
||||
|
||||
useEffect( () => {
|
||||
if ( !isTablet ) {
|
||||
Orientation.lockToPortrait();
|
||||
}
|
||||
|
||||
return ( ) => Orientation?.unlockAllOrientations( );
|
||||
}, [] );
|
||||
|
||||
useObservationUpdatesWhenFocused();
|
||||
|
||||
// When the app is coming back from the background, set the focusManager to focused
|
||||
// This will trigger react-query to refetch any queries that are stale
|
||||
const onAppStateChange = status => {
|
||||
focusManager.setFocused( status === "active" );
|
||||
};
|
||||
|
||||
useEffect( () => {
|
||||
// subscribe to app state changes
|
||||
const subscription = AppState.addEventListener( "change", onAppStateChange );
|
||||
|
||||
// unsubscribe on unmount
|
||||
return ( ) => subscription?.remove();
|
||||
}, [] );
|
||||
|
||||
useEffect( () => {
|
||||
addARCameraFiles();
|
||||
useEffect( ( ) => {
|
||||
addARCameraFiles( );
|
||||
}, [] );
|
||||
|
||||
useEffect( ( ) => {
|
||||
const checkForSignedInUser = async ( ) => {
|
||||
// check to see if this is a fresh install of the app
|
||||
// if it is, delete realm file when we sign the user out of the app
|
||||
// this handles the case where a user deletes the app, then reinstalls
|
||||
// and expects to be signed out with no previously saved data
|
||||
const alreadyLaunched = await AsyncStorage.getItem( "alreadyLaunched" );
|
||||
if ( !alreadyLaunched ) {
|
||||
await AsyncStorage.setItem( "alreadyLaunched", "true" );
|
||||
if ( !currentUser ) {
|
||||
logger.debug(
|
||||
"Signing out and deleting Realm because no signed in user found in the database"
|
||||
);
|
||||
await signOut( { clearRealm: true } );
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
checkForSignedInUser( );
|
||||
}, [currentUser] );
|
||||
|
||||
const changeLanguageToLocale = useCallback(
|
||||
locale => i18n.changeLanguage( locale ),
|
||||
[i18n]
|
||||
);
|
||||
|
||||
// When we get the updated current user, update the record in the database
|
||||
useEffect( ( ) => {
|
||||
if ( remoteUser ) {
|
||||
realm?.write( ( ) => {
|
||||
realm?.create( "User", remoteUser, "modified" );
|
||||
} );
|
||||
|
||||
// If the current user's locale has changed, change the language
|
||||
if ( remoteUser.locale !== i18n.language ) {
|
||||
changeLanguageToLocale( remoteUser.locale );
|
||||
}
|
||||
if ( realm?.path ) {
|
||||
logger.debug( "[App.js] Need to open Realm in another app?" );
|
||||
logger.debug( "[App.js] realm.path: ", realm.path );
|
||||
}
|
||||
}, [changeLanguageToLocale, i18n, realm, remoteUser] );
|
||||
|
||||
// If the current user's locale is not set, change the language
|
||||
useEffect( ( ) => {
|
||||
if ( currentUser?.locale && currentUser?.locale !== i18n.language ) {
|
||||
changeLanguageToLocale( currentUser.locale );
|
||||
}
|
||||
}, [changeLanguageToLocale, currentUser?.locale, i18n] );
|
||||
|
||||
const navigateConfirmedUser = useCallback( ( ) => {
|
||||
if ( currentUser ) { return; }
|
||||
navigation.navigate( "LoginNavigator", {
|
||||
screen: "Login",
|
||||
params: { emailConfirmed: true }
|
||||
} );
|
||||
}, [navigation, currentUser] );
|
||||
|
||||
const newAccountConfirmedUrl = "https://www.inaturalist.org/users/sign_in?confirmed=true";
|
||||
const existingAccountConfirmedUrl = "https://www.inaturalist.org/home?confirmed=true";
|
||||
// const testUrl = "https://www.inaturalist.org/observations";
|
||||
|
||||
useEffect( ( ) => {
|
||||
Linking.addEventListener( "url", async ( { url } ) => {
|
||||
if ( url === newAccountConfirmedUrl
|
||||
// || url.includes( testUrl )
|
||||
|| url === existingAccountConfirmedUrl
|
||||
) {
|
||||
navigateConfirmedUser( );
|
||||
}
|
||||
} );
|
||||
}, [navigateConfirmedUser] );
|
||||
|
||||
useEffect( ( ) => {
|
||||
const fetchInitialUrl = async ( ) => {
|
||||
const url = await Linking.getInitialURL( );
|
||||
|
||||
if ( url === newAccountConfirmedUrl
|
||||
// || url?.includes( testUrl )
|
||||
|| url === existingAccountConfirmedUrl
|
||||
) {
|
||||
navigateConfirmedUser( );
|
||||
}
|
||||
};
|
||||
fetchInitialUrl( );
|
||||
}, [navigateConfirmedUser] );
|
||||
}, [realm?.path] );
|
||||
|
||||
// this children prop is here for the sake of testing with jest
|
||||
// normally we would never do this in code
|
||||
|
||||
@@ -18,7 +18,6 @@ import CameraWithDevice from "./CameraWithDevice";
|
||||
|
||||
const CameraContainer = ( ): Node => {
|
||||
const { params } = useRoute( );
|
||||
const backToObsEdit = params?.backToObsEdit;
|
||||
const addEvidence = params?.addEvidence;
|
||||
const cameraType = params?.camera;
|
||||
const [cameraPosition, setCameraPosition] = useState( "back" );
|
||||
@@ -34,7 +33,6 @@ const CameraContainer = ( ): Node => {
|
||||
|
||||
return (
|
||||
<CameraWithDevice
|
||||
backToObsEdit={backToObsEdit}
|
||||
addEvidence={addEvidence}
|
||||
cameraType={cameraType}
|
||||
cameraPosition={cameraPosition}
|
||||
|
||||
@@ -40,7 +40,6 @@ type Props = {
|
||||
cameraPosition: string,
|
||||
device: Object,
|
||||
setCameraPosition: Function,
|
||||
backToObsEdit: ?boolean
|
||||
}
|
||||
|
||||
const CameraWithDevice = ( {
|
||||
@@ -48,8 +47,7 @@ const CameraWithDevice = ( {
|
||||
cameraType,
|
||||
cameraPosition,
|
||||
device,
|
||||
setCameraPosition,
|
||||
backToObsEdit
|
||||
setCameraPosition
|
||||
}: Props ): Node => {
|
||||
// screen orientation locked to portrait on small devices
|
||||
if ( !isTablet ) {
|
||||
@@ -139,7 +137,6 @@ const CameraWithDevice = ( {
|
||||
? (
|
||||
<StandardCamera
|
||||
addEvidence={addEvidence}
|
||||
backToObsEdit={backToObsEdit}
|
||||
camera={camera}
|
||||
device={device}
|
||||
flipCamera={flipCamera}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @flow
|
||||
|
||||
import { useFocusEffect, useNavigation } from "@react-navigation/native";
|
||||
import { useFocusEffect, useNavigation, useRoute } from "@react-navigation/native";
|
||||
import classnames from "classnames";
|
||||
import CameraView from "components/Camera/CameraView";
|
||||
import FadeInOutView from "components/Camera/FadeInOutView";
|
||||
@@ -8,6 +8,7 @@ import useRotation from "components/Camera/hooks/useRotation";
|
||||
import useTakePhoto from "components/Camera/hooks/useTakePhoto";
|
||||
import useZoom from "components/Camera/hooks/useZoom";
|
||||
import { View } from "components/styledComponents";
|
||||
import { getCurrentRoute } from "navigation/navigationUtils";
|
||||
import type { Node } from "react";
|
||||
import React, {
|
||||
useCallback,
|
||||
@@ -40,7 +41,6 @@ export const MAX_PHOTOS_ALLOWED = 20;
|
||||
|
||||
type Props = {
|
||||
addEvidence: ?boolean,
|
||||
backToObsEdit: ?boolean,
|
||||
camera: any,
|
||||
device: any,
|
||||
flipCamera: Function,
|
||||
@@ -50,7 +50,6 @@ type Props = {
|
||||
|
||||
const StandardCamera = ( {
|
||||
addEvidence,
|
||||
backToObsEdit,
|
||||
camera,
|
||||
device,
|
||||
flipCamera,
|
||||
@@ -71,11 +70,37 @@ const StandardCamera = ( {
|
||||
rotatableAnimatedStyle,
|
||||
rotation
|
||||
} = useRotation( );
|
||||
const navigation = useNavigation( );
|
||||
const { params } = useRoute();
|
||||
const onBack = () => {
|
||||
const currentRoute = getCurrentRoute();
|
||||
if ( currentRoute.params && currentRoute.params.addEvidence ) {
|
||||
navigation.navigate( "ObsEdit" );
|
||||
} else {
|
||||
const previousScreen = params && params.previousScreen
|
||||
? params.previousScreen
|
||||
: null;
|
||||
const screenParams = previousScreen && previousScreen.name === "ObsDetails"
|
||||
? {
|
||||
navToObsDetails: true,
|
||||
uuid: previousScreen.params.uuid
|
||||
}
|
||||
: {};
|
||||
|
||||
navigation.navigate( "TabNavigator", {
|
||||
screen: "ObservationsStackNavigator",
|
||||
params: {
|
||||
screen: "ObsList",
|
||||
params: screenParams
|
||||
}
|
||||
} );
|
||||
}
|
||||
};
|
||||
const {
|
||||
handleBackButtonPress,
|
||||
setShowDiscardSheet,
|
||||
showDiscardSheet
|
||||
} = useBackPress( backToObsEdit );
|
||||
} = useBackPress( onBack );
|
||||
const {
|
||||
takePhoto,
|
||||
takePhotoOptions,
|
||||
@@ -83,7 +108,6 @@ const StandardCamera = ( {
|
||||
toggleFlash
|
||||
} = useTakePhoto( camera, addEvidence, device );
|
||||
|
||||
const navigation = useNavigation( );
|
||||
const { t } = useTranslation( );
|
||||
|
||||
const cameraPreviewUris = useStore( state => state.cameraPreviewUris );
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @flow
|
||||
|
||||
import { useFocusEffect, useNavigation } from "@react-navigation/native";
|
||||
import { useFocusEffect } from "@react-navigation/native";
|
||||
import {
|
||||
useCallback,
|
||||
useState
|
||||
@@ -10,20 +10,17 @@ import {
|
||||
} from "react-native";
|
||||
import useStore from "stores/useStore";
|
||||
|
||||
const useBackPress = ( backToObsEdit: ?boolean ): Object => {
|
||||
const useBackPress = ( onBack: Function ): Object => {
|
||||
const [showDiscardSheet, setShowDiscardSheet] = useState( false );
|
||||
const navigation = useNavigation( );
|
||||
const cameraPreviewUris = useStore( state => state.cameraPreviewUris );
|
||||
|
||||
const handleBackButtonPress = useCallback( ( ) => {
|
||||
if ( cameraPreviewUris.length > 0 ) {
|
||||
setShowDiscardSheet( true );
|
||||
} else if ( backToObsEdit ) {
|
||||
navigation.navigate( "ObsEdit" );
|
||||
} else {
|
||||
navigation.goBack( );
|
||||
onBack();
|
||||
}
|
||||
}, [backToObsEdit, setShowDiscardSheet, cameraPreviewUris, navigation] );
|
||||
}, [setShowDiscardSheet, cameraPreviewUris, onBack] );
|
||||
|
||||
useFocusEffect(
|
||||
// note: cannot use navigation.addListener to trigger bottom sheet in tab navigator
|
||||
|
||||
@@ -86,6 +86,9 @@ const Explore = ( {
|
||||
...exploreAPIParams,
|
||||
per_page: 20
|
||||
};
|
||||
if ( exploreView === "observers" ) {
|
||||
queryParams.order_by = "observation_count";
|
||||
}
|
||||
delete queryParams.taxon_name;
|
||||
|
||||
const paramsTotalResults = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @flow
|
||||
import { fetchObservers } from "api/observations";
|
||||
import UserListItem from "components/SharedComponents/UserListItem";
|
||||
import { UserListItem } from "components/SharedComponents";
|
||||
import { View } from "components/styledComponents";
|
||||
import type { Node } from "react";
|
||||
import React, { useEffect } from "react";
|
||||
@@ -22,6 +22,7 @@ const ObserversView = ( {
|
||||
queryParams,
|
||||
updateCount
|
||||
}: Props ): Node => {
|
||||
console.log( queryParams, "query params" );
|
||||
const {
|
||||
data,
|
||||
isFetchingNextPage,
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
// @flow
|
||||
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { DisplayTaxonName } from "components/SharedComponents";
|
||||
import ObsImagePreview from "components/SharedComponents/ObservationsFlashList/ObsImagePreview";
|
||||
import { View } from "components/styledComponents";
|
||||
import { Pressable, View } from "components/styledComponents";
|
||||
import type { Node } from "react";
|
||||
import React from "react";
|
||||
import Photo from "realmModels/Photo";
|
||||
import { useTranslation } from "sharedHooks";
|
||||
|
||||
import SpeciesSeenCheckmark from "./SpeciesSeenCheckmark";
|
||||
|
||||
@@ -16,40 +18,52 @@ type Props = {
|
||||
style?: Object
|
||||
};
|
||||
|
||||
const ObsGridItem = ( {
|
||||
const TaxonGridItem = ( {
|
||||
taxon,
|
||||
width = "w-full",
|
||||
height,
|
||||
style
|
||||
}: Props ): Node => (
|
||||
<ObsImagePreview
|
||||
source={{
|
||||
uri: Photo.displayLocalOrRemoteMediumPhoto(
|
||||
taxon?.default_photo
|
||||
)
|
||||
}}
|
||||
width={width}
|
||||
height={height}
|
||||
style={style}
|
||||
isMultiplePhotosTop
|
||||
obsPhotosCount={taxon?.default_photo
|
||||
? 1
|
||||
: 0}
|
||||
testID={`TaxonGridItem.${taxon.id}`}
|
||||
iconicTaxonName={taxon.iconic_taxon_name}
|
||||
>
|
||||
<SpeciesSeenCheckmark
|
||||
taxonId={taxon.id}
|
||||
/>
|
||||
<View className="absolute bottom-0 flex p-2 w-full">
|
||||
<DisplayTaxonName
|
||||
keyBase={taxon?.id}
|
||||
taxon={taxon}
|
||||
layout="vertical"
|
||||
color="text-white"
|
||||
/>
|
||||
</View>
|
||||
</ObsImagePreview>
|
||||
);
|
||||
}: Props ): Node => {
|
||||
const navigation = useNavigation( );
|
||||
const { t } = useTranslation( );
|
||||
|
||||
export default ObsGridItem;
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
testID={`TaxonGridItem.Pressable.${taxon.id}`}
|
||||
onPress={( ) => navigation.navigate( "TaxonDetails", { id: taxon.id } )}
|
||||
accessibilityLabel={t( "Navigate-to-taxon-details" )}
|
||||
>
|
||||
<ObsImagePreview
|
||||
source={{
|
||||
uri: Photo.displayLocalOrRemoteMediumPhoto(
|
||||
taxon?.default_photo
|
||||
)
|
||||
}}
|
||||
width={width}
|
||||
height={height}
|
||||
style={style}
|
||||
isMultiplePhotosTop
|
||||
obsPhotosCount={taxon?.default_photo
|
||||
? 1
|
||||
: 0}
|
||||
testID={`TaxonGridItem.${taxon.id}`}
|
||||
iconicTaxonName={taxon.iconic_taxon_name}
|
||||
>
|
||||
<SpeciesSeenCheckmark
|
||||
taxonId={taxon.id}
|
||||
/>
|
||||
<View className="absolute bottom-0 flex p-2 w-full">
|
||||
<DisplayTaxonName
|
||||
keyBase={taxon?.id}
|
||||
taxon={taxon}
|
||||
layout="vertical"
|
||||
color="text-white"
|
||||
/>
|
||||
</View>
|
||||
</ObsImagePreview>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaxonGridItem;
|
||||
|
||||
@@ -14,6 +14,7 @@ import RNSInfo from "react-native-sensitive-info";
|
||||
import Realm from "realm";
|
||||
import realmConfig from "realmModels/index";
|
||||
import User from "realmModels/User";
|
||||
import safeRealmWrite from "sharedHelpers/safeRealmWrite";
|
||||
|
||||
import { log } from "../../../react-native-logs.config";
|
||||
|
||||
@@ -344,9 +345,10 @@ const authenticateUser = async (
|
||||
|
||||
// Save userId to local, encrypted storage
|
||||
const currentUser = { id: userId, login: remoteUsername, signedIn: true };
|
||||
realm?.write( ( ) => {
|
||||
logger.debug( "writing current user to realm: ", currentUser );
|
||||
safeRealmWrite( realm, ( ) => {
|
||||
realm.create( "User", currentUser, "modified" );
|
||||
} );
|
||||
}, "saving current user in AuthenticationService" );
|
||||
const currentRealmUser = User.currentUser( realm );
|
||||
logger.debug( "Signed in", currentRealmUser.login, currentRealmUser.id, currentRealmUser );
|
||||
const realmPathExists = await RNFS.exists( realm.path );
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
// @flow
|
||||
import Header from "components/MyObservations/Header";
|
||||
import { ObservationsFlashList, StickyView, ViewWrapper } from "components/SharedComponents";
|
||||
import { View } from "components/styledComponents";
|
||||
import {
|
||||
ObservationsFlashList,
|
||||
ScrollableWithStickyHeader,
|
||||
ViewWrapper
|
||||
} from "components/SharedComponents";
|
||||
import type { Node } from "react";
|
||||
import React, { useRef, useState } from "react";
|
||||
import { Animated, Platform } from "react-native";
|
||||
import React from "react";
|
||||
|
||||
import Announcements from "./Announcements";
|
||||
import LoginSheet from "./LoginSheet";
|
||||
@@ -45,80 +47,48 @@ const MyObservations = ( {
|
||||
uploadMultipleObservations,
|
||||
uploadSingleObservation,
|
||||
uploadState
|
||||
}: Props ): Node => {
|
||||
const [heightAboveToolbar, setHeightAboveToolbar] = useState( 0 );
|
||||
|
||||
const [hideHeaderCard, setHideHeaderCard] = useState( false );
|
||||
const [yValue, setYValue] = useState( 0 );
|
||||
// basing collapsible sticky header code off the example in this article
|
||||
// https://medium.com/swlh/making-a-collapsible-sticky-header-animations-with-react-native-6ad7763875c3
|
||||
const scrollY = useRef( new Animated.Value( 0 ) );
|
||||
|
||||
const handleScroll = Animated.event(
|
||||
[
|
||||
{
|
||||
nativeEvent: {
|
||||
contentOffset: { y: scrollY.current }
|
||||
}
|
||||
}
|
||||
],
|
||||
{
|
||||
listener: ( { nativeEvent } ) => {
|
||||
const { y } = nativeEvent.contentOffset;
|
||||
const hide = yValue < y;
|
||||
// there's likely a better way to do this, but for now fading out
|
||||
// the content that goes under the status bar / safe area notch on iOS
|
||||
if ( Platform.OS !== "ios" ) { return; }
|
||||
if ( hide !== hideHeaderCard ) {
|
||||
setHideHeaderCard( hide );
|
||||
setYValue( y );
|
||||
}
|
||||
},
|
||||
useNativeDriver: true
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ViewWrapper>
|
||||
<View className="overflow-hidden">
|
||||
<StickyView scrollY={scrollY} heightAboveView={heightAboveToolbar}>
|
||||
<Header
|
||||
currentUser={currentUser}
|
||||
hideToolbar={observations.length === 0}
|
||||
layout={layout}
|
||||
setHeightAboveToolbar={setHeightAboveToolbar}
|
||||
stopUploads={stopUploads}
|
||||
syncObservations={syncObservations}
|
||||
toggleLayout={toggleLayout}
|
||||
toolbarProgress={toolbarProgress}
|
||||
uploadMultipleObservations={uploadMultipleObservations}
|
||||
uploadState={uploadState}
|
||||
/>
|
||||
<ObservationsFlashList
|
||||
dataCanBeFetched={!!currentUser}
|
||||
data={observations.filter( o => o.isValid() )}
|
||||
handleScroll={handleScroll}
|
||||
hideLoadingWheel={!isFetchingNextPage || !currentUser}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
isOnline={isOnline}
|
||||
layout={layout}
|
||||
onEndReached={onEndReached}
|
||||
showObservationsEmptyScreen
|
||||
status={status}
|
||||
testID="MyObservationsAnimatedList"
|
||||
uploadSingleObservation={uploadSingleObservation}
|
||||
uploadState={uploadState}
|
||||
renderHeader={(
|
||||
<Announcements currentUser={currentUser} isOnline={isOnline} />
|
||||
)}
|
||||
/>
|
||||
</StickyView>
|
||||
</View>
|
||||
</ViewWrapper>
|
||||
{showLoginSheet && <LoginSheet setShowLoginSheet={setShowLoginSheet} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
}: Props ): Node => (
|
||||
<>
|
||||
<ViewWrapper>
|
||||
<ScrollableWithStickyHeader
|
||||
renderHeader={setStickyAt => (
|
||||
<Header
|
||||
currentUser={currentUser}
|
||||
hideToolbar={observations.length === 0}
|
||||
layout={layout}
|
||||
setHeightAboveToolbar={setStickyAt}
|
||||
stopUploads={stopUploads}
|
||||
syncObservations={syncObservations}
|
||||
toggleLayout={toggleLayout}
|
||||
toolbarProgress={toolbarProgress}
|
||||
uploadMultipleObservations={uploadMultipleObservations}
|
||||
uploadState={uploadState}
|
||||
/>
|
||||
)}
|
||||
renderScrollable={onSroll => (
|
||||
<ObservationsFlashList
|
||||
dataCanBeFetched={!!currentUser}
|
||||
data={observations.filter( o => o.isValid() )}
|
||||
handleScroll={onSroll}
|
||||
hideLoadingWheel={!isFetchingNextPage || !currentUser}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
isOnline={isOnline}
|
||||
layout={layout}
|
||||
onEndReached={onEndReached}
|
||||
showObservationsEmptyScreen
|
||||
status={status}
|
||||
testID="MyObservationsAnimatedList"
|
||||
uploadSingleObservation={uploadSingleObservation}
|
||||
uploadState={uploadState}
|
||||
renderHeader={(
|
||||
<Announcements currentUser={currentUser} isOnline={isOnline} />
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</ViewWrapper>
|
||||
{showLoginSheet && <LoginSheet setShowLoginSheet={setShowLoginSheet} />}
|
||||
</>
|
||||
);
|
||||
|
||||
export default MyObservations;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "api/observations";
|
||||
import { getJWT } from "components/LoginSignUp/AuthenticationService";
|
||||
import { format } from "date-fns";
|
||||
import { navigationRef } from "navigation/navigationUtils";
|
||||
import { RealmContext } from "providers/contexts";
|
||||
import type { Node } from "react";
|
||||
import React, {
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
INCREMENT_SINGLE_UPLOAD_PROGRESS
|
||||
} from "sharedHelpers/emitUploadProgress";
|
||||
import { log } from "sharedHelpers/logger";
|
||||
import safeRealmWrite from "sharedHelpers/safeRealmWrite";
|
||||
import uploadObservation from "sharedHelpers/uploadObservation";
|
||||
import {
|
||||
useCurrentUser,
|
||||
@@ -122,11 +124,13 @@ const { useRealm } = RealmContext;
|
||||
|
||||
const MyObservationsContainer = ( ): Node => {
|
||||
const navigation = useNavigation( );
|
||||
const { params } = useRoute( );
|
||||
const { t } = useTranslation( );
|
||||
const realm = useRealm( );
|
||||
const allObsToUpload = Observation.filterUnsyncedObservations( realm );
|
||||
const { params: navParams } = useRoute( );
|
||||
const [state, dispatch] = useReducer( uploadReducer, INITIAL_UPLOAD_STATE );
|
||||
const { observationList: observations, allObsToUpload } = useLocalObservations( );
|
||||
const { observationList: observations } = useLocalObservations( );
|
||||
const { layout, writeLayoutToStorage } = useStoredLayout( "myObservationsLayout" );
|
||||
|
||||
const isOnline = useIsConnected( );
|
||||
@@ -169,6 +173,18 @@ const MyObservationsContainer = ( ): Node => {
|
||||
: "grid" );
|
||||
};
|
||||
|
||||
useEffect( () => {
|
||||
if ( navigationRef && navigationRef.isReady() ) {
|
||||
if ( params && params.navToObsDetails ) {
|
||||
// We wrap this in a setTimeout, since otherwise this routing doesn't work immediately
|
||||
// when loading this screen
|
||||
setTimeout( () => {
|
||||
navigation.navigate( "ObsDetails", { uuid: params.uuid } );
|
||||
}, 100 );
|
||||
}
|
||||
}
|
||||
}, [navigation, params] );
|
||||
|
||||
useEffect( ( ) => {
|
||||
// show progress in toolbar for observations uploaded on ObsEdit
|
||||
if ( navParams?.uuid && !state.uploadInProgress && currentUser ) {
|
||||
@@ -295,12 +311,12 @@ const MyObservationsContainer = ( ): Node => {
|
||||
|
||||
const downloadRemoteObservationsFromServer = useCallback( async ( ) => {
|
||||
const apiToken = await getJWT( );
|
||||
const params = {
|
||||
const searchParams = {
|
||||
user_id: currentUser?.id,
|
||||
per_page: 50,
|
||||
fields: Observation.FIELDS
|
||||
};
|
||||
const { results } = await searchObservations( params, { api_token: apiToken } );
|
||||
const { results } = await searchObservations( searchParams, { api_token: apiToken } );
|
||||
|
||||
Observation.upsertRemoteObservations( results, realm );
|
||||
}, [currentUser, realm] );
|
||||
@@ -309,10 +325,10 @@ const MyObservationsContainer = ( ): Node => {
|
||||
const syncRemoteDeletedObservations = useCallback( async ( ) => {
|
||||
const apiToken = await getJWT( );
|
||||
const lastSyncTime = realm.objects( "LocalPreferences" )?.[0]?.last_sync_time;
|
||||
const params = { since: format( new Date( ), "yyyy-MM-dd" ) };
|
||||
const deletedParams = { since: format( new Date( ), "yyyy-MM-dd" ) };
|
||||
if ( lastSyncTime ) {
|
||||
try {
|
||||
params.since = format( lastSyncTime, "yyyy-MM-dd" );
|
||||
deletedParams.since = format( lastSyncTime, "yyyy-MM-dd" );
|
||||
} catch ( lastSyncTimeFormatError ) {
|
||||
if ( lastSyncTimeFormatError instanceof RangeError ) {
|
||||
// If we can't parse that date, assume we've never synced and use the default
|
||||
@@ -321,33 +337,29 @@ const MyObservationsContainer = ( ): Node => {
|
||||
}
|
||||
}
|
||||
}
|
||||
const response = await checkForDeletedObservations( params, { api_token: apiToken } );
|
||||
const response = await checkForDeletedObservations( deletedParams, { api_token: apiToken } );
|
||||
const deletedObservations = response?.results;
|
||||
if ( !deletedObservations ) { return; }
|
||||
if ( deletedObservations?.length > 0 ) {
|
||||
realm?.write( ( ) => {
|
||||
deletedObservations.forEach( observationId => {
|
||||
const localObsToDelete = realm.objects( "Observation" )
|
||||
.filtered( `id == ${observationId}` );
|
||||
realm.delete( localObsToDelete );
|
||||
safeRealmWrite( realm, ( ) => {
|
||||
const localObservationsToDelete = realm.objects( "Observation" )
|
||||
.filtered( `id IN { ${deletedObservations} }` );
|
||||
localObservationsToDelete.forEach( observation => {
|
||||
realm.delete( observation );
|
||||
} );
|
||||
} );
|
||||
}, "deleting remote deleted observations in MyObservationsContainer" );
|
||||
}
|
||||
}, [realm] );
|
||||
|
||||
const updateSyncTime = useCallback( ( ) => {
|
||||
const currentSyncTime = new Date( );
|
||||
realm?.write( ( ) => {
|
||||
const localPrefs = realm.objects( "LocalPreferences" )[0];
|
||||
if ( !localPrefs ) {
|
||||
realm.create( "LocalPreferences", {
|
||||
...localPrefs,
|
||||
last_sync_time: currentSyncTime
|
||||
} );
|
||||
} else {
|
||||
localPrefs.last_sync_time = currentSyncTime;
|
||||
}
|
||||
} );
|
||||
const localPrefs = realm.objects( "LocalPreferences" )[0];
|
||||
const updatedPrefs = {
|
||||
...localPrefs,
|
||||
last_sync_time: new Date( )
|
||||
};
|
||||
safeRealmWrite( realm, ( ) => {
|
||||
realm.create( "LocalPreferences", updatedPrefs, "modified" );
|
||||
}, "updating sync time in MyObservationsContainer" );
|
||||
}, [realm] );
|
||||
|
||||
const syncObservations = useCallback( async ( ) => {
|
||||
|
||||
@@ -7,7 +7,6 @@ import { Dimensions, PixelRatio } from "react-native";
|
||||
import { useTheme } from "react-native-paper";
|
||||
import {
|
||||
useCurrentUser,
|
||||
useObservationsUpdates,
|
||||
useTranslation
|
||||
} from "sharedHooks";
|
||||
|
||||
@@ -61,19 +60,15 @@ const ToolbarContainer = ( {
|
||||
currentUploadCount
|
||||
} = uploadState;
|
||||
|
||||
const { refetch } = useObservationsUpdates( false );
|
||||
|
||||
const handleSyncButtonPress = useCallback( ( ) => {
|
||||
if ( numUnuploadedObs > 0 ) {
|
||||
uploadMultipleObservations( );
|
||||
} else {
|
||||
syncObservations( );
|
||||
refetch( );
|
||||
}
|
||||
}, [
|
||||
numUnuploadedObs,
|
||||
syncObservations,
|
||||
refetch,
|
||||
uploadMultipleObservations
|
||||
] );
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { deleteRemoteObservation } from "api/observations";
|
||||
import { RealmContext } from "providers/contexts";
|
||||
import { useCallback, useEffect, useReducer } from "react";
|
||||
import { log } from "sharedHelpers/logger";
|
||||
import safeRealmWrite from "sharedHelpers/safeRealmWrite";
|
||||
import { useAuthenticatedMutation } from "sharedHooks";
|
||||
|
||||
const logger = log.extend( "useDeleteObservations" );
|
||||
@@ -70,12 +71,14 @@ const useDeleteObservations = ( ): Object => {
|
||||
const observationToDelete = deletions[currentDeleteCount - 1];
|
||||
|
||||
const deleteLocalObservation = useCallback( ( ) => {
|
||||
const realmObservation = realm.objectForPrimaryKey( "Observation", observationToDelete.uuid );
|
||||
logger.info( "Local observation to delete: ", realmObservation.uuid );
|
||||
realm?.write( ( ) => {
|
||||
realm?.delete( realmObservation );
|
||||
} );
|
||||
logger.info( "Local observation deleted" );
|
||||
const realmObservation = realm?.objectForPrimaryKey( "Observation", observationToDelete.uuid );
|
||||
logger.info( "Local observation to delete: ", realmObservation?.uuid );
|
||||
if ( realmObservation ) {
|
||||
safeRealmWrite( realm, ( ) => {
|
||||
realm?.delete( realmObservation );
|
||||
}, `deleting local observation ${realmObservation.uuid} in useDeleteObservations` );
|
||||
logger.info( "Local observation deleted" );
|
||||
}
|
||||
return true;
|
||||
}, [realm, observationToDelete] );
|
||||
|
||||
|
||||
@@ -12,10 +12,10 @@ const NotificationsContainer = (): Node => {
|
||||
|
||||
const {
|
||||
notifications,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
status,
|
||||
refetch
|
||||
refetch,
|
||||
isInitialLoading,
|
||||
isFetching
|
||||
} = useInfiniteNotificationsScroll( );
|
||||
|
||||
useEffect( ( ) => {
|
||||
@@ -30,9 +30,8 @@ const NotificationsContainer = (): Node => {
|
||||
<NotificationsList
|
||||
data={notifications}
|
||||
onEndReached={fetchNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
status={status}
|
||||
isOnline={isOnline}
|
||||
isLoading={isInitialLoading || isFetching}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,28 +3,24 @@
|
||||
import { FlashList } from "@shopify/flash-list";
|
||||
import InfiniteScrollLoadingWheel from "components/MyObservations/InfiniteScrollLoadingWheel";
|
||||
import NotificationsListItem from "components/Notifications/NotificationsListItem";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Body2
|
||||
} from "components/SharedComponents";
|
||||
import { Body2 } from "components/SharedComponents";
|
||||
import { View } from "components/styledComponents";
|
||||
import type { Node } from "react";
|
||||
import React, { useCallback } from "react";
|
||||
import { Animated } from "react-native";
|
||||
import { useTranslation } from "sharedHooks";
|
||||
|
||||
const AnimatedFlashList = Animated.createAnimatedComponent( FlashList );
|
||||
|
||||
type Props = {
|
||||
data: Object,
|
||||
isOnline: boolean,
|
||||
status: string,
|
||||
onEndReached: Function,
|
||||
isFetchingNextPage?: boolean
|
||||
};
|
||||
data: Object,
|
||||
isLoading?: boolean,
|
||||
isOnline: boolean,
|
||||
onEndReached: Function
|
||||
};
|
||||
|
||||
const NotificationsList = ( {
|
||||
data, isOnline, status, onEndReached, isFetchingNextPage
|
||||
data,
|
||||
isOnline,
|
||||
onEndReached,
|
||||
isLoading
|
||||
}: Props ): Node => {
|
||||
const { t } = useTranslation( );
|
||||
|
||||
@@ -36,47 +32,37 @@ const NotificationsList = ( {
|
||||
|
||||
const renderFooter = useCallback( ( ) => (
|
||||
<InfiniteScrollLoadingWheel
|
||||
hideLoadingWheel={!isFetchingNextPage}
|
||||
hideLoadingWheel={!isLoading}
|
||||
isOnline={isOnline}
|
||||
explore={false}
|
||||
/>
|
||||
), [isFetchingNextPage, isOnline] );
|
||||
), [isLoading, isOnline] );
|
||||
|
||||
const renderEmptyComponent = useCallback( ( ) => {
|
||||
const showEmptyScreen = ( isOnline )
|
||||
if ( isLoading ) return null;
|
||||
return isOnline
|
||||
? (
|
||||
<Body2 className="mt-[150px] self-center mx-12">
|
||||
<Body2 className="mt-[150px] text-center mx-12">
|
||||
{t( "No-Notifications-Found" )}
|
||||
</Body2>
|
||||
)
|
||||
: (
|
||||
<Body2 className="mt-[150px] self-center mx-12">
|
||||
<Body2 className="mt-[150px] text-center mx-12">
|
||||
{t( "Offline-No-Notifications" )}
|
||||
</Body2>
|
||||
);
|
||||
|
||||
return ( ( status === "loading" ) )
|
||||
? (
|
||||
<View className="self-center mt-[150px]">
|
||||
<ActivityIndicator
|
||||
size={50}
|
||||
testID="NotificationsFlashList.loading"
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
: showEmptyScreen;
|
||||
}, [isOnline, status, t] );
|
||||
}, [isLoading, isOnline, t] );
|
||||
|
||||
return (
|
||||
<View className="h-full">
|
||||
<AnimatedFlashList
|
||||
<FlashList
|
||||
data={data}
|
||||
keyExtractor={item => item.id}
|
||||
renderItem={renderItem}
|
||||
ItemSeparatorComponent={renderItemSeparator}
|
||||
estimatedItemSize={20}
|
||||
onEndReached={onEndReached}
|
||||
refreshing={isFetchingNextPage}
|
||||
refreshing={isLoading}
|
||||
ListFooterComponent={renderFooter}
|
||||
ListEmptyComponent={renderEmptyComponent}
|
||||
/>
|
||||
|
||||
@@ -164,11 +164,7 @@ const ObsDetails = ( {
|
||||
belongsToCurrentUser={belongsToCurrentUser}
|
||||
observation={observation}
|
||||
/>
|
||||
<View
|
||||
// TODO don't hardcode this, should be based on the calculated
|
||||
// height of the nav header
|
||||
className="mt-[-44px]"
|
||||
>
|
||||
<View>
|
||||
<ObsMediaDisplayContainer observation={observation} />
|
||||
{ currentUser && (
|
||||
<FaveButton
|
||||
|
||||
@@ -14,6 +14,7 @@ import React, {
|
||||
} from "react";
|
||||
import { Alert, LogBox } from "react-native";
|
||||
import Observation from "realmModels/Observation";
|
||||
import safeRealmWrite from "sharedHelpers/safeRealmWrite";
|
||||
import {
|
||||
useAuthenticatedMutation,
|
||||
useAuthenticatedQuery,
|
||||
@@ -103,6 +104,9 @@ const reducer = ( state, action ) => {
|
||||
|
||||
const ObsDetailsContainer = ( ): Node => {
|
||||
const setObservations = useStore( state => state.setObservations );
|
||||
const setObservationMarkedAsViewedAt = useStore(
|
||||
state => state.setObservationMarkedAsViewedAt
|
||||
);
|
||||
const currentUser = useCurrentUser( );
|
||||
const { params } = useRoute();
|
||||
const {
|
||||
@@ -225,11 +229,11 @@ const ObsDetailsContainer = ( ): Node => {
|
||||
|
||||
const markViewedLocally = async () => {
|
||||
if ( !localObservation ) { return; }
|
||||
realm?.write( () => {
|
||||
safeRealmWrite( realm, ( ) => {
|
||||
// Flags if all comments and identifications have been viewed
|
||||
localObservation.comments_viewed = true;
|
||||
localObservation.identifications_viewed = true;
|
||||
} );
|
||||
}, "marking viewed locally in ObsDetailsContainer" );
|
||||
};
|
||||
|
||||
const { refetch: refetchObservationUpdates } = useObservationsUpdates(
|
||||
@@ -245,6 +249,7 @@ const ObsDetailsContainer = ( ): Node => {
|
||||
queryClient.invalidateQueries( [fetchObservationUpdatesKey] );
|
||||
refetchRemoteObservation( );
|
||||
refetchObservationUpdates( );
|
||||
setObservationMarkedAsViewedAt( new Date( ) );
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -260,12 +265,12 @@ const ObsDetailsContainer = ( ): Node => {
|
||||
{
|
||||
onSuccess: data => {
|
||||
if ( belongsToCurrentUser ) {
|
||||
realm?.write( ( ) => {
|
||||
safeRealmWrite( realm, ( ) => {
|
||||
const localComments = localObservation?.comments;
|
||||
const newComment = data[0];
|
||||
newComment.user = currentUser;
|
||||
localComments.push( newComment );
|
||||
} );
|
||||
}, "setting local comment in ObsDetailsContainer" );
|
||||
const updatedLocalObservation = realm.objectForPrimaryKey( "Observation", uuid );
|
||||
dispatch( { type: "ADD_ACTIVITY_ITEM", observationShown: updatedLocalObservation } );
|
||||
} else {
|
||||
@@ -300,7 +305,7 @@ const ObsDetailsContainer = ( ): Node => {
|
||||
{
|
||||
onSuccess: data => {
|
||||
if ( belongsToCurrentUser ) {
|
||||
realm?.write( ( ) => {
|
||||
safeRealmWrite( realm, ( ) => {
|
||||
const localIdentifications = localObservation?.identifications;
|
||||
const newIdentification = data[0];
|
||||
newIdentification.user = currentUser;
|
||||
@@ -312,7 +317,7 @@ const ObsDetailsContainer = ( ): Node => {
|
||||
newIdentification.vision = true;
|
||||
}
|
||||
localIdentifications.push( newIdentification );
|
||||
} );
|
||||
}, "setting local identification in ObsDetailsContainer" );
|
||||
const updatedLocalObservation = realm.objectForPrimaryKey( "Observation", uuid );
|
||||
dispatch( { type: "ADD_ACTIVITY_ITEM", observationShown: updatedLocalObservation } );
|
||||
} else {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "components/styledComponents";
|
||||
import type { Node } from "react";
|
||||
import React from "react";
|
||||
import DeviceInfo from "react-native-device-info";
|
||||
import {
|
||||
useTranslation
|
||||
} from "sharedHooks";
|
||||
@@ -18,6 +19,8 @@ import colors from "styles/tailwindColors";
|
||||
|
||||
import HeaderKebabMenu from "./HeaderKebabMenu";
|
||||
|
||||
const isTablet = DeviceInfo.isTablet( );
|
||||
|
||||
type Props = {
|
||||
belongsToCurrentUser?: boolean,
|
||||
observation: Object,
|
||||
@@ -43,7 +46,12 @@ const ObsDetailsHeader = ( {
|
||||
"justify-between",
|
||||
"h-10"
|
||||
)}
|
||||
colors={["rgba(0,0,0,0.1)", "transparent"]}
|
||||
colors={[
|
||||
isTablet
|
||||
? "rgba(0,0,0,0.1)"
|
||||
: "rgba(0,0,0,0.6)",
|
||||
"transparent"
|
||||
]}
|
||||
>
|
||||
<BackButton color="white" inCustomHeader />
|
||||
{
|
||||
|
||||
@@ -17,15 +17,39 @@ type Props = {
|
||||
const PhotoContainer = ( { photo, onPress, style }: Props ): Node => {
|
||||
const { t } = useTranslation( );
|
||||
const [loadSuccess, setLoadSuccess] = useState( null );
|
||||
// check for local file path for unuploaded photos
|
||||
const photoUrl = photo?.url
|
||||
? photo.url.replace( "square", "large" )
|
||||
: photo.localFilePath;
|
||||
|
||||
const imageSources = [];
|
||||
if ( photo.localFilePath ) {
|
||||
imageSources.push( { uri: photo.localFilePath } );
|
||||
}
|
||||
if ( photo.url ) {
|
||||
imageSources.push( {
|
||||
uri: photo.url,
|
||||
width: 75,
|
||||
height: 75
|
||||
} );
|
||||
imageSources.push( {
|
||||
uri: photo.url.replace( "square", "small" ),
|
||||
width: 240,
|
||||
height: 240
|
||||
} );
|
||||
imageSources.push( {
|
||||
uri: photo.url.replace( "square", "medium" ),
|
||||
width: 500,
|
||||
height: 500
|
||||
} );
|
||||
imageSources.push( {
|
||||
uri: photo.url.replace( "square", "large" ),
|
||||
width: 1024,
|
||||
height: 1024
|
||||
} );
|
||||
}
|
||||
|
||||
const image = (
|
||||
<Image
|
||||
testID="ObsMedia.photo"
|
||||
source={{ uri: photoUrl }}
|
||||
source={imageSources}
|
||||
progressiveRenderingEnabled
|
||||
className={classnames(
|
||||
"h-72",
|
||||
"w-screen",
|
||||
|
||||
@@ -39,8 +39,7 @@ const AddEvidenceSheet = ( {
|
||||
screen: "Camera",
|
||||
params: {
|
||||
addEvidence: true,
|
||||
camera: "Standard",
|
||||
backToObsEdit: true
|
||||
camera: "Standard"
|
||||
}
|
||||
} );
|
||||
} else if ( choice === "import" ) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { RealmContext } from "providers/contexts";
|
||||
import type { Node } from "react";
|
||||
import React, { useCallback } from "react";
|
||||
import { log } from "sharedHelpers/logger";
|
||||
import safeRealmWrite from "sharedHelpers/safeRealmWrite";
|
||||
import { useTranslation } from "sharedHooks";
|
||||
|
||||
const { useRealm } = RealmContext;
|
||||
@@ -41,9 +42,9 @@ const DeleteObservationSheet = ( {
|
||||
navToObsList( );
|
||||
} else {
|
||||
logger.info( "Observation to add to deletion queue: ", localObsToDelete.uuid );
|
||||
realm?.write( ( ) => {
|
||||
safeRealmWrite( realm, ( ) => {
|
||||
localObsToDelete._deleted_at = new Date( );
|
||||
} );
|
||||
}, "adding _deleted_at date in DeleteObservationSheet" );
|
||||
logger.info(
|
||||
"Observation added to deletion queue; returning to MyObservations"
|
||||
);
|
||||
|
||||
@@ -122,8 +122,10 @@ const GroupPhotosContainer = ( ): Node => {
|
||||
removedFromGroup.push( { photos: filteredGroupedPhotos } );
|
||||
}
|
||||
} );
|
||||
|
||||
// remove from group photos screen
|
||||
setGroupedPhotos( removedFromGroup );
|
||||
setSelectedObservations( [] );
|
||||
};
|
||||
|
||||
const navToObsEdit = async ( ) => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import PermissionGateContainer, { READ_MEDIA_PERMISSIONS }
|
||||
import { t } from "i18next";
|
||||
import type { Node } from "react";
|
||||
import React, {
|
||||
useCallback,
|
||||
useState
|
||||
} from "react";
|
||||
import {
|
||||
@@ -46,6 +47,31 @@ const PhotoGallery = ( ): Node => {
|
||||
? params.fromGroupPhotos
|
||||
: false;
|
||||
|
||||
const navToObsList = useCallback( ( ) => {
|
||||
navigation.navigate( "TabNavigator", {
|
||||
screen: "ObservationsStackNavigator",
|
||||
params: {
|
||||
screen: "ObsList"
|
||||
}
|
||||
} );
|
||||
}, [navigation] );
|
||||
|
||||
const navToObsDetails = useCallback( uuid => navigation.navigate( "TabNavigator", {
|
||||
screen: "ObservationsStackNavigator",
|
||||
params: {
|
||||
// Need to return to ObsDetails but with a navigation stack that goes back to ObsList
|
||||
screen: "ObsList",
|
||||
params: {
|
||||
navToObsDetails: true,
|
||||
uuid
|
||||
}
|
||||
}
|
||||
} ), [navigation] );
|
||||
|
||||
const navToObsEdit = useCallback( ( ) => navigation.navigate( "ObsEdit", {
|
||||
lastScreen: "PhotoGallery"
|
||||
} ), [navigation] );
|
||||
|
||||
const showPhotoGallery = React.useCallback( async () => {
|
||||
if ( photoGalleryShown ) {
|
||||
return;
|
||||
@@ -78,8 +104,15 @@ const PhotoGallery = ( ): Node => {
|
||||
// This screen was called from the plus button of the group photos screen - get back to it
|
||||
navigation.navigate( "CameraNavigator", { screen: "GroupPhotos" } );
|
||||
navigation.setParams( { fromGroupPhotos: false } );
|
||||
} else if ( skipGroupPhotos ) {
|
||||
// This only happens when being called from ObsEdit
|
||||
navToObsEdit();
|
||||
|
||||
// Determine if we need to go back to ObsList or ObsDetails screen
|
||||
} else if ( params && params.previousScreen && params.previousScreen.name === "ObsDetails" ) {
|
||||
navToObsDetails( params.previousScreen.params.uuid );
|
||||
} else {
|
||||
navigation.goBack();
|
||||
navToObsList();
|
||||
}
|
||||
setPhotoGalleryShown( false );
|
||||
return;
|
||||
@@ -99,8 +132,6 @@ const PhotoGallery = ( ): Node => {
|
||||
return;
|
||||
}
|
||||
|
||||
const navToObsEdit = () => navigation.navigate( "ObsEdit", { lastScreen: "PhotoGallery" } );
|
||||
|
||||
if ( skipGroupPhotos ) {
|
||||
// add evidence to existing observation
|
||||
setPhotoImporterState( {
|
||||
@@ -136,11 +167,11 @@ const PhotoGallery = ( ): Node => {
|
||||
setPhotoGalleryShown( false );
|
||||
}
|
||||
}, [
|
||||
photoGalleryShown, numOfObsPhotos, setPhotoImporterState,
|
||||
evidenceToAdd, galleryUris, navigation, setGroupedPhotos,
|
||||
fromGroupPhotos, skipGroupPhotos, groupedPhotos, currentObservation,
|
||||
updateObservations, observations,
|
||||
currentObservationIndex] );
|
||||
navToObsEdit, navToObsList, photoGalleryShown, numOfObsPhotos, setPhotoImporterState,
|
||||
evidenceToAdd, galleryUris, navigation, setGroupedPhotos, fromGroupPhotos, skipGroupPhotos,
|
||||
groupedPhotos, currentObservation, updateObservations, observations, currentObservationIndex,
|
||||
navToObsDetails, params
|
||||
] );
|
||||
|
||||
const onPermissionGranted = () => {
|
||||
setPermissionGranted( true );
|
||||
|
||||
@@ -57,7 +57,7 @@ const RotatingINatIconButton = ( {
|
||||
() => ( {
|
||||
transform: [
|
||||
{
|
||||
rotateZ: `-${rotation.value}deg`
|
||||
rotateZ: `${rotation.value}deg`
|
||||
}
|
||||
]
|
||||
} ),
|
||||
|
||||
@@ -168,8 +168,12 @@ const ObservationsFlashList = ( {
|
||||
// react thinks we've rendered a second item w/ a duplicate key
|
||||
keyExtractor={item => item.uuid || item.id}
|
||||
numColumns={numColumns}
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={0.2}
|
||||
onMomentumScrollEnd={( ) => {
|
||||
if ( dataCanBeFetched ) {
|
||||
onEndReached( );
|
||||
}
|
||||
}}
|
||||
onScroll={handleScroll}
|
||||
refreshing={isFetchingNextPage}
|
||||
renderItem={renderItem}
|
||||
|
||||
154
src/components/SharedComponents/ScrollableWithStickyHeader.js
Normal file
154
src/components/SharedComponents/ScrollableWithStickyHeader.js
Normal file
@@ -0,0 +1,154 @@
|
||||
// @flow
|
||||
|
||||
// ScrollableWithStickyHeader renders a scrollable view (e.g. ScrollView or
|
||||
// FlashList) with a header component above it. The header component will
|
||||
// stick to the top of the screen when scrolled to a particular y value
|
||||
// (stickyAt)
|
||||
//
|
||||
// To use this, you need to give it two functions, one to render the header
|
||||
// and the other to render the scrollable. renderHeader takes a single
|
||||
// argument, the setStickyAt function, that sets the scroll offset at which
|
||||
// the header sticks (this is probably dependent on the height of the
|
||||
// rendered layout).
|
||||
//
|
||||
// renderScrollable takes a single argument, the onScroll callback, which
|
||||
// should be passed to the scrollable's onScroll prop, and/or get called with
|
||||
// the same event
|
||||
//
|
||||
// Some background: the easiest way to set a sticky header header with a
|
||||
// ScrollView is the stickyHeaderIndices prop, but that doesn't work quite as
|
||||
// expected with FlashList because the only children of the underlying
|
||||
// ScrollView in FlashList are the items themselves. You can render the
|
||||
// header as the first item and then make that stick, but you run into
|
||||
// problems when you try to show multiple columns and your header component
|
||||
// gets confined to the column width. The solution here uses an offset
|
||||
// transform to achive something similar. It also assumes it occupies full
|
||||
// height
|
||||
//
|
||||
// In case git loses some of the history, this approach was original authored
|
||||
// by @albullington, with modifications by @budowski to deal with overscroll
|
||||
// problems
|
||||
|
||||
import { View } from "components/styledComponents";
|
||||
import type { Node } from "react";
|
||||
import React, {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from "react";
|
||||
import { Animated } from "react-native";
|
||||
import { useDeviceOrientation } from "sharedHooks";
|
||||
|
||||
const { diffClamp } = Animated;
|
||||
|
||||
type Props = {
|
||||
renderHeader: Function,
|
||||
renderScrollable: Function
|
||||
};
|
||||
|
||||
const ScrollableWithStickyHeader = ( {
|
||||
renderHeader,
|
||||
renderScrollable
|
||||
}: Props ): Node => {
|
||||
const {
|
||||
isTablet,
|
||||
screenHeight,
|
||||
screenWidth
|
||||
} = useDeviceOrientation( );
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const [scrollPosition, setScrollPosition] = useState( 0 );
|
||||
|
||||
const [stickyAt, setStickyAt] = useState( 0 );
|
||||
|
||||
// basing collapsible sticky header code off the example in this article
|
||||
// https://medium.com/swlh/making-a-collapsible-sticky-header-animations-with-react-native-6ad7763875c3
|
||||
const scrollY = useRef( new Animated.Value( 0 ) );
|
||||
|
||||
const onScroll = Animated.event(
|
||||
[
|
||||
{
|
||||
nativeEvent: {
|
||||
contentOffset: { y: scrollY.current }
|
||||
}
|
||||
}
|
||||
],
|
||||
{
|
||||
useNativeDriver: true
|
||||
}
|
||||
);
|
||||
|
||||
// On Android, the scroll view offset is a double (not an integer), and interpolation shouldn't be
|
||||
// one-to-one, which causes a jittery header while slow scrolling (see issue #634).
|
||||
// See here as well: https://stackoverflow.com/a/60898411/1233767
|
||||
const scrollYClamped = diffClamp(
|
||||
scrollY.current,
|
||||
0,
|
||||
stickyAt * 2
|
||||
);
|
||||
|
||||
// Same as comment above (see here: https://stackoverflow.com/a/60898411/1233767)
|
||||
const offsetForHeader = scrollYClamped.interpolate( {
|
||||
inputRange: [0, stickyAt * 2],
|
||||
// $FlowIgnore
|
||||
outputRange: [0, -stickyAt]
|
||||
} );
|
||||
|
||||
useEffect( () => {
|
||||
const currentScrollY = scrollY.current;
|
||||
|
||||
if ( scrollY.current ) {
|
||||
// #560 - We use a state variable to force rendering of the component - since on iOS,
|
||||
// you can over scroll a list when scrolling it to the top (creating a bounce effect),
|
||||
// and sometimes, even though offsetForHeader gets updated correctly, it doesn't cause
|
||||
// a re-render of the component, and then the Animated.View's translateY property doesn't
|
||||
// get updated with the latest value of offsetForHeader (this causes a weird view, where the
|
||||
// top header if semi cut off, even though the user scrolled the list all the way to the top).
|
||||
// So by changing a state variable of the component, every time the user scroll the list -> we
|
||||
// make sure the component always gets re-rendered.
|
||||
currentScrollY.addListener( ( { value } ) => {
|
||||
if ( value <= 0 ) {
|
||||
// Only force refresh of the state in case of an over-scroll (bounce effect)
|
||||
setScrollPosition( value );
|
||||
}
|
||||
} );
|
||||
}
|
||||
|
||||
return () => {
|
||||
currentScrollY.removeAllListeners();
|
||||
};
|
||||
}, [scrollY] );
|
||||
|
||||
const contentHeight = useMemo(
|
||||
( ) => (
|
||||
isTablet
|
||||
? screenHeight
|
||||
: Math.max( screenWidth, screenHeight )
|
||||
),
|
||||
[isTablet, screenHeight, screenWidth]
|
||||
);
|
||||
|
||||
return (
|
||||
// Note that we want to occupy full height but hide the overflow because
|
||||
// we are intentionally setting the height of the Animated.View to exceed
|
||||
// the height of this parent view. We want the parent view to be laid out
|
||||
// nicely with its peers, not flow off the screen.
|
||||
<View className="overflow-hidden h-full">
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
transform: [{ translateY: offsetForHeader }],
|
||||
// Set the height to flow off screen so that when we translate the
|
||||
// view up, there's no gap at the bottom
|
||||
height: contentHeight + stickyAt
|
||||
}
|
||||
]}
|
||||
>
|
||||
{renderHeader( setStickyAt )}
|
||||
{renderScrollable( onScroll )}
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScrollableWithStickyHeader;
|
||||
@@ -1,85 +0,0 @@
|
||||
// @flow
|
||||
import type { Node } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Animated } from "react-native";
|
||||
import { useDeviceOrientation } from "sharedHooks";
|
||||
|
||||
const { diffClamp } = Animated;
|
||||
|
||||
type Props = {
|
||||
children: any,
|
||||
scrollY: any,
|
||||
heightAboveView: number
|
||||
};
|
||||
|
||||
const StickyView = ( {
|
||||
children,
|
||||
scrollY,
|
||||
heightAboveView
|
||||
}: Props ): Node => {
|
||||
const {
|
||||
isTablet,
|
||||
screenHeight,
|
||||
screenWidth
|
||||
} = useDeviceOrientation( );
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const [scrollPosition, setScrollPosition] = useState( 0 );
|
||||
|
||||
// On Android, the scroll view offset is a double (not an integer), and interpolation shouldn't be
|
||||
// one-to-one, which causes a jittery header while slow scrolling (see issue #634).
|
||||
// See here as well: https://stackoverflow.com/a/60898411/1233767
|
||||
const scrollYClamped = diffClamp(
|
||||
scrollY.current,
|
||||
0,
|
||||
heightAboveView * 2
|
||||
);
|
||||
|
||||
// Same as comment above (see here: https://stackoverflow.com/a/60898411/1233767)
|
||||
const offsetForHeader = scrollYClamped.interpolate( {
|
||||
inputRange: [0, heightAboveView * 2],
|
||||
// $FlowIgnore
|
||||
outputRange: [0, -heightAboveView]
|
||||
} );
|
||||
|
||||
useEffect( () => {
|
||||
const currentScrollY = scrollY.current;
|
||||
|
||||
if ( scrollY.current ) {
|
||||
// #560 - We use a state variable to force rendering of the component - since on iOS,
|
||||
// you can over scroll a list when scrolling it to the top (creating a bounce effect),
|
||||
// and sometimes, even though offsetForHeader gets updated correctly, it doesn't cause
|
||||
// a re-render of the component, and then the Animated.View's translateY property doesn't
|
||||
// get updated with the latest value of offsetForHeader (this causes a weird view, where the
|
||||
// top header if semi cut off, even though the user scrolled the list all the way to the top).
|
||||
// So by changing a state variable of the component, every time the user scroll the list -> we
|
||||
// make sure the component always gets re-rendered.
|
||||
currentScrollY.addListener( ( { value } ) => {
|
||||
if ( value <= 0 ) {
|
||||
// Only force refresh of the state in case of an over-scroll (bounce effect)
|
||||
setScrollPosition( value );
|
||||
}
|
||||
} );
|
||||
}
|
||||
|
||||
return () => {
|
||||
currentScrollY.removeAllListeners();
|
||||
};
|
||||
}, [scrollY] );
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
transform: [{ translateY: offsetForHeader }],
|
||||
height: isTablet
|
||||
? screenHeight
|
||||
: Math.max( screenWidth, screenHeight )
|
||||
}
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
export default StickyView;
|
||||
@@ -1,9 +1,10 @@
|
||||
// @flow
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import {
|
||||
Body1, INatIcon,
|
||||
List2, UserIcon
|
||||
} from "components/SharedComponents";
|
||||
import { View } from "components/styledComponents";
|
||||
import { Pressable, View } from "components/styledComponents";
|
||||
import type { Node } from "react";
|
||||
import React from "react";
|
||||
import User from "realmModels/User";
|
||||
@@ -18,11 +19,15 @@ type Props = {
|
||||
const UserListItem = ( { item, count, countText }: Props ): Node => {
|
||||
const { t } = useTranslation( );
|
||||
const user = item?.user;
|
||||
const navigation = useNavigation( );
|
||||
|
||||
return (
|
||||
<View
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
className="flex-row items-center mx-3 my-2"
|
||||
testID={`UserProfile.${user?.id}`}
|
||||
onPress={( ) => navigation.navigate( "UserProfile", { userId: user?.id } )}
|
||||
accessibilityLabel={t( "Navigates-to-user-profile" )}
|
||||
>
|
||||
|
||||
{user?.icon_url
|
||||
@@ -34,12 +39,12 @@ const UserListItem = ( { item, count, countText }: Props ): Node => {
|
||||
/>
|
||||
)}
|
||||
<View className="ml-3">
|
||||
<Body1 className="mt-3">{user?.login}</Body1>
|
||||
{user?.login && <Body1 className="mt-3">{user?.login}</Body1>}
|
||||
<List2 className="mt-1">
|
||||
{t( countText, { count } )}
|
||||
</List2>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ export { default as PhotoCount } from "./PhotoCount";
|
||||
export { default as ProjectListItem } from "./ProjectListItem";
|
||||
export { default as QualityGradeStatus } from "./QualityGradeStatus/QualityGradeStatus";
|
||||
export { default as RadioButtonRow } from "./RadioButtonRow";
|
||||
export { default as ScrollableWithStickyHeader } from "./ScrollableWithStickyHeader";
|
||||
export { default as ScrollViewWrapper } from "./ScrollViewWrapper";
|
||||
export { default as SearchBar } from "./SearchBar";
|
||||
export { default as BottomSheet } from "./Sheets/BottomSheet";
|
||||
@@ -45,7 +46,6 @@ export { default as TextInputSheet } from "./Sheets/TextInputSheet";
|
||||
export { default as TextSheet } from "./Sheets/TextSheet";
|
||||
export { default as WarningSheet } from "./Sheets/WarningSheet";
|
||||
export { default as StickyToolbar } from "./StickyToolbar";
|
||||
export { default as StickyView } from "./StickyView";
|
||||
export { default as Tabs } from "./Tabs/Tabs";
|
||||
export { default as TaxonResult } from "./TaxonResult";
|
||||
export { default as Body1 } from "./Typography/Body1";
|
||||
@@ -65,5 +65,6 @@ export { default as List2 } from "./Typography/List2";
|
||||
export { default as Subheading1 } from "./Typography/Subheading1";
|
||||
export { default as UploadStatus } from "./UploadStatus/UploadStatus";
|
||||
export { default as UserIcon } from "./UserIcon/UserIcon";
|
||||
export { default as UserListItem } from "./UserListItem";
|
||||
export { default as UserText } from "./UserText";
|
||||
export { default as ViewWrapper } from "./ViewWrapper";
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
convertOfflineScoreToConfidence,
|
||||
convertOnlineScoreToConfidence
|
||||
} from "sharedHelpers/convertScores";
|
||||
import { formatISONoTimezone } from "sharedHelpers/dateAndTime";
|
||||
import { useTranslation } from "sharedHooks";
|
||||
|
||||
import AddCommentPrompt from "./AddCommentPrompt";
|
||||
@@ -36,7 +37,8 @@ type Props = {
|
||||
setSelectedPhotoUri: Function,
|
||||
observers: Array<string>,
|
||||
topSuggestion: Object,
|
||||
usingOfflineSuggestions: boolean
|
||||
usingOfflineSuggestions: boolean,
|
||||
debugData: any
|
||||
};
|
||||
|
||||
const Suggestion = ( { suggestion, onChosen } ) => (
|
||||
@@ -63,7 +65,8 @@ const Suggestions = ( {
|
||||
setSelectedPhotoUri,
|
||||
observers,
|
||||
topSuggestion,
|
||||
usingOfflineSuggestions
|
||||
usingOfflineSuggestions,
|
||||
debugData
|
||||
}: Props ): Node => {
|
||||
const { t } = useTranslation( );
|
||||
const navigation = useNavigation( );
|
||||
@@ -93,9 +96,26 @@ const Suggestions = ( {
|
||||
return null;
|
||||
}, [loadingSuggestions, suggestions, t] );
|
||||
|
||||
/* eslint-disable i18next/no-literal-string */
|
||||
/* eslint-disable react/jsx-one-expression-per-line */
|
||||
/* eslint-disable max-len */
|
||||
const renderFooter = useCallback( ( ) => (
|
||||
<Attribution observers={observers} />
|
||||
), [observers] );
|
||||
<>
|
||||
<Attribution observers={observers} />
|
||||
<View className="bg-yellow p-3">
|
||||
<Heading4>Diagnostics</Heading4>
|
||||
<Body3>Online suggestions URI: {JSON.stringify( debugData?.selectedPhotoUri )}</Body3>
|
||||
<Body3>Online suggestions updated at: {formatISONoTimezone( debugData?.onlineSuggestionsUpdatedAt )}</Body3>
|
||||
<Body3>Online suggestions timed out: {JSON.stringify( debugData?.timedOut )}</Body3>
|
||||
<Body3>Num online suggestions: {JSON.stringify( debugData?.onlineSuggestions?.results.length )}</Body3>
|
||||
<Body3>Num offline suggestions: {JSON.stringify( debugData?.offlineSuggestions?.length )}</Body3>
|
||||
<Body3>Error loading online: {JSON.stringify( debugData?.onlineSuggestionsError )}</Body3>
|
||||
</View>
|
||||
</>
|
||||
), [debugData, observers] );
|
||||
/* eslint-enable i18next/no-literal-string */
|
||||
/* eslint-enable react/jsx-one-expression-per-line */
|
||||
/* eslint-enable max-len */
|
||||
|
||||
const renderHeader = useCallback( ( ) => (
|
||||
<>
|
||||
|
||||
@@ -20,6 +20,8 @@ const SuggestionsContainer = ( ): Node => {
|
||||
const [selectedTaxon, setSelectedTaxon] = useState( null );
|
||||
|
||||
const {
|
||||
dataUpdatedAt: onlineSuggestionsUpdatedAt,
|
||||
error: onlineSuggestionsError,
|
||||
onlineSuggestions,
|
||||
loadingOnlineSuggestions,
|
||||
timedOut
|
||||
@@ -73,6 +75,14 @@ const SuggestionsContainer = ( ): Node => {
|
||||
setSelectedPhotoUri={setSelectedPhotoUri}
|
||||
observers={observers}
|
||||
usingOfflineSuggestions={tryOfflineSuggestions && offlineSuggestions?.length > 0}
|
||||
debugData={{
|
||||
timedOut,
|
||||
onlineSuggestions,
|
||||
offlineSuggestions,
|
||||
onlineSuggestionsError,
|
||||
onlineSuggestionsUpdatedAt,
|
||||
selectedPhotoUri
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
useAuthenticatedQuery
|
||||
} from "sharedHooks";
|
||||
|
||||
const SCORE_IMAGE_TIMEOUT = 5_000;
|
||||
|
||||
const resizeImage = async (
|
||||
path: string,
|
||||
width: number,
|
||||
@@ -63,9 +65,11 @@ const flattenUploadParams = async (
|
||||
};
|
||||
|
||||
type OnlineSuggestionsResponse = {
|
||||
dataUpdatedAt: Date,
|
||||
onlineSuggestions: Object,
|
||||
loadingOnlineSuggestions: boolean,
|
||||
timedOut: boolean
|
||||
timedOut: boolean,
|
||||
error: Object
|
||||
}
|
||||
|
||||
const useOnlineSuggestions = (
|
||||
@@ -82,8 +86,10 @@ const useOnlineSuggestions = (
|
||||
// uploading images
|
||||
const {
|
||||
data: onlineSuggestions,
|
||||
dataUpdatedAt,
|
||||
isLoading: loadingOnlineSuggestions,
|
||||
isError
|
||||
isError,
|
||||
error
|
||||
} = useAuthenticatedQuery(
|
||||
["scoreImage", selectedPhotoUri],
|
||||
async optsWithAuth => {
|
||||
@@ -103,13 +109,14 @@ const useOnlineSuggestions = (
|
||||
}
|
||||
);
|
||||
|
||||
// Give up on suggestions request after a timeout
|
||||
useEffect( ( ) => {
|
||||
const timer = setTimeout( ( ) => {
|
||||
if ( onlineSuggestions === undefined ) {
|
||||
queryClient.cancelQueries( { queryKey: ["scoreImage", selectedPhotoUri] } );
|
||||
setTimedOut( true );
|
||||
}
|
||||
}, 2000 );
|
||||
}, SCORE_IMAGE_TIMEOUT );
|
||||
|
||||
return ( ) => {
|
||||
clearTimeout( timer );
|
||||
@@ -118,11 +125,15 @@ const useOnlineSuggestions = (
|
||||
|
||||
return timedOut
|
||||
? {
|
||||
dataUpdatedAt,
|
||||
error,
|
||||
onlineSuggestions: undefined,
|
||||
loadingOnlineSuggestions: false,
|
||||
timedOut
|
||||
}
|
||||
: {
|
||||
dataUpdatedAt,
|
||||
error,
|
||||
onlineSuggestions,
|
||||
loadingOnlineSuggestions: loadingOnlineSuggestions && !isError,
|
||||
timedOut
|
||||
|
||||
@@ -24,7 +24,7 @@ const useTaxonSelected = ( selectedTaxon: ?Object, options: Object ) => {
|
||||
// screen (by adding an id) or they can first land on ObsEdit (by tapping the edit button)
|
||||
if ( lastScreen === "ObsDetails" ) {
|
||||
navigation.navigate( "ObsDetails", {
|
||||
uuid: currentObservation.uuid,
|
||||
uuid: currentObservation?.uuid,
|
||||
// TODO refactor so we're not passing complex objects as params; all
|
||||
// obs details really needs to know is the ID of the taxon
|
||||
suggestedTaxonId: selectedTaxon.id,
|
||||
@@ -44,7 +44,7 @@ const useTaxonSelected = ( selectedTaxon: ?Object, options: Object ) => {
|
||||
}
|
||||
}, [
|
||||
comment,
|
||||
currentObservation.uuid,
|
||||
currentObservation?.uuid,
|
||||
lastScreen,
|
||||
navigation,
|
||||
selectedTaxon,
|
||||
|
||||
36
src/components/hooks/useChangeLocale.js
Normal file
36
src/components/hooks/useChangeLocale.js
Normal file
@@ -0,0 +1,36 @@
|
||||
// @flow
|
||||
|
||||
import { useCallback, useEffect } from "react";
|
||||
import {
|
||||
useTranslation,
|
||||
useUserMe
|
||||
} from "sharedHooks";
|
||||
|
||||
const useChangeLocale = ( currentUser: ?Object ) => {
|
||||
const { i18n } = useTranslation( );
|
||||
// fetch current user from server and save to realm in useEffect
|
||||
// this is used for changing locale and also for showing UserCard
|
||||
const { remoteUser } = useUserMe( { updateRealm: true } );
|
||||
const changeLanguageToLocale = useCallback(
|
||||
locale => i18n.changeLanguage( locale ),
|
||||
[i18n]
|
||||
);
|
||||
|
||||
// When we get the updated current user, update the record in the database
|
||||
useEffect( ( ) => {
|
||||
if ( !remoteUser ) { return; }
|
||||
// If the current user's locale has changed, change the language
|
||||
if ( remoteUser?.locale !== i18n.language ) {
|
||||
changeLanguageToLocale( remoteUser.locale );
|
||||
}
|
||||
}, [changeLanguageToLocale, i18n, remoteUser] );
|
||||
|
||||
// If the current user's locale is not set, change the language
|
||||
useEffect( ( ) => {
|
||||
if ( currentUser?.locale && currentUser?.locale !== i18n.language ) {
|
||||
changeLanguageToLocale( currentUser.locale );
|
||||
}
|
||||
}, [changeLanguageToLocale, currentUser?.locale, i18n] );
|
||||
};
|
||||
|
||||
export default useChangeLocale;
|
||||
34
src/components/hooks/useFreshInstall.js
Normal file
34
src/components/hooks/useFreshInstall.js
Normal file
@@ -0,0 +1,34 @@
|
||||
// @flow
|
||||
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { signOut } from "components/LoginSignUp/AuthenticationService";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { log } from "../../../react-native-logs.config";
|
||||
|
||||
const logger = log.extend( "useFreshInstall" );
|
||||
|
||||
const useFreshInstall = ( currentUser: ?Object ) => {
|
||||
useEffect( ( ) => {
|
||||
const checkForSignedInUser = async ( ) => {
|
||||
// check to see if this is a fresh install of the app
|
||||
// if it is, delete realm file when we sign the user out of the app
|
||||
// this handles the case where a user deletes the app, then reinstalls
|
||||
// and expects to be signed out with no previously saved data
|
||||
const alreadyLaunched = await AsyncStorage.getItem( "alreadyLaunched" );
|
||||
if ( !alreadyLaunched ) {
|
||||
await AsyncStorage.setItem( "alreadyLaunched", "true" );
|
||||
if ( !currentUser ) {
|
||||
logger.debug(
|
||||
"Signing out and deleting Realm because no signed in user found in the database"
|
||||
);
|
||||
await signOut( { clearRealm: true } );
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
checkForSignedInUser( );
|
||||
}, [currentUser] );
|
||||
};
|
||||
|
||||
export default useFreshInstall;
|
||||
47
src/components/hooks/useLinking.js
Normal file
47
src/components/hooks/useLinking.js
Normal file
@@ -0,0 +1,47 @@
|
||||
// @flow
|
||||
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { Linking } from "react-native";
|
||||
|
||||
const useLinking = ( currentUser: ?Object ) => {
|
||||
const navigation = useNavigation( );
|
||||
const navigateConfirmedUser = useCallback( ( ) => {
|
||||
if ( currentUser ) { return; }
|
||||
navigation.navigate( "LoginNavigator", {
|
||||
screen: "Login",
|
||||
params: { emailConfirmed: true }
|
||||
} );
|
||||
}, [navigation, currentUser] );
|
||||
|
||||
const newAccountConfirmedUrl = "https://www.inaturalist.org/users/sign_in?confirmed=true";
|
||||
const existingAccountConfirmedUrl = "https://www.inaturalist.org/home?confirmed=true";
|
||||
// const testUrl = "https://www.inaturalist.org/observations";
|
||||
|
||||
useEffect( ( ) => {
|
||||
Linking.addEventListener( "url", async ( { url } ) => {
|
||||
if ( url === newAccountConfirmedUrl
|
||||
// || url.includes( testUrl )
|
||||
|| url === existingAccountConfirmedUrl
|
||||
) {
|
||||
navigateConfirmedUser( );
|
||||
}
|
||||
} );
|
||||
}, [navigateConfirmedUser] );
|
||||
|
||||
useEffect( ( ) => {
|
||||
const fetchInitialUrl = async ( ) => {
|
||||
const url = await Linking.getInitialURL( );
|
||||
|
||||
if ( url === newAccountConfirmedUrl
|
||||
// || url?.includes( testUrl )
|
||||
|| url === existingAccountConfirmedUrl
|
||||
) {
|
||||
navigateConfirmedUser( );
|
||||
}
|
||||
};
|
||||
fetchInitialUrl( );
|
||||
}, [navigateConfirmedUser] );
|
||||
};
|
||||
|
||||
export default useLinking;
|
||||
19
src/components/hooks/useLockOrientation.js
Normal file
19
src/components/hooks/useLockOrientation.js
Normal file
@@ -0,0 +1,19 @@
|
||||
// @flow
|
||||
|
||||
import { useEffect } from "react";
|
||||
import DeviceInfo from "react-native-device-info";
|
||||
import Orientation from "react-native-orientation-locker";
|
||||
|
||||
const isTablet = DeviceInfo.isTablet();
|
||||
|
||||
const useLockOrientation = ( ) => {
|
||||
useEffect( () => {
|
||||
if ( !isTablet ) {
|
||||
Orientation.lockToPortrait();
|
||||
}
|
||||
|
||||
return ( ) => Orientation?.unlockAllOrientations( );
|
||||
}, [] );
|
||||
};
|
||||
|
||||
export default useLockOrientation;
|
||||
25
src/components/hooks/useReactQueryRefetch.js
Normal file
25
src/components/hooks/useReactQueryRefetch.js
Normal file
@@ -0,0 +1,25 @@
|
||||
// @flow
|
||||
|
||||
import { focusManager } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
AppState
|
||||
} from "react-native";
|
||||
|
||||
const useReactQueryRefetch = ( ) => {
|
||||
// When the app is coming back from the background, set the focusManager to focused
|
||||
// This will trigger react-query to refetch any queries that are stale
|
||||
const onAppStateChange = status => {
|
||||
focusManager.setFocused( status === "active" );
|
||||
};
|
||||
|
||||
useEffect( () => {
|
||||
// subscribe to app state changes
|
||||
const subscription = AppState.addEventListener( "change", onAppStateChange );
|
||||
|
||||
// unsubscribe on unmount
|
||||
return ( ) => subscription?.remove();
|
||||
}, [] );
|
||||
};
|
||||
|
||||
export default useReactQueryRefetch;
|
||||
@@ -589,6 +589,8 @@ NOTES = NOTES
|
||||
|
||||
Notifications = Notifications
|
||||
|
||||
NOTIFICATIONS = NOTIFICATIONS
|
||||
|
||||
# notification when someone adds an identification to your observation
|
||||
notifications-user-added-identification-to-observation-by-you = <0>{$userName}</0> added an identification to an observation by you
|
||||
|
||||
|
||||
@@ -362,6 +362,7 @@
|
||||
"val": "NOTES"
|
||||
},
|
||||
"Notifications": "Notifications",
|
||||
"NOTIFICATIONS": "NOTIFICATIONS",
|
||||
"notifications-user-added-identification-to-observation-by-you": {
|
||||
"comment": "notification when someone adds an identification to your observation",
|
||||
"val": "<0>{ $userName }</0> added an identification to an observation by you"
|
||||
|
||||
@@ -589,6 +589,8 @@ NOTES = NOTES
|
||||
|
||||
Notifications = Notifications
|
||||
|
||||
NOTIFICATIONS = NOTIFICATIONS
|
||||
|
||||
# notification when someone adds an identification to your observation
|
||||
notifications-user-added-identification-to-observation-by-you = <0>{$userName}</0> added an identification to an observation by you
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" version="1.1">
|
||||
<path d="M 12.036054,-1.5261334e-5 C 9.890664,-0.01182374 7.7700925,0.54758015 5.8972318,1.6315953 4.4725101,2.456216 3.2399629,3.5555111 2.2644193,4.856956 V 2.4735425 c 0,-0.6252271 -0.5069937,-1.1322116 -1.1322116,-1.1322116 -0.62521966,0 -1.1322115239768,0.5069845 -1.1322115239768,1.1322116 v 5.4429087 c 0,0.6252363 0.5069918639768,1.1322116 1.1322115239768,1.1322116 H 1.784852 c 0.017224,3.969e-4 0.035124,3.969e-4 0.052284,0 h 4.728966 c 0.6252179,0 1.1322116,-0.5069753 1.1322116,-1.1322116 0,-0.625227 -0.5069937,-1.1322115 -1.1322116,-1.1322115 H 3.6796837 C 4.5243813,5.4752717 5.6700876,4.3773734 7.0312463,3.5895281 8.9059952,2.5044246 11.084384,2.0679964 13.231366,2.3455376 c 2.146983,0.2775505 4.144017,1.2552495 5.682693,2.781851 1.538768,1.5266385 2.534086,3.5167609 2.832332,5.6664664 0.08585,0.619292 0.65715,1.052284 1.276442,0.966346 0.619292,-0.08585 1.052284,-0.657151 0.966346,-1.276442 C 23.622903,7.8432261 22.40081,5.3955983 20.509612,3.5192156 18.618321,1.6427866 16.162183,0.44049691 13.521631,0.0991434 13.026545,0.03513944 12.531144,0.00270977 12.036054,-1.5261334e-5 Z M 11.999996,5.6610425 c -0.500214,0 -0.905048,0.4048609 -0.905048,0.905048 v 7.2457935 c 0,0.500122 0.404834,0.905048 0.905048,0.905048 0.500215,0 0.905049,-0.404926 0.905049,-0.905048 V 6.5660905 c 0,-0.5001871 -0.404834,-0.905048 -0.905049,-0.905048 z M 1.206126,12.230754 c -0.075068,-0.0048 -0.1515549,-0.0017 -0.2289663,0.009 -0.61929267,0.08585 -1.05224915,0.657058 -0.96634622,1.276442 0.36627481,2.640551 1.58833222,5.088114 3.47956732,6.964543 1.8912721,1.876429 4.3474297,3.078718 6.9879802,3.420072 2.64046,0.341354 5.319295,-0.198238 7.6244,-1.532451 1.424676,-0.824584 2.657306,-1.923916 3.632812,-3.225361 v 2.383413 c 0,0.6252 0.507012,1.132212 1.132212,1.132212 0.625292,0 1.132211,-0.507012 1.132211,-1.132212 v -5.442909 c 0,-0.625291 -0.506919,-1.132211 -1.132211,-1.132211 H 22.21514 c -0.01717,-3.69e-4 -0.03521,-3.69e-4 -0.05228,0 h -4.728966 c -0.6252,0 -1.132212,0.50692 -1.132212,1.132211 0,0.6252 0.507012,1.132212 1.132212,1.132212 h 2.886418 c -0.844707,1.308922 -1.990395,2.406866 -3.351563,3.194712 -1.874767,1.085168 -4.053138,1.521559 -6.20012,1.24399 C 8.6216806,21.376863 6.6246092,20.399164 5.0859337,18.872581 3.5472122,17.345998 2.5517832,15.355774 2.2536019,13.206115 2.1784382,12.664235 1.7315988,12.264407 1.206126,12.230754 Z m 10.746996,4.298077 c -0.47855,0.02425 -0.858174,0.420465 -0.858174,0.905048 0,0.500215 0.404834,0.905049 0.905048,0.905049 0.500215,0 0.905049,-0.404834 0.905049,-0.905049 0,-0.500215 -0.404834,-0.905048 -0.905049,-0.905048 -0.01563,0 -0.03144,-7.83e-4 -0.04687,0 z"/>
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24" height="24" version="1.1" viewBox="0 0 24 24">
|
||||
<path d="m11.964-1.5268e-5c2.1454-0.011808 4.266 0.54758 6.1388 1.6316 1.4247 0.82459 2.6573 1.9238 3.6328 3.2252v-2.3833c0-0.6252 0.50699-1.1322 1.1322-1.1322 0.62522 0 1.1322 0.50697 1.1322 1.1322v5.4427c0 0.62521-0.50699 1.1322-1.1322 1.1322h-0.65264c-0.01722 3.969e-4 -0.03512 3.969e-4 -0.05228 0h-4.729c-0.62522 0-1.1322-0.50696-1.1322-1.1322 0-0.6252 0.50699-1.1322 1.1322-1.1322h2.8864c-0.8447-1.3089-1.9904-2.4068-3.3516-3.1946-1.8747-1.0851-4.0531-1.5215-6.2001-1.2439-2.147 0.27754-4.144 1.2552-5.6827 2.7818-1.5388 1.5266-2.5341 3.5166-2.8323 5.6663-0.08585 0.61927-0.65715 1.0522-1.2764 0.96631-0.61929-0.08585-1.0523-0.65713-0.96635-1.2764 0.36628-2.6404 1.5884-5.088 3.4796-6.9643 1.8913-1.8764 4.3474-3.0786 6.988-3.42 0.49509-0.064002 0.99049-0.09643 1.4856-0.099155zm0.036058 5.6609c0.50021 0 0.90505 0.40485 0.90505 0.90502v7.2455c0 0.5001-0.40483 0.90502-0.90505 0.90502-0.50022 0-0.90505-0.40491-0.90505-0.90502v-7.2455c0-0.50017 0.40483-0.90502 0.90505-0.90502zm10.794 6.5695c0.07507-0.0048 0.15156-0.0017 0.22897 9e-3 0.61929 0.08585 1.0522 0.65704 0.96635 1.2764-0.36627 2.6405-1.5883 5.0879-3.4796 6.9643-1.8913 1.8764-4.3474 3.0786-6.988 3.42-2.6405 0.34134-5.3193-0.19823-7.6244-1.5324-1.4247-0.82456-2.6573-1.9238-3.6328-3.2252v2.3833c0 0.62518-0.50701 1.1322-1.1322 1.1322-0.62529 0-1.1322-0.50699-1.1322-1.1322v-5.4427c0-0.62527 0.50692-1.1322 1.1322-1.1322h0.65264c0.01717-3.69e-4 0.03521-3.69e-4 0.05228 0h4.729c0.6252 0 1.1322 0.5069 1.1322 1.1322 0 0.62518-0.50701 1.1322-1.1322 1.1322h-2.8864c0.84471 1.3089 1.9904 2.4068 3.3516 3.1946 1.8748 1.0851 4.0531 1.5215 6.2001 1.2439 2.1469-0.27754 4.144-1.2552 5.6827-2.7817 1.5387-1.5265 2.5342-3.5167 2.8323-5.6663 0.07516-0.54186 0.522-0.94168 1.0475-0.97533zm-10.747 4.2979c0.47855 0.02425 0.85817 0.42045 0.85817 0.90502 0 0.5002-0.40483 0.90502-0.90505 0.90502-0.50022 0-0.90505-0.40482-0.90505-0.90502 0-0.5002 0.40483-0.90502 0.90505-0.90502 0.01563 0 0.03144-7.83e-4 0.04687 0z" stroke-width=".99998"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.1 KiB |
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" version="1.1">
|
||||
<path d="M 5.8974026,1.6307794 C 8.2024651,0.29660732 10.881697,-0.24135104 13.52216,0.10000327 c 2.640555,0.34135339 5.095479,1.54300683 6.986772,3.41943513 1.891201,1.8763821 3.113909,4.3234004 3.480186,6.9639326 0.08594,0.619291 -0.346523,1.191044 -0.965723,1.27689 C 22.404102,11.8462 21.832348,11.413831 21.746502,10.79454 21.448256,8.6448347 20.452994,6.6533727 18.914224,5.1267347 17.375546,3.6001337 15.37893,2.6230119 13.231944,2.3454615 11.084959,2.0679203 8.9063301,2.5052551 7.0315785,3.5903583 5.6704179,4.3782034 4.5241589,5.4758512 3.6794601,6.7848187 h 2.8866474 c 0.6252187,0 1.132062,0.5068424 1.132062,1.1320692 0,0.6252361 -0.5068433,1.1320785 -1.132062,1.1320785 H 1.8367762 c -0.01716,3.97e-4 -0.034357,3.97e-4 -0.051582,0 H 1.1322005 C 0.50697991,9.0489664 1.3842646e-4,8.542124 1.3842646e-4,7.9168879 V 2.4727721 c 0,-0.6252268 0.50684148354,-1.1320785 1.13206207354,-1.1320785 0.6252187,0 1.132062,0.5068517 1.132062,1.1320785 V 4.8573259 C 3.2398076,3.5558814 4.4726789,2.4553998 5.8974026,1.6307794 Z M 0.97666197,12.239707 c 0.61929253,-0.08594 1.19096363,0.34643 1.27686513,0.965722 0.2981817,2.149658 1.2935083,4.141194 2.8322228,5.667777 1.5386869,1.526582 3.5353677,2.50375 5.6823161,2.781319 2.146986,0.277569 4.325633,-0.159784 6.200403,-1.244952 1.36117,-0.787845 2.507355,-1.885475 3.352063,-3.194396 h -2.886647 c -0.625201,0 -1.132062,-0.506861 -1.132062,-1.13206 0,-0.625291 0.506861,-1.132152 1.132062,-1.132152 h 4.729386 c 0.01708,-3.69e-4 0.03434,-3.69e-4 0.05151,0 h 0.652985 c 0.625292,0 1.132062,0.506861 1.132062,1.132152 v 5.444116 c 0,0.625199 -0.50677,1.13206 -1.132062,1.13206 -0.6252,0 -1.132062,-0.506861 -1.132062,-1.13206 v -2.384582 c -0.975508,1.301445 -2.20837,2.401936 -3.633048,3.226519 -2.305109,1.334214 -4.984341,1.872182 -7.624803,1.530829 C 7.8372958,23.558646 5.3823808,22.356986 3.4911061,20.480557 1.5998684,18.604129 0.37715001,16.157148 0.01087468,13.516597 -0.07502837,12.897214 0.35736846,12.325553 0.97666197,12.239707 Z"/>
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24" height="24" version="1.1" viewBox="0 0 24 24">
|
||||
<path d="m18.103 1.6308c-2.3051-1.3342-4.9843-1.8721-7.6248-1.5308-2.6406 0.34135-5.0955 1.543-6.9868 3.4194-1.8912 1.8764-3.1139 4.3234-3.4802 6.9639-0.08594 0.61929 0.34652 1.191 0.96572 1.2769 0.61929 0.085939 1.191-0.34643 1.2769-0.96572 0.29825-2.1497 1.2935-4.1412 2.8323-5.6678 1.5387-1.5266 3.5353-2.5037 5.6823-2.7813 2.147-0.27754 4.3256 0.15979 6.2004 1.2449 1.3612 0.78785 2.5074 1.8855 3.3521 3.1945h-2.8866c-0.62522 0-1.1321 0.50684-1.1321 1.1321 0 0.62524 0.50684 1.1321 1.1321 1.1321h4.7293c0.01716 3.97e-4 0.03436 3.97e-4 0.05158 0h0.653c0.62522 0 1.1321-0.50684 1.1321-1.1321v-5.4441c0-0.62523-0.50684-1.1321-1.1321-1.1321-0.62522 0-1.1321 0.50685-1.1321 1.1321v2.3846c-0.97555-1.3014-2.2084-2.4019-3.6331-3.2265zm4.9207 10.609c-0.61929-0.08594-1.191 0.34643-1.2769 0.96572-0.29818 2.1497-1.2935 4.1412-2.8322 5.6678-1.5387 1.5266-3.5354 2.5038-5.6823 2.7813-2.147 0.27757-4.3256-0.15978-6.2004-1.245-1.3612-0.78784-2.5074-1.8855-3.3521-3.1944h2.8866c0.6252 0 1.1321-0.50686 1.1321-1.1321 0-0.62529-0.50686-1.1322-1.1321-1.1322h-4.7294c-0.01708-3.69e-4 -0.03434-3.69e-4 -0.05151 0h-0.65298c-0.62529 0-1.1321 0.50686-1.1321 1.1322v5.4441c0 0.6252 0.50677 1.1321 1.1321 1.1321 0.6252 0 1.1321-0.50686 1.1321-1.1321v-2.3846c0.97551 1.3014 2.2084 2.4019 3.633 3.2265 2.3051 1.3342 4.9843 1.8722 7.6248 1.5308 2.6406-0.34135 5.0955-1.543 6.9867-3.4194 1.8912-1.8764 3.114-4.3234 3.4802-6.964 0.0859-0.61938-0.34649-1.191-0.96579-1.2769z"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -84,7 +84,10 @@ const CustomTabBarContainer = ( { navigation, isOnline }: Props ): Node => {
|
||||
height: 44,
|
||||
size: 32,
|
||||
onPress: ( ) => {
|
||||
navigation.navigate( "Notifications" );
|
||||
navigation.reset( {
|
||||
index: 0,
|
||||
routes: [{ name: "Notifications" }]
|
||||
} );
|
||||
setActiveTab( NOTIFICATIONS_SCREEN_ID );
|
||||
},
|
||||
active: NOTIFICATIONS_SCREEN_ID === activeTab
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// @flow
|
||||
import { INatIconButton, UserIcon } from "components/SharedComponents";
|
||||
import { Pressable } from "components/styledComponents";
|
||||
import NotificationsIconContainer from "navigation/BottomTabNavigator/NotificationsIconContainer";
|
||||
import * as React from "react";
|
||||
import colors from "styles/tailwindColors";
|
||||
|
||||
@@ -47,6 +48,16 @@ const NavButton = ( {
|
||||
height
|
||||
};
|
||||
|
||||
const notificationProps = {
|
||||
testID,
|
||||
onPress,
|
||||
accessibilityRole,
|
||||
accessibilityLabel,
|
||||
accessibilityHint,
|
||||
width,
|
||||
height
|
||||
};
|
||||
|
||||
if ( userIconUri ) {
|
||||
return (
|
||||
<Pressable
|
||||
@@ -58,6 +69,17 @@ const NavButton = ( {
|
||||
);
|
||||
}
|
||||
|
||||
if ( icon === "notifications-bell" ) {
|
||||
return (
|
||||
<NotificationsIconContainer
|
||||
icon={icon}
|
||||
size={size}
|
||||
active={active}
|
||||
{...notificationProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<INatIconButton
|
||||
icon={icon}
|
||||
|
||||
84
src/navigation/BottomTabNavigator/NotificationsIcon.js
Normal file
84
src/navigation/BottomTabNavigator/NotificationsIcon.js
Normal file
@@ -0,0 +1,84 @@
|
||||
// @flow
|
||||
import { INatIcon, INatIconButton } from "components/SharedComponents";
|
||||
import {
|
||||
Pressable, View
|
||||
} from "components/styledComponents";
|
||||
import * as React from "react";
|
||||
import colors from "styles/tailwindColors";
|
||||
|
||||
type Props = {
|
||||
unread: boolean,
|
||||
icon: string,
|
||||
testID: string,
|
||||
onPress: any,
|
||||
active:boolean,
|
||||
accessibilityLabel: string,
|
||||
accessibilityRole?: string,
|
||||
accessibilityHint?: string,
|
||||
size: number,
|
||||
width?: number,
|
||||
height?: number
|
||||
};
|
||||
|
||||
const NotificationsIcon = ( {
|
||||
unread,
|
||||
testID,
|
||||
size,
|
||||
icon,
|
||||
onPress,
|
||||
active,
|
||||
accessibilityLabel,
|
||||
accessibilityHint,
|
||||
accessibilityRole = "tab",
|
||||
width,
|
||||
height
|
||||
}: Props ): React.Node => {
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
const sharedProps = {
|
||||
testID,
|
||||
onPress,
|
||||
accessibilityRole,
|
||||
accessibilityLabel,
|
||||
accessibilityHint,
|
||||
accessibilityState: {
|
||||
selected: active,
|
||||
expanded: active,
|
||||
disabled: false
|
||||
},
|
||||
width,
|
||||
height
|
||||
};
|
||||
|
||||
if ( unread ) {
|
||||
return (
|
||||
<Pressable
|
||||
className="flex items-center justify-center"
|
||||
{...sharedProps}
|
||||
>
|
||||
<INatIcon
|
||||
name={icon}
|
||||
color={active
|
||||
? colors.inatGreen
|
||||
: colors.darkGray}
|
||||
size={size}
|
||||
|
||||
/>
|
||||
<View className="bg-warningRed h-[10px] w-[10px] rounded-full absolute top-1 right-2.5" />
|
||||
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<INatIconButton
|
||||
icon={icon}
|
||||
color={active
|
||||
? colors.inatGreen
|
||||
: colors.darkGray}
|
||||
size={size}
|
||||
{...sharedProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationsIcon;
|
||||
@@ -0,0 +1,88 @@
|
||||
// @flow
|
||||
import { fetchUnviewedObservationUpdatesCount } from "api/observations";
|
||||
import NotificationsIcon from "navigation/BottomTabNavigator/NotificationsIcon";
|
||||
import type { Node } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
useAuthenticatedQuery,
|
||||
useCurrentUser,
|
||||
useInterval,
|
||||
useIsConnected
|
||||
} from "sharedHooks";
|
||||
import useStore from "stores/useStore";
|
||||
|
||||
type Props = {
|
||||
testID: string,
|
||||
icon: any,
|
||||
onPress: any,
|
||||
active:boolean,
|
||||
accessibilityLabel: string,
|
||||
accessibilityRole?: string,
|
||||
accessibilityHint?: string,
|
||||
size: number,
|
||||
width?: number,
|
||||
height?: number
|
||||
};
|
||||
|
||||
const NotificationsIconContainer = ( {
|
||||
testID,
|
||||
size,
|
||||
icon,
|
||||
onPress,
|
||||
active,
|
||||
accessibilityLabel,
|
||||
accessibilityHint,
|
||||
accessibilityRole = "tab",
|
||||
width,
|
||||
height
|
||||
}: Props ): Node => {
|
||||
const [hasUnread, setHasUnread] = useState( false );
|
||||
const [numFetchIntervals, setNumFetchIntervals] = useState( 0 );
|
||||
const currentUser = useCurrentUser( );
|
||||
const isOnline = useIsConnected( );
|
||||
const observationMarkedAsViewedAt = useStore( state => state.observationMarkedAsViewedAt );
|
||||
|
||||
const { data: unviewedUpdatesCount } = useAuthenticatedQuery(
|
||||
[
|
||||
"notificationsCount",
|
||||
// We want to check for notifications at a set interval, so this gets
|
||||
// bumped at that interval
|
||||
numFetchIntervals,
|
||||
// We want to check for notifications when the user views an
|
||||
// observation, because that might make the indicator go away
|
||||
observationMarkedAsViewedAt
|
||||
],
|
||||
optsWithAuth => fetchUnviewedObservationUpdatesCount( optsWithAuth ),
|
||||
{
|
||||
enabled: !!currentUser && !!isOnline
|
||||
}
|
||||
);
|
||||
|
||||
// Show icon when there are unread updates
|
||||
useEffect( () => {
|
||||
setHasUnread( unviewedUpdatesCount > 0 );
|
||||
}, [unviewedUpdatesCount] );
|
||||
|
||||
// Fetch new updates count every minute by changing the request key
|
||||
useInterval( () => {
|
||||
setNumFetchIntervals( numFetchIntervals + 1 );
|
||||
}, 60_000 );
|
||||
|
||||
return (
|
||||
<NotificationsIcon
|
||||
icon={icon}
|
||||
unread={hasUnread}
|
||||
active={active}
|
||||
size={size}
|
||||
testID={testID}
|
||||
onPress={onPress}
|
||||
accessibilityRole={accessibilityRole}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
accessibilityHint={accessibilityHint}
|
||||
width={width}
|
||||
height={height}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationsIconContainer;
|
||||
@@ -31,6 +31,7 @@ const taxonSearchTitle = () => <Heading4>{t( "SEARCH-TAXA" )}</Heading4>;
|
||||
const locationSearchTitle = () => <Heading4>{t( "SEARCH-LOCATION" )}</Heading4>;
|
||||
const userSearchTitle = () => <Heading4>{t( "SEARCH-USER" )}</Heading4>;
|
||||
const projectSearchTitle = () => <Heading4>{t( "SEARCH-PROJECT" )}</Heading4>;
|
||||
const notificationsTitle = ( ) => <Heading4>{t( "NOTIFICATIONS" )}</Heading4>;
|
||||
|
||||
const Stack = createNativeStackNavigator( );
|
||||
|
||||
@@ -52,6 +53,11 @@ const ObservationsStackNavigator = ( ): Node => (
|
||||
<Stack.Screen
|
||||
name="Notifications"
|
||||
component={NotificationsContainer}
|
||||
options={{
|
||||
...showHeader,
|
||||
headerTitle: notificationsTitle,
|
||||
headerTitleAlign: "center"
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ObsDetails"
|
||||
|
||||
21
src/navigation/navigationUtils.js
Normal file
21
src/navigation/navigationUtils.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createNavigationContainerRef } from "@react-navigation/native";
|
||||
|
||||
export const navigationRef = createNavigationContainerRef();
|
||||
|
||||
// Returns current active route
|
||||
export function getCurrentRoute() {
|
||||
if ( navigationRef.isReady() ) {
|
||||
// Get the root navigator state
|
||||
const rootState = navigationRef.getRootState();
|
||||
|
||||
// Find the active route in the navigation state
|
||||
let route = rootState.routes[rootState.index];
|
||||
while ( route.state && route.state.routes ) {
|
||||
route = route.state.routes[route.state.index];
|
||||
}
|
||||
|
||||
return route;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Realm } from "@realm/react";
|
||||
import uuid from "react-native-uuid";
|
||||
import { createObservedOnStringForUpload } from "sharedHelpers/dateAndTime";
|
||||
import { formatExifDateAsString, parseExif } from "sharedHelpers/parseExif";
|
||||
import safeRealmWrite from "sharedHelpers/safeRealmWrite";
|
||||
|
||||
import Application from "./Application";
|
||||
import Comment from "./Comment";
|
||||
@@ -103,7 +104,7 @@ class Observation extends Realm.Object {
|
||||
const obsToUpsert = observations.filter(
|
||||
obs => !Observation.isUnsyncedObservation( realm, obs )
|
||||
);
|
||||
realm?.write( ( ) => {
|
||||
safeRealmWrite( realm, ( ) => {
|
||||
obsToUpsert.forEach( obs => {
|
||||
realm.create(
|
||||
"Observation",
|
||||
@@ -111,7 +112,7 @@ class Observation extends Realm.Object {
|
||||
"modified"
|
||||
);
|
||||
} );
|
||||
} );
|
||||
}, "upserting remote observations in Observation" );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,12 +220,12 @@ class Observation extends Realm.Object {
|
||||
observationSounds
|
||||
};
|
||||
|
||||
realm?.write( ( ) => {
|
||||
safeRealmWrite( realm, ( ) => {
|
||||
// using 'modified' here for the case where a new observation has the same Taxon
|
||||
// as a previous observation; otherwise, realm will error out
|
||||
// also using modified for updating observations which were already saved locally
|
||||
realm?.create( "Observation", obsToSave, "modified" );
|
||||
} );
|
||||
realm.create( "Observation", obsToSave, "modified" );
|
||||
}, "saving local observation for upload in Observation" );
|
||||
return realm.objectForPrimaryKey( "Observation", obs.uuid );
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Realm } from "@realm/react";
|
||||
import { FileUpload } from "inaturalistjs";
|
||||
import uuid from "react-native-uuid";
|
||||
import safeRealmWrite from "sharedHelpers/safeRealmWrite";
|
||||
|
||||
import Photo from "./Photo";
|
||||
|
||||
@@ -90,9 +91,9 @@ class ObservationPhoto extends Realm.Object {
|
||||
// api v2, so just going to worry about deleting locally for now
|
||||
const obsPhotoToDelete = currentObservation?.observationPhotos.find( p => p.url === uri );
|
||||
if ( obsPhotoToDelete ) {
|
||||
realm?.write( ( ) => {
|
||||
safeRealmWrite( realm, ( ) => {
|
||||
realm?.delete( obsPhotoToDelete );
|
||||
} );
|
||||
}, "deleting remote observation photo in ObservationPhoto" );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,9 +103,9 @@ class ObservationPhoto extends Realm.Object {
|
||||
const obsPhotoToDelete = currentObservation?.observationPhotos
|
||||
.find( p => p.localFilePath === uri );
|
||||
if ( obsPhotoToDelete ) {
|
||||
realm?.write( ( ) => {
|
||||
safeRealmWrite( realm, ( ) => {
|
||||
realm?.delete( obsPhotoToDelete );
|
||||
} );
|
||||
}, "deleting local observation photo in ObservationPhoto" );
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Realm } from "@realm/react";
|
||||
import safeRealmWrite from "sharedHelpers/safeRealmWrite";
|
||||
|
||||
import Photo from "./Photo";
|
||||
|
||||
@@ -109,9 +110,9 @@ class Taxon extends Realm.Object {
|
||||
static saveRemoteTaxon = async ( remoteTaxon, realm ) => {
|
||||
if ( remoteTaxon ) {
|
||||
const localTaxon = Taxon.mapApiToRealm( remoteTaxon, realm );
|
||||
realm?.write( ( ) => {
|
||||
safeRealmWrite( realm, ( ) => {
|
||||
realm.create( "Taxon", localTaxon, "modified" );
|
||||
} );
|
||||
}, "saving remote taxon in Taxon" );
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
32
src/sharedHelpers/safeRealmWrite.js
Normal file
32
src/sharedHelpers/safeRealmWrite.js
Normal file
@@ -0,0 +1,32 @@
|
||||
// @flow
|
||||
|
||||
import { log } from "../../react-native-logs.config";
|
||||
|
||||
const logger = log.extend( "safeRealmWrite" );
|
||||
|
||||
// this is based on safeWrite from this github issue, but customized for
|
||||
// realmjs: https://stackoverflow.com/questions/39366182/the-realm-is-already-in-a-write-transaction
|
||||
|
||||
const safeRealmWrite = (
|
||||
realm: any,
|
||||
action: Function,
|
||||
description: string = "No description given"
|
||||
): any => {
|
||||
if ( realm.isInTransaction ) {
|
||||
logger.info( "realm is in transaction:", realm.isInTransaction );
|
||||
realm.cancelTransaction( );
|
||||
}
|
||||
// https://www.mongodb.com/docs/realm-sdks/react/latest/classes/Realm-1.html#beginTransaction.beginTransaction-1
|
||||
realm.beginTransaction( );
|
||||
try {
|
||||
logger.info( "writing to realm:", description );
|
||||
const response = action( );
|
||||
realm.commitTransaction( );
|
||||
return response;
|
||||
} catch ( e ) {
|
||||
logger.info( "couldn't write to realm: ", e );
|
||||
throw new Error( `${description}: ${e.message}` );
|
||||
}
|
||||
};
|
||||
|
||||
export default safeRealmWrite;
|
||||
@@ -10,6 +10,7 @@ import inatjs from "inaturalistjs";
|
||||
import Observation from "realmModels/Observation";
|
||||
import ObservationPhoto from "realmModels/ObservationPhoto";
|
||||
import emitUploadProgress from "sharedHelpers/emitUploadProgress";
|
||||
import safeRealmWrite from "sharedHelpers/safeRealmWrite";
|
||||
|
||||
const UPLOAD_PROGRESS_INCREMENT = 0.5;
|
||||
|
||||
@@ -28,10 +29,10 @@ const markRecordUploaded = ( observationUUID, recordUUID, type, response, realm
|
||||
}
|
||||
// TODO: add ObservationSound
|
||||
|
||||
realm?.write( ( ) => {
|
||||
safeRealmWrite( realm, ( ) => {
|
||||
record.id = id;
|
||||
record._synced_at = new Date( );
|
||||
} );
|
||||
}, "marking record uploaded in uploadObservation.js" );
|
||||
};
|
||||
|
||||
const uploadEvidence = async (
|
||||
|
||||
@@ -7,6 +7,7 @@ export { default as useIconicTaxa } from "./useIconicTaxa";
|
||||
export { default as useInfiniteNotificationsScroll } from "./useInfiniteNotificationsScroll";
|
||||
export { default as useInfiniteObservationsScroll } from "./useInfiniteObservationsScroll";
|
||||
export { default as useInfiniteScroll } from "./useInfiniteScroll";
|
||||
export { default as useInterval } from "./useInterval";
|
||||
export { default as useIsConnected } from "./useIsConnected";
|
||||
export { default as useLocalObservation } from "./useLocalObservation";
|
||||
export { default as useLocalObservations } from "./useLocalObservations";
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// @flow
|
||||
import { searchTaxa } from "api/taxa";
|
||||
import { RealmContext } from "providers/contexts";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import safeRealmWrite from "sharedHelpers/safeRealmWrite";
|
||||
import { useAuthenticatedQuery, useIsConnected } from "sharedHooks";
|
||||
|
||||
const { useRealm } = RealmContext;
|
||||
@@ -9,6 +10,7 @@ const { useRealm } = RealmContext;
|
||||
const useIconicTaxa = ( { reload }: Object ): Object => {
|
||||
const realm = useRealm( );
|
||||
const isConnected = useIsConnected( );
|
||||
const [isUpdatingRealm, setIsUpdatingRealm] = useState( );
|
||||
|
||||
const queryKey = ["searchTaxa", reload];
|
||||
const { data: iconicTaxa } = useAuthenticatedQuery(
|
||||
@@ -18,15 +20,18 @@ const useIconicTaxa = ( { reload }: Object ): Object => {
|
||||
);
|
||||
|
||||
useEffect( ( ) => {
|
||||
if ( iconicTaxa?.length > 0 ) {
|
||||
iconicTaxa.forEach( taxa => {
|
||||
taxa.isIconic = true;
|
||||
realm?.write( ( ) => {
|
||||
realm?.create( "Taxon", taxa, "modified" );
|
||||
if ( iconicTaxa?.length > 0 && !isUpdatingRealm ) {
|
||||
setIsUpdatingRealm( true );
|
||||
safeRealmWrite( realm, ( ) => {
|
||||
iconicTaxa.forEach( taxa => {
|
||||
realm.create( "Taxon", {
|
||||
...taxa,
|
||||
isIconic: true
|
||||
}, "modified" );
|
||||
} );
|
||||
} );
|
||||
}, "modifying iconic taxa in useIconicTaxa" );
|
||||
}
|
||||
}, [iconicTaxa, realm] );
|
||||
}, [iconicTaxa, realm, isUpdatingRealm] );
|
||||
|
||||
return realm?.objects( "Taxon" ).filtered( "isIconic = true" );
|
||||
};
|
||||
|
||||
@@ -4,34 +4,22 @@ import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { fetchObservationUpdates } from "api/observations";
|
||||
import { getJWT } from "components/LoginSignUp/AuthenticationService";
|
||||
import { flatten } from "lodash";
|
||||
import { log } from "sharedHelpers/logger";
|
||||
import { reactQueryRetry } from "sharedHelpers/logging";
|
||||
import { useCurrentUser } from "sharedHooks";
|
||||
|
||||
const logger = log.extend( "useInfiniteNotificationsScroll" );
|
||||
const BASE_PARAMS = {
|
||||
observations_by: "owner",
|
||||
fields: "all",
|
||||
per_page: 30,
|
||||
ttl: -1,
|
||||
page: 1
|
||||
};
|
||||
|
||||
const useInfiniteNotificationsScroll = ( ): Object => {
|
||||
const currentUser = useCurrentUser( );
|
||||
|
||||
// Request params for fetching unviewed updates
|
||||
const baseParams = {
|
||||
observations_by: "owner",
|
||||
fields: "all",
|
||||
per_page: 30,
|
||||
ttl: -1
|
||||
};
|
||||
|
||||
const queryKey = ["useInfiniteNotificationsScroll", "fetchNotifications"];
|
||||
|
||||
const {
|
||||
data: notifications,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
status,
|
||||
refetch
|
||||
} = useInfiniteQuery( {
|
||||
// eslint-disable-next-line
|
||||
queryKey,
|
||||
const infQueryResult = useInfiniteQuery( {
|
||||
queryKey: ["useInfiniteNotificationsScroll"],
|
||||
keepPreviousData: false,
|
||||
queryFn: async ( { pageParam } ) => {
|
||||
const apiToken = await getJWT( );
|
||||
@@ -39,35 +27,33 @@ const useInfiniteNotificationsScroll = ( ): Object => {
|
||||
api_token: apiToken
|
||||
};
|
||||
|
||||
const params = { ...BASE_PARAMS };
|
||||
|
||||
if ( pageParam ) {
|
||||
// $FlowIgnore
|
||||
baseParams.page = pageParam;
|
||||
params.page = pageParam;
|
||||
} else {
|
||||
// $FlowIgnore
|
||||
baseParams.page = 0;
|
||||
params.page = 1;
|
||||
}
|
||||
|
||||
const response = await fetchObservationUpdates( baseParams, options );
|
||||
const response = await fetchObservationUpdates( params, options );
|
||||
|
||||
return response;
|
||||
},
|
||||
getNextPageParam: ( lastPage, allPages ) => ( lastPage.length > 0
|
||||
? allPages.length + 1
|
||||
: undefined ),
|
||||
enabled: true,
|
||||
retry: ( failureCount, error ) => reactQueryRetry( failureCount, error, {
|
||||
beforeRetry: ( ) => logger.error( error )
|
||||
} )
|
||||
enabled: !!currentUser,
|
||||
retry: reactQueryRetry
|
||||
} );
|
||||
|
||||
return currentUser
|
||||
&& {
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
notifications: flatten( notifications?.pages ),
|
||||
status,
|
||||
refetch
|
||||
};
|
||||
return {
|
||||
...infQueryResult,
|
||||
// Disable fetchNextPage if signed out
|
||||
fetchNextPage: currentUser
|
||||
? infQueryResult.fetchNextPage
|
||||
: ( ) => { },
|
||||
notifications: flatten( infQueryResult?.data?.pages )
|
||||
};
|
||||
};
|
||||
|
||||
export default useInfiniteNotificationsScroll;
|
||||
|
||||
28
src/sharedHooks/useInterval.js
Normal file
28
src/sharedHooks/useInterval.js
Normal file
@@ -0,0 +1,28 @@
|
||||
// @flow
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
function useInterval( callback:Function, delay: number | null ) {
|
||||
const savedCallback = useRef<Function>( null );
|
||||
|
||||
// Remember the latest callback function
|
||||
useEffect( () => {
|
||||
if ( delay === null ) return;
|
||||
savedCallback.current = callback;
|
||||
}, [callback, delay] );
|
||||
|
||||
// Set up the interval
|
||||
useEffect( () => {
|
||||
function tick() {
|
||||
savedCallback.current();
|
||||
}
|
||||
if ( delay === null ) {
|
||||
return;
|
||||
}
|
||||
const id = setInterval( tick, delay );
|
||||
// eslint-disable-next-line consistent-return
|
||||
return () => clearInterval( id );
|
||||
}, [delay] );
|
||||
}
|
||||
|
||||
export default useInterval;
|
||||
@@ -1,6 +1,7 @@
|
||||
// @flow
|
||||
import { useNetInfo } from "@react-native-community/netinfo";
|
||||
|
||||
// Note that a return value of null means the state is unknown
|
||||
const useIsConnected = ( ): boolean => {
|
||||
const { isInternetReachable } = useNetInfo( );
|
||||
return isInternetReachable;
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
useEffect, useRef,
|
||||
useState
|
||||
} from "react";
|
||||
import Observation from "realmModels/Observation";
|
||||
|
||||
const { useRealm } = RealmContext;
|
||||
|
||||
@@ -17,7 +16,6 @@ const useLocalObservations = ( ): Object => {
|
||||
// views from rendering when they have focus.
|
||||
const stagedObservationList = useRef( [] );
|
||||
const [observationList, setObservationList] = useState( [] );
|
||||
const [allObsToUpload, setAllObsToUpload] = useState( [] );
|
||||
|
||||
const realm = useRealm( );
|
||||
|
||||
@@ -31,11 +29,8 @@ const useLocalObservations = ( ): Object => {
|
||||
localObservations.addListener( ( collection, _changes ) => {
|
||||
stagedObservationList.current = [...collection];
|
||||
|
||||
const unsyncedObs = Observation.filterUnsyncedObservations( realm );
|
||||
|
||||
if ( isFocused ) {
|
||||
setObservationList( stagedObservationList.current );
|
||||
setAllObsToUpload( unsyncedObs );
|
||||
}
|
||||
} );
|
||||
// eslint-disable-next-line consistent-return
|
||||
@@ -43,11 +38,10 @@ const useLocalObservations = ( ): Object => {
|
||||
// remember to remove listeners to avoid async updates
|
||||
localObservations?.removeAllListeners( );
|
||||
};
|
||||
}, [isFocused, allObsToUpload.length, realm] );
|
||||
}, [isFocused, realm] );
|
||||
|
||||
return {
|
||||
observationList,
|
||||
allObsToUpload
|
||||
observationList
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { RealmContext } from "providers/contexts";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { AppState } from "react-native";
|
||||
import safeRealmWrite from "sharedHelpers/safeRealmWrite";
|
||||
|
||||
const { useRealm } = RealmContext;
|
||||
|
||||
@@ -14,12 +15,13 @@ const useObservationUpdatesWhenFocused = () => {
|
||||
const observations = realm
|
||||
.objects( "Observation" )
|
||||
.filtered( "comments_viewed == false OR identifications_viewed == false" );
|
||||
realm?.write( () => {
|
||||
if ( observations.length === 0 ) { return; }
|
||||
safeRealmWrite( realm, () => {
|
||||
observations.forEach( observation => {
|
||||
observation.comments_viewed = true;
|
||||
observation.identifications_viewed = true;
|
||||
} );
|
||||
} );
|
||||
}, "setting comments_viewed and ids_viewed to true in useObservationsUpdatesWhenFocused" );
|
||||
}, [realm] );
|
||||
|
||||
const onAppStateChange = useCallback(
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { fetchObservationUpdates } from "api/observations";
|
||||
import { RealmContext } from "providers/contexts";
|
||||
import { useEffect } from "react";
|
||||
import safeRealmWrite from "sharedHelpers/safeRealmWrite";
|
||||
import { useAuthenticatedQuery, useIsConnected } from "sharedHooks";
|
||||
|
||||
const { useRealm } = RealmContext;
|
||||
@@ -59,45 +61,45 @@ const useObservationsUpdates = ( enabled: boolean ): Object => {
|
||||
]
|
||||
*/
|
||||
|
||||
// Looping through all unviewed updates
|
||||
const unviewed = data?.filter( result => result.viewed === false );
|
||||
unviewed?.forEach( update => {
|
||||
// Get the observation from local realm that matches the update's resource_uuid
|
||||
const existingObs = realm?.objectForPrimaryKey(
|
||||
"Observation",
|
||||
update.resource_uuid
|
||||
);
|
||||
if ( !existingObs ) {
|
||||
return;
|
||||
}
|
||||
// If both comments and identifications are already unviewed, nothing to do here
|
||||
if (
|
||||
existingObs.comments_viewed === false
|
||||
&& existingObs.identifications_viewed === false
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// If the update is a comment, set the observation's comments_viewed to false
|
||||
if (
|
||||
existingObs.comments_viewed || existingObs.comments_viewed === null
|
||||
) {
|
||||
if ( update.comment_id ) {
|
||||
realm?.write( () => {
|
||||
existingObs.comments_viewed = false;
|
||||
} );
|
||||
}
|
||||
}
|
||||
// If the update is an identification, set the observation's identifications_viewed to false
|
||||
if (
|
||||
existingObs.identifications_viewed || existingObs.identifications_viewed === null
|
||||
) {
|
||||
if ( update.identification_id ) {
|
||||
realm?.write( () => {
|
||||
existingObs.identifications_viewed = false;
|
||||
} );
|
||||
}
|
||||
}
|
||||
} );
|
||||
useEffect( ( ) => {
|
||||
// Looping through all unviewed updates
|
||||
const remoteUnviewed = data?.filter( result => result.viewed === false );
|
||||
safeRealmWrite( realm, ( ) => {
|
||||
remoteUnviewed?.forEach( update => {
|
||||
// Get the observation from local realm that matches the update's resource_uuid
|
||||
const existingObs = realm?.objectForPrimaryKey(
|
||||
"Observation",
|
||||
update.resource_uuid
|
||||
);
|
||||
if ( !existingObs ) {
|
||||
return;
|
||||
}
|
||||
// If both comments and identifications are already unviewed, nothing to do here
|
||||
if (
|
||||
existingObs.comments_viewed === false
|
||||
&& existingObs.identifications_viewed === false
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// If the update is a comment, set the observation's comments_viewed to false
|
||||
if (
|
||||
existingObs.comments_viewed || existingObs.comments_viewed === null
|
||||
) {
|
||||
if ( update.comment_id ) {
|
||||
existingObs.comments_viewed = false;
|
||||
}
|
||||
}
|
||||
// If the update is an identification, set the observation's identifications_viewed to false
|
||||
if (
|
||||
existingObs.identifications_viewed || existingObs.identifications_viewed === null
|
||||
) {
|
||||
if ( update.identification_id ) {
|
||||
existingObs.identifications_viewed = false;
|
||||
}
|
||||
}
|
||||
} );
|
||||
}, "setting comments and/or identifications false in useObservationsUpdates" );
|
||||
}, [data, realm] );
|
||||
|
||||
return { refetch };
|
||||
};
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
// @flow
|
||||
import { fetchUserMe } from "api/users";
|
||||
import useAuthenticatedQuery from "sharedHooks/useAuthenticatedQuery";
|
||||
import useCurrentUser from "sharedHooks/useCurrentUser";
|
||||
import useIsConnected from "sharedHooks/useIsConnected";
|
||||
import { RealmContext } from "providers/contexts";
|
||||
import { useEffect } from "react";
|
||||
import safeRealmWrite from "sharedHelpers/safeRealmWrite";
|
||||
import { useAuthenticatedQuery, useCurrentUser, useIsConnected } from "sharedHooks";
|
||||
|
||||
const useUserMe = ( ): Object => {
|
||||
const { useRealm } = RealmContext;
|
||||
|
||||
const useUserMe = ( options: ?Object ): Object => {
|
||||
const realm = useRealm( );
|
||||
const currentUser = useCurrentUser( );
|
||||
const updateRealm = options?.updateRealm;
|
||||
const isConnected = useIsConnected( );
|
||||
const enabled = !!isConnected && !!currentUser;
|
||||
|
||||
@@ -21,6 +26,19 @@ const useUserMe = ( ): Object => {
|
||||
}
|
||||
);
|
||||
|
||||
const userLocaleChanged = (
|
||||
!currentUser?.locale || ( remoteUser?.locale !== currentUser?.locale )
|
||||
)
|
||||
&& updateRealm;
|
||||
|
||||
useEffect( ( ) => {
|
||||
if ( userLocaleChanged && remoteUser ) {
|
||||
safeRealmWrite( realm, ( ) => {
|
||||
realm.create( "User", remoteUser, "modified" );
|
||||
}, "modifying current user via remote fetch in useUserMe" );
|
||||
}
|
||||
}, [realm, userLocaleChanged, remoteUser] );
|
||||
|
||||
return {
|
||||
remoteUser,
|
||||
isLoading,
|
||||
|
||||
@@ -54,6 +54,9 @@ const useStore = create( set => ( {
|
||||
galleryUris: [],
|
||||
groupedPhotos: [],
|
||||
observations: [],
|
||||
// Track when any obs was last marked as viewed so we know when to update
|
||||
// the notifications indicator
|
||||
observationMarkedAsViewedAt: null,
|
||||
originalCameraUrisMap: {},
|
||||
photoEvidenceUris: [],
|
||||
savingPhoto: false,
|
||||
@@ -99,6 +102,9 @@ const useStore = create( set => ( {
|
||||
setGroupedPhotos: photos => set( {
|
||||
groupedPhotos: photos
|
||||
} ),
|
||||
setObservationMarkedAsViewedAt: date => set( {
|
||||
observationMarkedAsViewedAt: date
|
||||
} ),
|
||||
setObservations: updatedObservations => set( state => ( {
|
||||
observations: updatedObservations,
|
||||
currentObservation: observationToJSON( updatedObservations[state.currentObservationIndex] )
|
||||
|
||||
@@ -3,6 +3,7 @@ import i18next from "i18next";
|
||||
import inatjs from "inaturalistjs";
|
||||
import nock from "nock";
|
||||
import RNSInfo from "react-native-sensitive-info";
|
||||
import safeRealmWrite from "sharedHelpers/safeRealmWrite";
|
||||
import { makeResponse } from "tests/factory";
|
||||
|
||||
const TEST_JWT = "test-json-web-token";
|
||||
@@ -12,9 +13,9 @@ async function signOut( options = {} ) {
|
||||
const realm = options.realm || global.realm;
|
||||
i18next.language = undefined;
|
||||
// This is the nuclear option, maybe revisit if it's a source of bugs
|
||||
realm?.write( ( ) => {
|
||||
safeRealmWrite( realm, ( ) => {
|
||||
realm.deleteAll( );
|
||||
} );
|
||||
}, "deleting entire realm in signOut function, user.js" );
|
||||
await RNSInfo.deleteItem( "username" );
|
||||
await RNSInfo.deleteItem( "jwtToken" );
|
||||
await RNSInfo.deleteItem( "jwtGeneratedAt" );
|
||||
@@ -30,9 +31,9 @@ async function signIn( user, options = {} ) {
|
||||
await RNSInfo.setItem( "accessToken", TEST_ACCESS_TOKEN );
|
||||
inatjs.users.me.mockResolvedValue( makeResponse( [user] ) );
|
||||
user.signedIn = true;
|
||||
realm?.write( ( ) => {
|
||||
safeRealmWrite( realm, ( ) => {
|
||||
realm.create( "User", user, "modified" );
|
||||
} );
|
||||
}, "signing user in, user.js" );
|
||||
nock( API_HOST )
|
||||
.post( "/oauth/token" )
|
||||
.reply( 200, { access_token: TEST_ACCESS_TOKEN } )
|
||||
|
||||
@@ -13,6 +13,7 @@ import path from "path";
|
||||
import React from "react";
|
||||
import Realm from "realm";
|
||||
import realmConfig from "realmModels/index";
|
||||
import safeRealmWrite from "sharedHelpers/safeRealmWrite";
|
||||
import factory, { makeResponse } from "tests/factory";
|
||||
import { renderAppWithComponent } from "tests/helpers/render";
|
||||
import { signIn, signOut } from "tests/helpers/user";
|
||||
@@ -123,9 +124,9 @@ describe( "MyObservations", ( ) => {
|
||||
} );
|
||||
|
||||
beforeEach( async ( ) => {
|
||||
global.mockRealms[__filename].write( ( ) => {
|
||||
safeRealmWrite( global.mockRealms[__filename], ( ) => {
|
||||
global.mockRealms[__filename].deleteAll( );
|
||||
} );
|
||||
}, "delete realm, MyObservations integration test when signed in" );
|
||||
await signIn( mockUser, { realm: global.mockRealms[__filename] } );
|
||||
} );
|
||||
|
||||
@@ -169,13 +170,13 @@ describe( "MyObservations", ( ) => {
|
||||
} )
|
||||
];
|
||||
|
||||
beforeEach( async () => {
|
||||
beforeEach( ( ) => {
|
||||
// Write local observation to Realm
|
||||
await global.mockRealms[__filename].write( () => {
|
||||
safeRealmWrite( global.mockRealms[__filename], ( ) => {
|
||||
mockObservations.forEach( mockObservation => {
|
||||
global.mockRealms[__filename].create( "Observation", mockObservation );
|
||||
} );
|
||||
} );
|
||||
}, "write local observation, MyObservations integration test with unsynced observations" );
|
||||
} );
|
||||
|
||||
afterEach( ( ) => {
|
||||
@@ -277,13 +278,13 @@ describe( "MyObservations", ( ) => {
|
||||
} )
|
||||
];
|
||||
|
||||
beforeEach( async () => {
|
||||
await global.mockRealms[__filename].write( () => {
|
||||
beforeEach( ( ) => {
|
||||
safeRealmWrite( global.mockRealms[__filename], ( ) => {
|
||||
global.mockRealms[__filename].deleteAll( );
|
||||
mockObservationsSynced.forEach( mockObservation => {
|
||||
global.mockRealms[__filename].create( "Observation", mockObservation );
|
||||
} );
|
||||
} );
|
||||
}, "delete all and create synced observations, MyObservations integration test" );
|
||||
} );
|
||||
|
||||
afterEach( ( ) => {
|
||||
@@ -321,12 +322,12 @@ describe( "MyObservations", ( ) => {
|
||||
} );
|
||||
|
||||
describe( "after initial sync", ( ) => {
|
||||
beforeEach( async () => {
|
||||
await global.mockRealms[__filename].write( () => {
|
||||
beforeEach( ( ) => {
|
||||
safeRealmWrite( global.mockRealms[__filename], ( ) => {
|
||||
global.mockRealms[__filename].create( "LocalPreferences", {
|
||||
last_sync_time: new Date( "2023-11-01" )
|
||||
} );
|
||||
} );
|
||||
}, "add last_sync_time to LocalPreferences, MyObservations integration test" );
|
||||
} );
|
||||
|
||||
it( "downloads deleted observations from server when sync button tapped", async ( ) => {
|
||||
@@ -357,12 +358,10 @@ describe( "MyObservations", ( ) => {
|
||||
expect( syncIcon ).toBeVisible( );
|
||||
} );
|
||||
fireEvent.press( syncIcon );
|
||||
const spy = jest.spyOn( global.mockRealms[__filename], "write" );
|
||||
const deleteSpy = jest.spyOn( global.mockRealms[__filename], "delete" );
|
||||
await waitFor( ( ) => {
|
||||
expect( spy ).toHaveBeenCalled( );
|
||||
expect( deleteSpy ).toHaveBeenCalledTimes( 1 );
|
||||
} );
|
||||
expect( deleteSpy ).toHaveBeenCalled( );
|
||||
expect( global.mockRealms[__filename].objects( "Observation" ).length ).toBe( 1 );
|
||||
} );
|
||||
} );
|
||||
@@ -370,10 +369,10 @@ describe( "MyObservations", ( ) => {
|
||||
} );
|
||||
|
||||
describe( "localization for current user", ( ) => {
|
||||
beforeEach( async ( ) => {
|
||||
await global.mockRealms[__filename].write( ( ) => {
|
||||
beforeEach( ( ) => {
|
||||
safeRealmWrite( global.mockRealms[__filename], ( ) => {
|
||||
global.mockRealms[__filename].deleteAll( );
|
||||
} );
|
||||
}, "delete all, MyObservations integration test, localization for current user" );
|
||||
} );
|
||||
|
||||
afterEach( ( ) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { renderHook } from "@testing-library/react-native";
|
||||
import safeRealmWrite from "sharedHelpers/safeRealmWrite";
|
||||
import { useCurrentUser } from "sharedHooks";
|
||||
import factory from "tests/factory";
|
||||
|
||||
@@ -9,10 +10,10 @@ const mockUser = factory( "LocalUser", {
|
||||
|
||||
describe( "useCurrentUser", () => {
|
||||
beforeEach( async ( ) => {
|
||||
// Write mock observations to realm
|
||||
await global.realm.write( () => {
|
||||
// Write mock user to realm
|
||||
safeRealmWrite( global.realm, ( ) => {
|
||||
global.realm.create( "User", mockUser );
|
||||
} );
|
||||
}, "create current user, useCurrentUser test" );
|
||||
} );
|
||||
|
||||
it( "should return current user", () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { renderHook } from "@testing-library/react-native";
|
||||
import safeRealmWrite from "sharedHelpers/safeRealmWrite";
|
||||
import useObservationUpdatesWhenFocused from "sharedHooks/useObservationUpdatesWhenFocused";
|
||||
import factory from "tests/factory";
|
||||
|
||||
@@ -16,13 +17,13 @@ const mockObservations = [
|
||||
];
|
||||
|
||||
describe( "useObservationUpdatesWhenFocused", () => {
|
||||
beforeAll( async () => {
|
||||
beforeAll( ( ) => {
|
||||
// Write mock observations to realm
|
||||
await global.realm.write( () => {
|
||||
safeRealmWrite( global.realm, ( ) => {
|
||||
mockObservations.forEach( o => {
|
||||
global.realm.create( "Observation", o );
|
||||
} );
|
||||
} );
|
||||
}, "write observations to realm, useObservationUpdatesWhenFocused test" );
|
||||
} );
|
||||
|
||||
it( "should reset state of all observations in realm", () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { renderHook } from "@testing-library/react-native";
|
||||
import safeRealmWrite from "sharedHelpers/safeRealmWrite";
|
||||
import useObservationsUpdates from "sharedHooks/useObservationsUpdates";
|
||||
import factory from "tests/factory";
|
||||
|
||||
@@ -38,11 +39,11 @@ describe( "useObservationsUpdates", ( ) => {
|
||||
} );
|
||||
|
||||
describe( "when there is no local observation with the resource_uuid", ( ) => {
|
||||
beforeEach( async ( ) => {
|
||||
beforeEach( ( ) => {
|
||||
// Write mock observation to realm
|
||||
await global.realm.write( () => {
|
||||
safeRealmWrite( global.realm, ( ) => {
|
||||
global.realm.create( "Observation", mockObservation );
|
||||
} );
|
||||
}, "write mock observation, useObservationUpdates test" );
|
||||
} );
|
||||
|
||||
it( "should return without writing to a local observation", ( ) => {
|
||||
@@ -66,16 +67,16 @@ describe( "useObservationsUpdates", ( ) => {
|
||||
["not viewed comments and viewed identifications", false, true],
|
||||
["not viewed comments and not viewed identifications", false, false]
|
||||
] )( "when the local observation has %s", ( a1, viewedComments, viewedIdentifications ) => {
|
||||
beforeEach( async ( ) => {
|
||||
beforeEach( ( ) => {
|
||||
// Write mock observation to realm
|
||||
await global.realm.write( () => {
|
||||
safeRealmWrite( global.realm, ( ) => {
|
||||
global.realm.deleteAll( );
|
||||
global.realm.create( "Observation", {
|
||||
...mockObservation,
|
||||
comments_viewed: viewedComments,
|
||||
identifications_viewed: viewedIdentifications
|
||||
} );
|
||||
} );
|
||||
}, "delete all and create observation, useObservationsUpdates test" );
|
||||
} );
|
||||
|
||||
it( "should write correct viewed status for comments and identifications", ( ) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { renderHook } from "@testing-library/react-native";
|
||||
import safeRealmWrite from "sharedHelpers/safeRealmWrite";
|
||||
import { useTaxon } from "sharedHooks";
|
||||
import factory from "tests/factory";
|
||||
|
||||
@@ -26,9 +27,9 @@ describe( "useTaxon", ( ) => {
|
||||
describe( "with local taxon", ( ) => {
|
||||
beforeEach( async ( ) => {
|
||||
// Write mock taxon to realm
|
||||
await global.realm.write( () => {
|
||||
safeRealmWrite( global.realm, ( ) => {
|
||||
global.realm.create( "Taxon", mockTaxon, "modified" );
|
||||
} );
|
||||
}, "write mock taxon, useTaxon test" );
|
||||
} );
|
||||
|
||||
it( "should return an object", ( ) => {
|
||||
@@ -47,9 +48,9 @@ describe( "useTaxon", ( ) => {
|
||||
|
||||
describe( "when there is no local taxon with taxon id", ( ) => {
|
||||
beforeEach( async ( ) => {
|
||||
await global.realm.write( ( ) => {
|
||||
safeRealmWrite( global.realm, ( ) => {
|
||||
global.realm.deleteAll( );
|
||||
} );
|
||||
}, "delete all realm, useTaxon test" );
|
||||
} );
|
||||
|
||||
it( "should make an API call and return passed in taxon when fetchRemote is enabled", ( ) => {
|
||||
|
||||
@@ -42,7 +42,8 @@ describe( "AddObsModal", ( ) => {
|
||||
fireEvent.press( noEvidenceButton );
|
||||
await waitFor( ( ) => {
|
||||
expect( mockNavigate ).toHaveBeenCalledWith( "CameraNavigator", {
|
||||
screen: "ObsEdit"
|
||||
screen: "ObsEdit",
|
||||
params: { previousScreen: null }
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
@@ -56,7 +57,7 @@ describe( "AddObsModal", ( ) => {
|
||||
fireEvent.press( arCameraButton );
|
||||
expect( mockNavigate ).toHaveBeenCalledWith( "CameraNavigator", {
|
||||
screen: "Camera",
|
||||
params: { camera: "AR" }
|
||||
params: { camera: "AR", previousScreen: null }
|
||||
} );
|
||||
} );
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { screen } from "@testing-library/react-native";
|
||||
import initI18next from "i18n/initI18next";
|
||||
import CustomTabBarContainer from "navigation/BottomTabNavigator/CustomTabBarContainer";
|
||||
import React from "react";
|
||||
import * as useCurrentUser from "sharedHooks/useCurrentUser";
|
||||
@@ -17,39 +18,54 @@ jest.mock( "sharedHooks/useCurrentUser", ( ) => ( {
|
||||
default: () => undefined
|
||||
} ) );
|
||||
|
||||
jest.mock( "sharedHooks/useAuthenticatedQuery", () => ( {
|
||||
__esModule: true,
|
||||
default: () => ( {
|
||||
data: 0
|
||||
} )
|
||||
} ) );
|
||||
|
||||
describe( "CustomTabBar", () => {
|
||||
it( "should render correctly", () => {
|
||||
beforeAll( async ( ) => {
|
||||
await initI18next( );
|
||||
} );
|
||||
|
||||
beforeEach( ( ) => {
|
||||
jest.useFakeTimers();
|
||||
} );
|
||||
|
||||
it( "should render correctly", async () => {
|
||||
renderComponent( <CustomTabBarContainer navigation={jest.fn( )} /> );
|
||||
|
||||
expect( screen ).toMatchSnapshot();
|
||||
await expect( screen ).toMatchSnapshot();
|
||||
} );
|
||||
|
||||
it( "should not have accessibility errors", () => {
|
||||
it( "should not have accessibility errors", async () => {
|
||||
const tabBar = <CustomTabBarContainer navigation={jest.fn( )} />;
|
||||
|
||||
expect( tabBar ).toBeAccessible();
|
||||
await expect( tabBar ).toBeAccessible();
|
||||
} );
|
||||
|
||||
it( "should display person icon while user is logged out", () => {
|
||||
it( "should display person icon while user is logged out", async () => {
|
||||
renderComponent( <CustomTabBarContainer navigation={jest.fn( )} isOnline /> );
|
||||
|
||||
const personIcon = screen.getByTestId( "NavButton.personIcon" );
|
||||
expect( personIcon ).toBeVisible( );
|
||||
await expect( personIcon ).toBeVisible( );
|
||||
} );
|
||||
|
||||
it( "should display avatar while user is logged in", () => {
|
||||
it( "should display avatar while user is logged in", async () => {
|
||||
jest.spyOn( useCurrentUser, "default" ).mockImplementation( () => mockUser );
|
||||
renderComponent( <CustomTabBarContainer navigation={jest.fn( )} isOnline /> );
|
||||
|
||||
const avatar = screen.getByTestId( "UserIcon.photo" );
|
||||
expect( avatar ).toBeVisible( );
|
||||
await expect( avatar ).toBeVisible( );
|
||||
} );
|
||||
|
||||
it( "should display person icon when connectivity is low", ( ) => {
|
||||
it( "should display person icon when connectivity is low", async ( ) => {
|
||||
jest.spyOn( useIsConnected, "default" ).mockImplementation( () => false );
|
||||
renderComponent( <CustomTabBarContainer navigation={jest.fn( )} isOnline={false} /> );
|
||||
|
||||
const personIcon = screen.getByTestId( "NavButton.personIcon" );
|
||||
expect( personIcon ).toBeVisible( );
|
||||
await expect( personIcon ).toBeVisible( );
|
||||
} );
|
||||
} );
|
||||
|
||||
@@ -57,8 +57,8 @@ exports[`CustomTabBar should render correctly 1`] = `
|
||||
}
|
||||
>
|
||||
<View
|
||||
accessibilityHint="Opens-the-side-drawer-menu"
|
||||
accessibilityLabel="Open-drawer"
|
||||
accessibilityHint="Opens the side drawer menu."
|
||||
accessibilityLabel="Open drawer"
|
||||
accessibilityRole="button"
|
||||
accessibilityState={
|
||||
{
|
||||
@@ -139,7 +139,7 @@ exports[`CustomTabBar should render correctly 1`] = `
|
||||
</View>
|
||||
</View>
|
||||
<View
|
||||
accessibilityHint="Navigates-to-explore"
|
||||
accessibilityHint="Navigates to explore."
|
||||
accessibilityLabel="Explore"
|
||||
accessibilityRole="button"
|
||||
accessibilityState={
|
||||
@@ -251,6 +251,8 @@ exports[`CustomTabBar should render correctly 1`] = `
|
||||
visible={false}
|
||||
/>
|
||||
<View
|
||||
accessibilityHint="Opens add observation modal."
|
||||
accessibilityLabel="Add observations"
|
||||
accessibilityRole="button"
|
||||
accessibilityState={
|
||||
{
|
||||
@@ -385,7 +387,7 @@ exports[`CustomTabBar should render correctly 1`] = `
|
||||
</BVLinearGradient>
|
||||
</View>
|
||||
<View
|
||||
accessibilityHint="Navigates-to-observations"
|
||||
accessibilityHint="Navigates to observations."
|
||||
accessibilityLabel="Observations"
|
||||
accessibilityRole="button"
|
||||
accessibilityState={
|
||||
|
||||
@@ -3,6 +3,7 @@ import { render, screen } from "@testing-library/react-native";
|
||||
import { DisplayTaxonName } from "components/SharedComponents";
|
||||
import initI18next from "i18n/initI18next";
|
||||
import React from "react";
|
||||
import safeRealmWrite from "sharedHelpers/safeRealmWrite";
|
||||
import factory from "tests/factory";
|
||||
|
||||
const capitalizeFirstLetter = s => s.charAt( 0 ).toUpperCase( ) + s.slice( 1 );
|
||||
@@ -158,7 +159,7 @@ describe( "DisplayTaxonName", ( ) => {
|
||||
describe( "when taxon is a Realm object", ( ) => {
|
||||
it( "fills in a missing genus rank from the rank_level", ( ) => {
|
||||
let taxon;
|
||||
global.realm.write( ( ) => {
|
||||
safeRealmWrite( global.realm, ( ) => {
|
||||
taxon = global.realm.create(
|
||||
"Taxon",
|
||||
{
|
||||
@@ -168,7 +169,7 @@ describe( "DisplayTaxonName", ( ) => {
|
||||
},
|
||||
"modified"
|
||||
);
|
||||
} );
|
||||
}, "create taxon, DisplayTaxonName test" );
|
||||
render( <DisplayTaxonName taxon={taxon} /> );
|
||||
expect( screen.getByText( /Genus/ ) ).toBeTruthy( );
|
||||
} );
|
||||
|
||||
46
tests/unit/components/Explore/TaxonGridItem.test.js
Normal file
46
tests/unit/components/Explore/TaxonGridItem.test.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
fireEvent,
|
||||
screen
|
||||
} from "@testing-library/react-native";
|
||||
import TaxonGridItem from "components/Explore/TaxonGridItem";
|
||||
import initI18next from "i18n/initI18next";
|
||||
import React from "react";
|
||||
import factory from "tests/factory";
|
||||
import { renderComponent } from "tests/helpers/render";
|
||||
|
||||
const mockTaxon = factory( "RemoteTaxon" );
|
||||
|
||||
const mockedNavigate = jest.fn( );
|
||||
|
||||
jest.mock( "@react-navigation/native", () => {
|
||||
const actualNav = jest.requireActual( "@react-navigation/native" );
|
||||
return {
|
||||
...actualNav,
|
||||
useNavigation: () => ( {
|
||||
navigate: mockedNavigate
|
||||
} )
|
||||
};
|
||||
} );
|
||||
|
||||
describe( "TaxonGridItem", ( ) => {
|
||||
beforeAll( async () => {
|
||||
await initI18next();
|
||||
} );
|
||||
|
||||
it( "should be accessible", ( ) => {
|
||||
const taxonGridItem = (
|
||||
<TaxonGridItem
|
||||
taxon={mockTaxon}
|
||||
/>
|
||||
);
|
||||
expect( taxonGridItem ).toBeAccessible();
|
||||
} );
|
||||
|
||||
it( "should navigate to user profile on tap", ( ) => {
|
||||
renderComponent( <TaxonGridItem
|
||||
taxon={mockTaxon}
|
||||
/> );
|
||||
fireEvent.press( screen.getByTestId( `TaxonGridItem.Pressable.${mockTaxon.id}` ) );
|
||||
expect( mockedNavigate ).toHaveBeenCalledWith( "TaxonDetails", { id: mockTaxon.id } );
|
||||
} );
|
||||
} );
|
||||
@@ -2,6 +2,7 @@ import { faker } from "@faker-js/faker";
|
||||
import { renderHook, waitFor } from "@testing-library/react-native";
|
||||
import useDeleteObservations from "components/MyObservations/hooks/useDeleteObservations";
|
||||
import initI18next from "i18n/initI18next";
|
||||
import safeRealmWrite from "sharedHelpers/safeRealmWrite";
|
||||
import factory from "tests/factory";
|
||||
|
||||
const mockMutate = jest.fn();
|
||||
@@ -36,11 +37,15 @@ describe( "handle deletions", ( ) => {
|
||||
|
||||
it( "should not make deletion API call for unsynced observations", async ( ) => {
|
||||
const deleteSpy = jest.spyOn( global.realm, "delete" );
|
||||
unsyncedObservations.forEach( observation => {
|
||||
global.realm.write( ( ) => {
|
||||
global.realm.create( "Observation", observation );
|
||||
} );
|
||||
} );
|
||||
safeRealmWrite(
|
||||
global.realm,
|
||||
( ) => {
|
||||
unsyncedObservations.forEach( observation => {
|
||||
global.realm.create( "Observation", observation );
|
||||
} );
|
||||
},
|
||||
"write unsyncedObservations, useDeleteObservations test"
|
||||
);
|
||||
|
||||
const unsyncedObservation = getLocalObservation(
|
||||
unsyncedObservations[0].uuid
|
||||
@@ -51,17 +56,20 @@ describe( "handle deletions", ( ) => {
|
||||
await waitFor( ( ) => {
|
||||
expect( mockMutate ).not.toHaveBeenCalled( );
|
||||
} );
|
||||
|
||||
expect( deleteSpy ).toHaveBeenCalled( );
|
||||
} );
|
||||
|
||||
it( "should make deletion API call for previously synced observations", async ( ) => {
|
||||
const deleteSpy = jest.spyOn( global.realm, "delete" );
|
||||
syncedObservations.forEach( observation => {
|
||||
global.realm.write( ( ) => {
|
||||
global.realm.create( "Observation", observation );
|
||||
} );
|
||||
} );
|
||||
safeRealmWrite(
|
||||
global.realm,
|
||||
( ) => {
|
||||
syncedObservations.forEach( observation => {
|
||||
global.realm.create( "Observation", observation );
|
||||
} );
|
||||
},
|
||||
"write syncedObservations, useDeleteObservations test"
|
||||
);
|
||||
|
||||
const syncedObservation = getLocalObservation( syncedObservations[0].uuid );
|
||||
expect( syncedObservation._synced_at ).not.toBeNull( );
|
||||
|
||||
@@ -25,6 +25,28 @@ const mockPhotos = _.compact(
|
||||
Array.from( mockObservation.observationPhotos ).map( op => op.photo )
|
||||
);
|
||||
|
||||
const expectedImageSource = [
|
||||
{
|
||||
height: 75,
|
||||
uri: mockObservation.observationPhotos[0].photo.url,
|
||||
width: 75
|
||||
},
|
||||
{
|
||||
height: 240,
|
||||
uri: mockObservation.observationPhotos[0].photo.url,
|
||||
width: 240
|
||||
}, {
|
||||
height: 500,
|
||||
uri: mockObservation.observationPhotos[0].photo.url,
|
||||
width: 500
|
||||
},
|
||||
{
|
||||
height: 1024,
|
||||
uri: mockObservation.observationPhotos[0].photo.url,
|
||||
width: 1024
|
||||
}
|
||||
];
|
||||
|
||||
describe( "ObsMedia", () => {
|
||||
beforeAll( async ( ) => {
|
||||
await initI18next( );
|
||||
@@ -39,20 +61,12 @@ describe( "ObsMedia", () => {
|
||||
it( "should show photo with given url", async () => {
|
||||
render( <ObsMedia photos={mockPhotos} tablet={false} /> );
|
||||
const photo = await screen.findByTestId( "ObsMedia.photo" );
|
||||
expect( photo.props.source ).toStrictEqual(
|
||||
{
|
||||
uri: mockObservation.observationPhotos[0].photo.url
|
||||
}
|
||||
);
|
||||
expect( photo.props.source ).toStrictEqual( expectedImageSource );
|
||||
} );
|
||||
|
||||
it( "should show photo with given url on tablet", async () => {
|
||||
render( <ObsMedia photos={mockPhotos} tablet /> );
|
||||
const photo = await screen.findByTestId( "ObsMedia.photo" );
|
||||
expect( photo.props.source ).toStrictEqual(
|
||||
{
|
||||
uri: mockObservation.observationPhotos[0].photo.url
|
||||
}
|
||||
);
|
||||
expect( photo.props.source ).toStrictEqual( expectedImageSource );
|
||||
} );
|
||||
} );
|
||||
|
||||
@@ -5,6 +5,7 @@ import initI18next from "i18n/initI18next";
|
||||
import i18next from "i18next";
|
||||
import inatjs from "inaturalistjs";
|
||||
import React from "react";
|
||||
import safeRealmWrite from "sharedHelpers/safeRealmWrite";
|
||||
import factory from "tests/factory";
|
||||
import { renderComponent } from "tests/helpers/render";
|
||||
|
||||
@@ -35,9 +36,9 @@ describe( "delete observation", ( ) => {
|
||||
beforeAll( async ( ) => {
|
||||
await initI18next( );
|
||||
|
||||
global.realm.write( ( ) => {
|
||||
safeRealmWrite( global.realm, ( ) => {
|
||||
global.realm.create( "Observation", currentObservation );
|
||||
} );
|
||||
}, "write Observation, DeleteObservationSheet test" );
|
||||
} );
|
||||
|
||||
describe( "add observation to deletion queue", ( ) => {
|
||||
@@ -58,9 +59,9 @@ describe( "delete observation", ( ) => {
|
||||
describe( "cancel deletion", ( ) => {
|
||||
it( "should not add _deleted_at date in realm", ( ) => {
|
||||
const localObservation = getLocalObservation( currentObservation.uuid );
|
||||
global.realm.write( ( ) => {
|
||||
safeRealmWrite( global.realm, ( ) => {
|
||||
localObservation._deleted_at = null;
|
||||
} );
|
||||
}, "set _deleted_at to null, DeleteObservationSheet test" );
|
||||
expect( localObservation ).toBeTruthy( );
|
||||
renderDeleteSheet( );
|
||||
const cancelButton = screen.queryByText( /CANCEL/ );
|
||||
|
||||
56
tests/unit/components/SharedComponents/UserListItem.test.js
Normal file
56
tests/unit/components/SharedComponents/UserListItem.test.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import { faker } from "@faker-js/faker";
|
||||
import {
|
||||
fireEvent,
|
||||
screen
|
||||
} from "@testing-library/react-native";
|
||||
import { UserListItem } from "components/SharedComponents";
|
||||
import initI18next from "i18n/initI18next";
|
||||
import React from "react";
|
||||
import factory from "tests/factory";
|
||||
import { renderComponent } from "tests/helpers/render";
|
||||
|
||||
const mockUser = factory( "RemoteUser", {
|
||||
login: "test123",
|
||||
id: faker.number.int( )
|
||||
} );
|
||||
|
||||
const mockedNavigate = jest.fn( );
|
||||
|
||||
jest.mock( "@react-navigation/native", () => {
|
||||
const actualNav = jest.requireActual( "@react-navigation/native" );
|
||||
return {
|
||||
...actualNav,
|
||||
useNavigation: () => ( {
|
||||
navigate: mockedNavigate
|
||||
} )
|
||||
};
|
||||
} );
|
||||
|
||||
describe( "UserListItem", ( ) => {
|
||||
beforeAll( async () => {
|
||||
await initI18next();
|
||||
} );
|
||||
|
||||
it( "should be accessible", ( ) => {
|
||||
const userListItem = (
|
||||
<UserListItem
|
||||
item={{
|
||||
user: mockUser
|
||||
}}
|
||||
count={3}
|
||||
countText="X-Observations"
|
||||
/>
|
||||
);
|
||||
expect( userListItem ).toBeAccessible();
|
||||
} );
|
||||
|
||||
it( "should navigate to user profile on tap", ( ) => {
|
||||
renderComponent( <UserListItem
|
||||
item={{
|
||||
user: mockUser
|
||||
}}
|
||||
/> );
|
||||
fireEvent.press( screen.getByTestId( `UserProfile.${mockUser.id}` ) );
|
||||
expect( mockedNavigate ).toHaveBeenCalledWith( "UserProfile", { userId: mockUser.id } );
|
||||
} );
|
||||
} );
|
||||
Reference in New Issue
Block a user