Merge branch 'main' of github.com:inaturalist/iNaturalistReactNative

This commit is contained in:
Angie Ta
2024-02-13 17:30:08 -08:00
97 changed files with 1448 additions and 745 deletions

View File

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

View File

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

View File

Binary file not shown.

View File

@@ -3,7 +3,7 @@
"data": [
{
"path": "assets/fonts/INatIcon.ttf",
"sha1": "495181444f9a2d8275f8bd86ff4988cd54e1fcb4"
"sha1": "bb88abe4cdee0812e5158400b1769607f685d310"
},
{
"path": "assets/fonts/Whitney Book Regular.otf",

View File

Binary file not shown.

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -86,6 +86,9 @@ const Explore = ( {
...exploreAPIParams,
per_page: 20
};
if ( exploreView === "observers" ) {
queryParams.order_by = "observation_count";
}
delete queryParams.taxon_name;
const paramsTotalResults = {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
] );

View File

@@ -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] );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -39,8 +39,7 @@ const AddEvidenceSheet = ( {
screen: "Camera",
params: {
addEvidence: true,
camera: "Standard",
backToObsEdit: true
camera: "Standard"
}
} );
} else if ( choice === "import" ) {

View File

@@ -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"
);

View File

@@ -122,8 +122,10 @@ const GroupPhotosContainer = ( ): Node => {
removedFromGroup.push( { photos: filteredGroupedPhotos } );
}
} );
// remove from group photos screen
setGroupedPhotos( removedFromGroup );
setSelectedObservations( [] );
};
const navToObsEdit = async ( ) => {

View File

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

View File

@@ -57,7 +57,7 @@ const RotatingINatIconButton = ( {
() => ( {
transform: [
{
rotateZ: `-${rotation.value}deg`
rotateZ: `${rotation.value}deg`
}
]
} ),

View File

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

View 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;

View File

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

View File

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

View File

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

View File

@@ -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( ( ) => (
<>

View File

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

View File

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

View File

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

View 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;

View 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;

View 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;

View 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;

View 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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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;

View File

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

View File

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

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

View File

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

View File

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

View File

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

View 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;

View File

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

View File

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

View File

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

View File

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

View 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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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", () => {

View File

@@ -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", () => {

View File

@@ -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", ( ) => {

View File

@@ -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", ( ) => {

View File

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

View File

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

View File

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

View File

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

View 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 } );
} );
} );

View File

@@ -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( );

View File

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

View File

@@ -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/ );

View 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 } );
} );
} );