diff --git a/.eslintrc.js b/.eslintrc.js
index 6d33aa3d9..bc0be437b 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -127,12 +127,13 @@ module.exports = {
"react-native-a11y/has-valid-accessibility-ignores-invert-colors": 1,
"react-native-a11y/has-valid-accessibility-live-region": 1,
"react-native-a11y/has-valid-important-for-accessibility": 1,
+ "no-shadow": "off",
+ "@typescript-eslint/no-shadow": "error",
// TODO: we should actually type these at some point ~amanda 041824
"@typescript-eslint/ban-types": 0,
"@typescript-eslint/no-unused-vars": 0,
"@typescript-eslint/no-var-requires": 0
-
},
// need this so jest doesn't show as undefined in jest.setup.js
env: {
diff --git a/package-lock.json b/package-lock.json
index 2d2660e30..a2514b7d0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10289,6 +10289,7 @@
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.1.tgz",
"integrity": "sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"array-includes": "^3.1.7",
"array.prototype.findlastindex": "^1.2.3",
diff --git a/src/components/MyObservations/MyObservations.js b/src/components/MyObservations/MyObservations.js
index 0d5d943b8..240a2db98 100644
--- a/src/components/MyObservations/MyObservations.js
+++ b/src/components/MyObservations/MyObservations.js
@@ -23,7 +23,6 @@ type Props = {
setShowLoginSheet: Function,
showLoginSheet: boolean,
status: string,
- syncInProgress: boolean,
toggleLayout: Function
};
@@ -39,7 +38,6 @@ const MyObservations = ( {
setShowLoginSheet,
showLoginSheet,
status,
- syncInProgress,
toggleLayout
}: Props ): Node => (
<>
@@ -53,7 +51,6 @@ const MyObservations = ( {
layout={layout}
logInButtonNeutral={observations.length === 0}
setHeightAboveToolbar={setStickyAt}
- syncInProgress={syncInProgress}
toggleLayout={toggleLayout}
/>
)}
diff --git a/src/components/MyObservations/MyObservationsContainer.js b/src/components/MyObservations/MyObservationsContainer.js
index d561d8eeb..2f8c9c325 100644
--- a/src/components/MyObservations/MyObservationsContainer.js
+++ b/src/components/MyObservations/MyObservationsContainer.js
@@ -1,19 +1,14 @@
// @flow
-import { activateKeepAwake, deactivateKeepAwake } from "@sayem314/react-native-keep-awake";
-import { searchObservations } from "api/observations";
-import { getJWT } from "components/LoginSignUp/AuthenticationService";
+import { useNavigation } from "@react-navigation/native";
import { RealmContext } from "providers/contexts";
import type { Node } from "react";
import React, {
- useCallback,
- useReducer, useState
+ useCallback, useEffect,
+ useState
} from "react";
import { Alert } from "react-native";
-import Observation from "realmModels/Observation";
import { log } from "sharedHelpers/logger";
-import safeRealmWrite from "sharedHelpers/safeRealmWrite";
-import { sleep } from "sharedHelpers/util";
import {
useCurrentUser,
useInfiniteObservationsScroll,
@@ -24,9 +19,7 @@ import {
useTranslation
} from "sharedHooks";
import {
- UPLOAD_COMPLETE,
- UPLOAD_IN_PROGRESS,
- UPLOAD_PENDING
+ UPLOAD_IN_PROGRESS
} from "stores/createUploadObservationsSlice.ts";
import useStore from "stores/useStore";
@@ -34,37 +27,16 @@ import useClearGalleryPhotos from "./hooks/useClearGalleryPhotos";
import useClearRotatedOriginalPhotos from "./hooks/useClearRotatedOriginalPhotos";
import useClearSyncedPhotosForUpload from "./hooks/useClearSyncedPhotosForUpload";
import useClearSyncedSoundsForUpload from "./hooks/useClearSyncedSoundsForUpload";
-import useDeleteObservations from "./hooks/useDeleteObservations";
+import useSyncObservations from "./hooks/useSyncObservations";
import useUploadObservations from "./hooks/useUploadObservations";
import MyObservations from "./MyObservations";
const logger = log.extend( "MyObservationsContainer" );
-export const INITIAL_STATE = {
- canBeginDeletions: true,
- syncInProgress: false
-};
-
-const reducer = ( state: Object, action: Function ): Object => {
- switch ( action.type ) {
- case "SET_START_DELETIONS":
- return {
- ...state,
- canBeginDeletions: false
- };
- case "START_SYNC":
- return {
- ...state,
- syncInProgress: true
- };
- default:
- return state;
- }
-};
-
const { useRealm } = RealmContext;
const MyObservationsContainer = ( ): Node => {
+ const navigation = useNavigation( );
// clear original, large-sized photos before a user returns to any of the Camera or AICamera flows
useClearRotatedOriginalPhotos( );
useClearGalleryPhotos( );
@@ -72,26 +44,27 @@ const MyObservationsContainer = ( ): Node => {
useClearSyncedSoundsForUpload( );
const { t } = useTranslation( );
const realm = useRealm( );
- const resetUploadObservationsSlice = useStore( state => state?.resetUploadObservationsSlice );
- const uploadStatus = useStore( state => state.uploadStatus );
const setUploadStatus = useStore( state => state.setUploadStatus );
- const numUnuploadedObservations = useStore( state => state.numUnuploadedObservations );
const addToUploadQueue = useStore( state => state.addToUploadQueue );
const addTotalToolbarIncrements = useStore( state => state.addTotalToolbarIncrements );
- const setTotalToolbarIncrements = useStore( state => state.setTotalToolbarIncrements );
- const [state, dispatch] = useReducer( reducer, INITIAL_STATE );
- const { observationList: observations, unsyncedUuids } = useLocalObservations( );
- const { layout, writeLayoutToStorage } = useStoredLayout( "myObservationsLayout" );
+ const syncingStatus = useStore( state => state.syncingStatus );
+ const resetSyncObservationsSlice
+ = useStore( state => state.resetSyncObservationsSlice );
+ const startManualSync = useStore( state => state.startManualSync );
+ const startAutomaticSync = useStore( state => state.startAutomaticSync );
- const deletionsCompletedAt = useStore( s => s.deletionsCompletedAt );
+ const { observationList: observations } = useLocalObservations( );
+ const { layout, writeLayoutToStorage } = useStoredLayout( "myObservationsLayout" );
const isOnline = useIsConnected( );
const currentUser = useCurrentUser( );
+ const currentUserId = currentUser?.id;
const canUpload = currentUser && isOnline;
- useDeleteObservations(
- currentUser?.id && state.canBeginDeletions,
- dispatch
+ const { uploadObservations } = useUploadObservations( canUpload );
+ useSyncObservations(
+ currentUserId,
+ uploadObservations
);
useObservationsUpdates( !!currentUser );
@@ -104,7 +77,7 @@ const MyObservationsContainer = ( ): Node => {
} = useInfiniteObservationsScroll( {
upsert: true,
params: {
- user_id: currentUser?.id
+ user_id: currentUserId
}
} );
@@ -116,156 +89,74 @@ const MyObservationsContainer = ( ): Node => {
: "grid" );
};
- const showInternetErrorAlert = useCallback( ( ) => {
+ const confirmInternetConnection = useCallback( ( ) => {
if ( !isOnline ) {
Alert.alert(
t( "Internet-Connection-Required" ),
t( "Please-try-again-when-you-are-connected-to-the-internet" )
);
}
+ return isOnline;
}, [t, isOnline] );
- const toggleLoginSheet = useCallback( ( ) => {
+ const confirmLoggedIn = useCallback( ( ) => {
if ( !currentUser ) {
setShowLoginSheet( true );
}
+ return currentUser;
}, [currentUser] );
- const startUpload = useCallback( ( ) => {
- toggleLoginSheet( );
- showInternetErrorAlert( );
- if ( canUpload ) {
- setUploadStatus( UPLOAD_IN_PROGRESS );
- } else {
- setUploadStatus( UPLOAD_PENDING );
- }
- }, [
- canUpload,
- setUploadStatus,
- showInternetErrorAlert,
- toggleLoginSheet
- ] );
-
- const {
- syncInProgress
- } = state;
-
- useUploadObservations( );
-
- const downloadRemoteObservationsFromServer = useCallback( async ( ) => {
- const apiToken = await getJWT( );
- const searchParams = {
- user_id: currentUser?.id,
- per_page: 50,
- fields: Observation.FIELDS,
- ttl: -1
- };
- // Between elasticsearch update time and API caches, there's no absolute
- // guarantee fetching observations won't include something we just
- // deleted, so we check to see if deletions recently completed and if
- // they did, make sure 10s have elapsed since deletions complated before
- // fetching new obs
- if ( deletionsCompletedAt ) {
- const msSinceDeletionsCompleted = ( new Date( ) - deletionsCompletedAt );
- if ( msSinceDeletionsCompleted < 5_000 ) {
- const naptime = 10_000 - msSinceDeletionsCompleted;
- logger.info(
- "downloadRemoteObservationsFromServer finished deleting "
- + `recently deleted, waiting ${naptime} ms`
- );
- await sleep( naptime );
- }
- }
- logger.info(
- "downloadRemoteObservationsFromServer, fetching observations"
- );
- const { results } = await searchObservations( searchParams, { api_token: apiToken } );
- logger.info(
- "downloadRemoteObservationsFromServer, fetched",
- results.length,
- "results, upserting..."
- );
- Observation.upsertRemoteObservations( results, realm );
- }, [
- currentUser,
- deletionsCompletedAt,
- realm
- ] );
-
- const updateSyncTime = useCallback( ( ) => {
- 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 ( ) => {
- if ( uploadStatus === UPLOAD_COMPLETE ) {
- resetUploadObservationsSlice( );
- }
- if ( !currentUser ) {
- toggleLoginSheet( );
- resetUploadObservationsSlice( );
- return;
- }
- if ( !isOnline ) {
- showInternetErrorAlert( );
- resetUploadObservationsSlice( );
- return;
- }
- dispatch( { type: "START_SYNC" } );
- activateKeepAwake( );
-
- await downloadRemoteObservationsFromServer( );
- updateSyncTime( );
- deactivateKeepAwake( );
- resetUploadObservationsSlice( );
- }, [
- currentUser,
- downloadRemoteObservationsFromServer,
- isOnline,
- showInternetErrorAlert,
- toggleLoginSheet,
- updateSyncTime,
- uploadStatus,
- resetUploadObservationsSlice
- ] );
-
const handleSyncButtonPress = useCallback( ( ) => {
- if ( numUnuploadedObservations > 0 ) {
- const uuidsQuery = unsyncedUuids.map( uploadUuid => `'${uploadUuid}'` ).join( ", " );
- const uploads = realm.objects( "Observation" )
- .filtered( `uuid IN { ${uuidsQuery} }` );
- setTotalToolbarIncrements( uploads );
- addToUploadQueue( unsyncedUuids );
- startUpload( );
- } else {
- syncObservations( );
- }
+ logger.debug( "Manual sync starting: user tapped sync button" );
+ if ( !confirmLoggedIn( ) ) { return; }
+ if ( !confirmInternetConnection( ) ) { return; }
+
+ startManualSync( );
}, [
- addToUploadQueue,
- startUpload,
- numUnuploadedObservations,
- realm,
- setTotalToolbarIncrements,
- syncObservations,
- unsyncedUuids
+ startManualSync,
+ confirmInternetConnection,
+ confirmLoggedIn
] );
const handleIndividualUploadPress = useCallback( uuid => {
+ logger.debug( "Starting individual upload:", uuid );
+ if ( !confirmLoggedIn( ) ) { return; }
+ if ( !confirmInternetConnection( ) ) { return; }
const observation = realm.objectForPrimaryKey( "Observation", uuid );
addTotalToolbarIncrements( observation );
addToUploadQueue( uuid );
- startUpload( );
+ setUploadStatus( UPLOAD_IN_PROGRESS );
}, [
- addToUploadQueue,
+ confirmLoggedIn,
+ confirmInternetConnection,
+ realm,
addTotalToolbarIncrements,
- startUpload,
- realm
+ addToUploadQueue,
+ setUploadStatus
+ ] );
+
+ useEffect( ( ) => {
+ // this is intended to have the automatic sync run once
+ // the very first time a user lands on MyObservations
+ startAutomaticSync( );
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [] );
+
+ useEffect( ( ) => {
+ // this is intended to have the automatic sync run once
+ // every time a user lands on MyObservations from a different tab screen.
+ // ideally, we wouldn't need both this and the useEffect hook above
+ navigation.addListener( "focus", ( ) => {
+ startAutomaticSync( );
+ } );
+ navigation.addListener( "blur", ( ) => {
+ resetSyncObservationsSlice( );
+ } );
+ }, [
+ navigation,
+ startAutomaticSync,
+ syncingStatus,
+ resetSyncObservationsSlice
] );
if ( !layout ) { return null; }
@@ -289,7 +180,6 @@ const MyObservationsContainer = ( ): Node => {
setShowLoginSheet={setShowLoginSheet}
showLoginSheet={showLoginSheet}
status={observationListStatus}
- syncInProgress={syncInProgress}
toggleLayout={toggleLayout}
/>
);
diff --git a/src/components/MyObservations/MyObservationsHeader.js b/src/components/MyObservations/MyObservationsHeader.js
index 27977fd96..91abe9933 100644
--- a/src/components/MyObservations/MyObservationsHeader.js
+++ b/src/components/MyObservations/MyObservationsHeader.js
@@ -25,7 +25,6 @@ type Props = {
layout: string,
logInButtonNeutral: boolean,
setHeightAboveToolbar: Function,
- syncInProgress: boolean,
toggleLayout: Function
}
@@ -36,7 +35,6 @@ const MyObservationsHeader = ( {
layout,
logInButtonNeutral,
setHeightAboveToolbar,
- syncInProgress,
toggleLayout
}: Props ): Node => {
const numUnuploadedObservations = useStore( state => state.numUnuploadedObservations );
@@ -125,7 +123,6 @@ const MyObservationsHeader = ( {
)}
diff --git a/src/components/MyObservations/ToolbarContainer.js b/src/components/MyObservations/ToolbarContainer.js
index 05ab784f0..441c641eb 100644
--- a/src/components/MyObservations/ToolbarContainer.js
+++ b/src/components/MyObservations/ToolbarContainer.js
@@ -9,6 +9,9 @@ import {
useCurrentUser,
useTranslation
} from "sharedHooks";
+import {
+ SYNC_PENDING
+} from "stores/createSyncObservationsSlice.ts";
import {
UPLOAD_COMPLETE,
UPLOAD_IN_PROGRESS,
@@ -20,33 +23,32 @@ import Toolbar from "./Toolbar";
const screenWidth = Dimensions.get( "window" ).width * PixelRatio.get( );
+const DELETION_STARTED_PROGRESS = 0.25;
+
type Props = {
handleSyncButtonPress: Function,
layout: string,
- syncInProgress: boolean,
toggleLayout: Function
}
const ToolbarContainer = ( {
handleSyncButtonPress,
layout,
- syncInProgress,
toggleLayout
}: Props ): Node => {
const setExploreView = useStore( state => state.setExploreView );
const currentUser = useCurrentUser( );
const navigation = useNavigation( );
- const deletions = useStore( state => state.deletions );
- const deletionsComplete = useStore( state => state.deletionsComplete );
const currentDeleteCount = useStore( state => state.currentDeleteCount );
const deleteError = useStore( state => state.deleteError );
- const deletionsInProgress = useStore( state => state.deletionsInProgress );
const uploadMultiError = useStore( state => state.multiError );
const uploadErrorsByUuid = useStore( state => state.errorsByUuid );
const initialNumObservationsInQueue = useStore( state => state.initialNumObservationsInQueue );
const numUnuploadedObservations = useStore( state => state.numUnuploadedObservations );
const totalToolbarProgress = useStore( state => state.totalToolbarProgress );
const uploadStatus = useStore( state => state.uploadStatus );
+ const syncingStatus = useStore( state => state.syncingStatus );
+ const initialNumDeletionsInQueue = useStore( state => state.initialNumDeletionsInQueue );
const stopAllUploads = useStore( state => state.stopAllUploads );
const numUploadsAttempted = useStore( state => state.numUploadsAttempted );
@@ -62,18 +64,6 @@ const ToolbarContainer = ( {
numUploadsAttempted
] );
- const totalDeletions = deletions.length;
- const deletionsProgress = totalDeletions > 0
- ? currentDeleteCount / totalDeletions
- : 0;
- const deletionParams = useMemo( ( ) => ( {
- total: totalDeletions,
- currentDeleteCount
- } ), [
- totalDeletions,
- currentDeleteCount
- ] );
-
const navToExplore = useCallback(
( ) => {
setExploreView( "observations" );
@@ -89,26 +79,49 @@ const ToolbarContainer = ( {
const { t } = useTranslation( );
const theme = useTheme( );
+ const deletionsComplete = initialNumDeletionsInQueue === currentDeleteCount;
+ const deletionsInProgress = initialNumDeletionsInQueue > 0 && !deletionsComplete;
+
+ const syncInProgress = syncingStatus !== SYNC_PENDING;
const pendingUpload = uploadStatus === UPLOAD_PENDING && numUnuploadedObservations > 0;
const uploadInProgress = uploadStatus === UPLOAD_IN_PROGRESS && numUploadsAttempted > 0;
const uploadsComplete = uploadStatus === UPLOAD_COMPLETE && initialNumObservationsInQueue > 0;
const totalUploadErrors = Object.keys( uploadErrorsByUuid ).length;
+ const setDeletionsProgress = ( ) => {
+ // TODO: we should emit deletions progress like we do for uploads for an accurate progress
+ // right now, a user can only delete a single local upload at a time from ObsEdit
+ // so we don't need a more robust count here (20240607)
+ if ( initialNumDeletionsInQueue === 0 ) {
+ return 0;
+ }
+ if ( !deletionsComplete ) {
+ return currentDeleteCount * DELETION_STARTED_PROGRESS;
+ }
+ return 1;
+ };
+ const deletionsProgress = setDeletionsProgress( );
+
const showFinalUploadError = ( totalUploadErrors > 0 && uploadsComplete )
- || ( totalUploadErrors > 0 && ( numUploadsAttempted === initialNumObservationsInQueue ) );
+ || ( totalUploadErrors > 0 && ( numUploadsAttempted === initialNumObservationsInQueue ) );
const rotating = syncInProgress || uploadInProgress || deletionsInProgress;
const showsCheckmark = ( uploadsComplete && !uploadMultiError )
- || ( deletionsComplete && !deleteError );
+ || ( deletionsComplete && !deleteError && initialNumDeletionsInQueue > 0 );
const showsExclamation = pendingUpload || showFinalUploadError;
const getStatusText = useCallback( ( ) => {
if ( syncInProgress ) { return t( "Syncing" ); }
- if ( totalDeletions > 0 ) {
+ const deletionParams = {
+ total: initialNumDeletionsInQueue,
+ currentDeleteCount
+ };
+
+ if ( initialNumDeletionsInQueue > 0 ) {
if ( deletionsComplete ) {
- return t( "X-observations-deleted", { count: totalDeletions } );
+ return t( "X-observations-deleted", { count: initialNumDeletionsInQueue } );
}
// iPhone 4 pixel width
return screenWidth <= 640
@@ -133,14 +146,14 @@ const ToolbarContainer = ( {
return "";
}, [
- deletionParams,
+ currentDeleteCount,
deletionsComplete,
+ initialNumDeletionsInQueue,
numUploadsAttempted,
numUnuploadedObservations,
pendingUpload,
syncInProgress,
t,
- totalDeletions,
translationParams,
uploadInProgress,
uploadsComplete
@@ -187,7 +200,7 @@ const ToolbarContainer = ( {
handleSyncButtonPress={handleSyncButtonPress}
layout={layout}
navToExplore={navToExplore}
- progress={deletionsProgress || totalToolbarProgress}
+ progress={totalToolbarProgress || deletionsProgress}
rotating={rotating}
showsCancelUploadButton={uploadInProgress}
showsCheckmark={showsCheckmark}
diff --git a/src/components/MyObservations/helpers/filterLocalObservationsToDelete.ts b/src/components/MyObservations/helpers/filterLocalObservationsToDelete.ts
deleted file mode 100644
index 557515990..000000000
--- a/src/components/MyObservations/helpers/filterLocalObservationsToDelete.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-export default filterLocalObservationsToDelete = realm => {
- // currently sorting so oldest observations to delete are first
- const observationsFlaggedForDeletion = realm
- .objects( "Observation" ).filtered( "_deleted_at != nil" ).sorted( "_deleted_at", false );
-
- return observationsFlaggedForDeletion.map( obs => obs.toJSON( ) );
-};
diff --git a/src/components/MyObservations/helpers/syncRemoteObservations.ts b/src/components/MyObservations/helpers/syncRemoteObservations.ts
new file mode 100644
index 000000000..2b8839a50
--- /dev/null
+++ b/src/components/MyObservations/helpers/syncRemoteObservations.ts
@@ -0,0 +1,56 @@
+import { searchObservations } from "api/observations";
+import { getJWT } from "components/LoginSignUp/AuthenticationService";
+import Observation from "realmModels/Observation";
+import { log } from "sharedHelpers/logger";
+import safeRealmWrite from "sharedHelpers/safeRealmWrite";
+import { sleep } from "sharedHelpers/util";
+
+const logger = log.extend( "syncRemoteObservations.ts" );
+
+const updateSyncTime = realm => {
+ 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 syncRemoteObservations" );
+};
+
+export default syncRemoteObservations = async ( realm, currentUserId, deletionsCompletedAt ) => {
+ const apiToken = await getJWT( );
+ const searchParams = {
+ user_id: currentUserId,
+ per_page: 50,
+ fields: Observation.LIST_FIELDS,
+ ttl: -1
+ };
+ // Between elasticsearch update time and API caches, there's no absolute
+ // guarantee fetching observations won't include something we just
+ // deleted, so we check to see if deletions recently completed and if
+ // they did, make sure 10s have elapsed since deletions complated before
+ // fetching new obs
+ if ( deletionsCompletedAt ) {
+ const msSinceDeletionsCompleted = ( new Date( ) - deletionsCompletedAt );
+ if ( msSinceDeletionsCompleted < 5_000 ) {
+ const naptime = 10_000 - msSinceDeletionsCompleted;
+ logger.debug(
+ "downloadRemoteObservationsFromServer finished deleting "
+ + `recently deleted, waiting ${naptime} ms`
+ );
+ await sleep( naptime );
+ }
+ }
+ logger.debug(
+ "downloadRemoteObservationsFromServer, fetching observations"
+ );
+ const { results } = await searchObservations( searchParams, { api_token: apiToken } );
+ logger.debug(
+ "downloadRemoteObservationsFromServer, fetched",
+ results.length,
+ "results, upserting..."
+ );
+ updateSyncTime( realm );
+ await Observation.upsertRemoteObservations( results, realm );
+};
diff --git a/src/components/MyObservations/hooks/useDeleteObservations.ts b/src/components/MyObservations/hooks/useDeleteObservations.ts
deleted file mode 100644
index 02b08cb8a..000000000
--- a/src/components/MyObservations/hooks/useDeleteObservations.ts
+++ /dev/null
@@ -1,177 +0,0 @@
-import { useNavigation } from "@react-navigation/native";
-import { INatApiError } from "api/error";
-import { deleteRemoteObservation } from "api/observations";
-import { RealmContext } from "providers/contexts";
-import { useCallback, useEffect } from "react";
-import Observation from "realmModels/Observation";
-import { log } from "sharedHelpers/logger";
-import { useAuthenticatedMutation } from "sharedHooks";
-import useStore from "stores/useStore";
-
-import filterLocalObservationsToDelete from "../helpers/filterLocalObservationsToDelete";
-import syncRemoteDeletedObservations from "../helpers/syncRemoteDeletedObservations";
-
-const logger = log.extend( "useDeleteObservations" );
-
-const { useRealm } = RealmContext;
-
-const useDeleteObservations = ( canBeginDeletions, myObservationsDispatch ): Object => {
- const deletions = useStore( state => state.deletions );
- const deletionsComplete = useStore( state => state.deletionsComplete );
- const deletionsInProgress = useStore( state => state.deletionsInProgress );
- const currentDeleteCount = useStore( state => state.currentDeleteCount );
- const error = useStore( state => state.deleteError );
- const finishDeletions = useStore( state => state.finishDeletions );
- const resetDeleteObservationsSlice = useStore( state => state.resetDeleteObservationsSlice );
- const setDeletions = useStore( state => state.setDeletions );
- const startNextDeletion = useStore( state => state.startNextDeletion );
- const setDeletionError = useStore( state => state.setDeletionError );
-
- const realm = useRealm( );
- const navigation = useNavigation( );
-
- const observationToDelete = deletions[currentDeleteCount - 1];
- const uuid = observationToDelete?.uuid;
-
- const canStartDeletingLocalObservations = deletions.length > 0
- && !deletionsInProgress
- && !deletionsComplete;
-
- const deleteLocalObservation = useCallback( async ( ) => {
- await Observation.deleteLocalObservation( realm, uuid );
- return true;
- }, [realm, uuid] );
-
- const deleteObservationMutation = useAuthenticatedMutation(
- ( params, optsWithAuth ) => deleteRemoteObservation( params, optsWithAuth ),
- {
- onSuccess: ( ) => {
- logger.info(
- "Remote observation deleted, now deleting local observation"
- );
- deleteLocalObservation( );
- },
- onError: deleteObservationError => {
- // If we tried to delete and got a 404, this observation doesn't exist
- // on the server any more and should be deleted locally
- if ( deleteObservationError?.status === 404 ) {
- deleteLocalObservation( );
- } else {
- throw deleteObservationError;
- }
- }
- }
- );
-
- const deleteObservation = useCallback( async ( ) => {
- if ( !observationToDelete?._synced_at ) {
- return deleteLocalObservation( );
- }
- logger.info( "Remote observation to delete: ", uuid );
- return deleteObservationMutation.mutate( { uuid } );
- }, [deleteLocalObservation, deleteObservationMutation, observationToDelete, uuid] );
-
- const deleteRemoteObservationAndCatchError = useCallback( async ( ) => {
- try {
- return deleteObservation( );
- } catch ( deleteError ) {
- console.warn( "useDeleteObservations, deleteError: ", deleteError );
- let { message } = deleteError;
- if ( deleteError?.json?.errors ) {
- // TODO localize comma join
- message = deleteError.json.errors.map( e => {
- if ( e.message?.errors ) {
- return e.message.errors.flat( ).join( ", " );
- }
- return e.message;
- } ).join( ", " );
- } else {
- throw deleteError;
- }
- setDeletionError( message );
- return false;
- }
- }, [deleteObservation, setDeletionError] );
-
- useEffect( ( ) => {
- const beginDeletions = async ( ) => {
- // logger.info( "syncing remotely deleted observations" );
- try {
- await syncRemoteDeletedObservations( realm );
- } catch ( syncRemoteError ) {
- // For some reason this seems to run even when signed out, in which
- // case we end up sending no JWT or the anon JWT, wich fails auth. If
- // that happens, we can just return and call it a day.
- if (
- syncRemoteError instanceof INatApiError
- && syncRemoteError?.status === 401
- ) {
- return;
- }
- throw syncRemoteError;
- }
- // logger.info( "syncing locally deleted observations" );
- const localObservations = filterLocalObservationsToDelete( realm );
- if ( localObservations.length > 0 && deletions.length === 0 ) {
- setDeletions( localObservations );
- }
- };
- if ( canBeginDeletions ) {
- myObservationsDispatch( { type: "SET_START_DELETIONS" } );
- beginDeletions( );
- }
- }, [
- canBeginDeletions,
- deletions,
- myObservationsDispatch,
- realm,
- setDeletions
- ] );
-
- useEffect( ( ) => {
- if ( canStartDeletingLocalObservations ) {
- deletions.forEach( async ( observation, i ) => {
- await deleteRemoteObservationAndCatchError( );
- if ( i > 0 ) {
- startNextDeletion( );
- }
- if ( i === deletions.length - 1 ) {
- finishDeletions( );
- }
- } );
- }
- }, [
- canStartDeletingLocalObservations,
- deleteRemoteObservationAndCatchError,
- deletions,
- finishDeletions,
- startNextDeletion
- ] );
-
- useEffect(
- ( ) => {
- navigation.addListener( "blur", ( ) => {
- resetDeleteObservationsSlice( );
- } );
- },
- [navigation, resetDeleteObservationsSlice]
- );
-
- useEffect( ( ) => {
- let timer;
- if ( deletionsComplete && !error ) {
- timer = setTimeout( ( ) => {
- resetDeleteObservationsSlice( );
- }, 5000 );
- }
- return ( ) => {
- clearTimeout( timer );
- };
- }, [deletionsComplete, error, resetDeleteObservationsSlice] );
-
- return {
- deleteLocalObservation
- };
-};
-
-export default useDeleteObservations;
diff --git a/src/components/MyObservations/hooks/useSyncObservations.ts b/src/components/MyObservations/hooks/useSyncObservations.ts
new file mode 100644
index 000000000..3304d9c71
--- /dev/null
+++ b/src/components/MyObservations/hooks/useSyncObservations.ts
@@ -0,0 +1,185 @@
+import { INatApiError } from "api/error";
+import { deleteRemoteObservation } from "api/observations";
+import { RealmContext } from "providers/contexts";
+import { useCallback, useEffect } from "react";
+import Observation from "realmModels/Observation";
+import { log } from "sharedHelpers/logger";
+import useAuthenticatedMutation from "sharedHooks/useAuthenticatedMutation";
+import {
+ AUTOMATIC_SYNC_IN_PROGRESS,
+ BEGIN_AUTOMATIC_SYNC,
+ BEGIN_MANUAL_SYNC,
+ MANUAL_SYNC_IN_PROGRESS
+} from "stores/createSyncObservationsSlice.ts";
+import useStore from "stores/useStore";
+
+import syncRemoteDeletedObservations from "../helpers/syncRemoteDeletedObservations";
+import syncRemoteObservations from "../helpers/syncRemoteObservations";
+
+const logger = log.extend( "useSyncObservations" );
+
+const { useRealm } = RealmContext;
+
+const useSyncObservations = ( currentUserId, uploadObservations ): Object => {
+ const loggedIn = !!currentUserId;
+ const deleteQueue = useStore( state => state.deleteQueue );
+ const deletionsCompletedAt = useStore( state => state.deletionsCompletedAt );
+ const completeLocalDeletions = useStore( state => state.completeLocalDeletions );
+ const startNextDeletion = useStore( state => state.startNextDeletion );
+ const setDeletionError = useStore( state => state.setDeletionError );
+ const syncingStatus = useStore( state => state.syncingStatus );
+ const setSyncingStatus = useStore( state => state.setSyncingStatus );
+ const completeSync = useStore( state => state.completeSync );
+ const resetSyncToolbar = useStore( state => state.resetSyncToolbar );
+ const removeFromDeleteQueue = useStore( state => state.removeFromDeleteQueue );
+
+ const realm = useRealm( );
+
+ const deleteRealmObservation = useCallback( async uuid => {
+ await Observation.deleteLocalObservation( realm, uuid );
+ }, [realm] );
+
+ const handleRemoteDeletion = useAuthenticatedMutation(
+ ( params, optsWithAuth ) => deleteRemoteObservation( params, optsWithAuth ),
+ {
+ onSuccess: ( ) => logger.debug( "Remote observation deleted" ),
+ onError: deleteObservationError => {
+ setDeletionError( deleteObservationError?.message );
+ throw deleteObservationError;
+ }
+ }
+ );
+
+ const deleteLocalObservations = useCallback( async ( ) => {
+ if ( deleteQueue.length === 0 ) { return; }
+
+ deleteQueue.forEach( async ( uuid, i ) => {
+ const observation = realm.objectForPrimaryKey( "Observation", uuid );
+ const canDeleteRemoteObservation = observation?._synced_at && loggedIn;
+ if ( !canDeleteRemoteObservation ) {
+ return deleteRealmObservation( uuid );
+ }
+ await handleRemoteDeletion.mutate( { uuid } );
+ removeFromDeleteQueue( );
+
+ if ( i > 0 ) {
+ // this loop isn't really being used, since a user can only delete one
+ // observation at a time in soft launch
+ startNextDeletion( );
+ }
+ if ( i === deleteQueue.length - 1 ) {
+ completeLocalDeletions( );
+ }
+ return null;
+ } );
+ }, [
+ completeLocalDeletions,
+ deleteQueue,
+ deleteRealmObservation,
+ handleRemoteDeletion,
+ loggedIn,
+ realm,
+ removeFromDeleteQueue,
+ startNextDeletion
+ ] );
+
+ const fetchRemoteDeletions = useCallback( async ( ) => {
+ try {
+ await syncRemoteDeletedObservations( realm );
+ return true;
+ } catch ( syncRemoteError ) {
+ // For some reason this seems to run even when signed out, in which
+ // case we end up sending no JWT or the anon JWT, wich fails auth. If
+ // that happens, we can just return and call it a day.
+ if (
+ syncRemoteError instanceof INatApiError
+ && syncRemoteError?.status === 401
+ ) {
+ return false;
+ }
+ throw syncRemoteError;
+ }
+ }, [
+ realm
+ ] );
+
+ const fetchRemoteObservations = useCallback( async ( ) => {
+ try {
+ await syncRemoteObservations( realm, currentUserId, deletionsCompletedAt );
+ return true;
+ } catch ( syncRemoteError ) {
+ if (
+ syncRemoteError instanceof INatApiError
+ && syncRemoteError?.status === 401
+ ) {
+ return false;
+ }
+ throw syncRemoteError;
+ }
+ }, [
+ realm,
+ currentUserId,
+ deletionsCompletedAt
+ ] );
+
+ const syncAutomatically = useCallback( async ( ) => {
+ if ( loggedIn ) {
+ logger.debug( "sync #1: syncing remotely deleted observations" );
+ await fetchRemoteDeletions( );
+ }
+ logger.debug( "sync #2: handling locally deleted observations" );
+ await deleteLocalObservations( );
+ if ( loggedIn ) {
+ logger.debug( "sync #3: fetching remote observations" );
+ await fetchRemoteObservations( );
+ }
+ completeSync( );
+ }, [
+ deleteLocalObservations,
+ fetchRemoteDeletions,
+ fetchRemoteObservations,
+ loggedIn,
+ completeSync
+ ] );
+
+ const syncManually = useCallback( async ( ) => {
+ if ( loggedIn ) {
+ logger.debug( "sync #1: syncing remotely deleted observations" );
+ await fetchRemoteDeletions( );
+ }
+ logger.debug( "sync #2: handling locally deleted observations" );
+ await deleteLocalObservations( );
+ if ( loggedIn ) {
+ logger.debug( "sync #3: fetching remote observations" );
+ await fetchRemoteObservations( );
+ }
+ resetSyncToolbar( );
+ if ( loggedIn ) {
+ logger.debug( "sync #4: uploading all unsynced observations" );
+ await uploadObservations( );
+ }
+ completeSync( );
+ }, [
+ deleteLocalObservations,
+ fetchRemoteDeletions,
+ fetchRemoteObservations,
+ loggedIn,
+ resetSyncToolbar,
+ completeSync,
+ uploadObservations
+ ] );
+
+ useEffect( ( ) => {
+ if ( syncingStatus !== BEGIN_AUTOMATIC_SYNC ) { return; }
+ setSyncingStatus( AUTOMATIC_SYNC_IN_PROGRESS );
+ syncAutomatically( );
+ }, [syncingStatus, syncAutomatically, setSyncingStatus] );
+
+ useEffect( ( ) => {
+ if ( syncingStatus !== BEGIN_MANUAL_SYNC ) { return; }
+ setSyncingStatus( MANUAL_SYNC_IN_PROGRESS );
+ syncManually( );
+ }, [syncingStatus, syncManually, setSyncingStatus] );
+};
+
+export default useSyncObservations;
diff --git a/src/components/MyObservations/hooks/useUploadObservations.ts b/src/components/MyObservations/hooks/useUploadObservations.ts
index 92fa45583..6d35e0abb 100644
--- a/src/components/MyObservations/hooks/useUploadObservations.ts
+++ b/src/components/MyObservations/hooks/useUploadObservations.ts
@@ -9,23 +9,28 @@ import Observation from "realmModels/Observation";
import {
INCREMENT_SINGLE_UPLOAD_PROGRESS
} from "sharedHelpers/emitUploadProgress";
+import { log } from "sharedHelpers/logger";
import uploadObservation, { handleUploadError } from "sharedHelpers/uploadObservation";
import {
+ useLocalObservations,
useTranslation
} from "sharedHooks";
import {
UPLOAD_CANCELLED,
UPLOAD_COMPLETE,
- UPLOAD_IN_PROGRESS
+ UPLOAD_IN_PROGRESS,
+ UPLOAD_PENDING
} from "stores/createUploadObservationsSlice.ts";
import useStore from "stores/useStore";
+const logger = log.extend( "useUploadObservations" );
+
export const MS_BEFORE_TOOLBAR_RESET = 5_000;
const MS_BEFORE_UPLOAD_TIMES_OUT = 60_000 * 5;
const { useRealm } = RealmContext;
-export default useUploadObservations = ( ) => {
+export default useUploadObservations = canUpload => {
const realm = useRealm( );
const addUploadError = useStore( state => state.addUploadError );
@@ -41,8 +46,14 @@ export default useUploadObservations = ( ) => {
const uploadQueue = useStore( state => state.uploadQueue );
const uploadStatus = useStore( state => state.uploadStatus );
const setNumUnuploadedObservations = useStore( state => state.setNumUnuploadedObservations );
+ const setTotalToolbarIncrements = useStore( state => state.setTotalToolbarIncrements );
+ const addToUploadQueue = useStore( state => state.addToUploadQueue );
+ const setUploadStatus = useStore( state => state.setUploadStatus );
+ const resetSyncToolbar = useStore( state => state.resetSyncToolbar );
const initialNumObservationsInQueue = useStore( state => state.initialNumObservationsInQueue );
+ const { unsyncedUuids } = useLocalObservations( );
+
// The existing abortController lets you abort...
const abortController = useStore( storeState => storeState.abortController );
// ...but whenever you start a new abortable upload process, you need to
@@ -60,12 +71,19 @@ export default useUploadObservations = ( ) => {
const unsynced = Observation.filterUnsyncedObservations( realm );
setNumUnuploadedObservations( unsynced.length );
}, MS_BEFORE_TOOLBAR_RESET );
+ } else {
+ timer = setTimeout( () => {
+ resetSyncToolbar( );
+ const unsynced = Observation.filterUnsyncedObservations( realm );
+ setNumUnuploadedObservations( unsynced.length );
+ }, MS_BEFORE_TOOLBAR_RESET );
}
return () => {
clearTimeout( timer );
};
}, [
realm,
+ resetSyncToolbar,
resetUploadObservationsSlice,
setNumUnuploadedObservations,
uploadStatus
@@ -183,4 +201,42 @@ export default useUploadObservations = ( ) => {
deactivateKeepAwake( );
}
}, [abortController, uploadStatus] );
+
+ const startUpload = useCallback( ( ) => {
+ if ( canUpload ) {
+ logger.debug( "sync #4.2: starting upload" );
+ setUploadStatus( UPLOAD_IN_PROGRESS );
+ } else {
+ setUploadStatus( UPLOAD_PENDING );
+ }
+ }, [
+ canUpload,
+ setUploadStatus
+ ] );
+
+ const createUploadQueue = useCallback( ( ) => {
+ const uuidsQuery = unsyncedUuids.map( uploadUuid => `'${uploadUuid}'` ).join( ", " );
+ const uploads = realm.objects( "Observation" )
+ .filtered( `uuid IN { ${uuidsQuery} }` );
+ setTotalToolbarIncrements( uploads );
+ addToUploadQueue( unsyncedUuids );
+ startUpload( );
+ }, [
+ realm,
+ setTotalToolbarIncrements,
+ unsyncedUuids,
+ addToUploadQueue,
+ startUpload
+ ] );
+
+ const uploadObservations = useCallback( async ( ) => {
+ logger.debug( "sync #4.1: creating upload queue" );
+ createUploadQueue( );
+ }, [
+ createUploadQueue
+ ] );
+
+ return {
+ uploadObservations
+ };
};
diff --git a/src/components/ObsEdit/Sheets/DeleteObservationSheet.js b/src/components/ObsEdit/Sheets/DeleteObservationSheet.js
index bedabde0f..fe12471c8 100644
--- a/src/components/ObsEdit/Sheets/DeleteObservationSheet.js
+++ b/src/components/ObsEdit/Sheets/DeleteObservationSheet.js
@@ -8,6 +8,7 @@ import type { Node } from "react";
import React, { useCallback } from "react";
import safeRealmWrite from "sharedHelpers/safeRealmWrite";
import { useTranslation } from "sharedHooks";
+import useStore from "stores/useStore";
const { useRealm } = RealmContext;
@@ -29,6 +30,7 @@ const DeleteObservationSheet = ( {
const { t } = useTranslation( );
const realm = useRealm( );
const { uuid } = currentObservation;
+ const addToDeleteQueue = useStore( state => state.addToDeleteQueue );
const multipleObservations = observations.length > 1;
@@ -38,6 +40,7 @@ const DeleteObservationSheet = ( {
safeRealmWrite( realm, ( ) => {
localObsToDelete._deleted_at = new Date( );
}, "adding _deleted_at date in DeleteObservationSheet" );
+ addToDeleteQueue( uuid );
}
if ( multipleObservations ) {
updateObservations( observations.filter( o => o.uuid !== uuid ) );
@@ -51,6 +54,7 @@ const DeleteObservationSheet = ( {
navToObsList,
observations,
realm,
+ addToDeleteQueue,
updateObservations,
uuid
] );
diff --git a/src/stores/createDeleteObservationsSlice.ts b/src/stores/createDeleteObservationsSlice.ts
deleted file mode 100644
index cffda0eb1..000000000
--- a/src/stores/createDeleteObservationsSlice.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-const DEFAULT_STATE = {
- currentDeleteCount: 1,
- deletions: [],
- deletionsComplete: false,
- deletionsCompletedAt: null,
- deletionsInProgress: false,
- deleteError: null
-};
-
-interface DeleteObservationsSlice {
- currentDeleteCount: number,
- deletions: Array