Merge pull request #3357 from inaturalist/mob-1104-consolidate-obsdetailscontainer-and

MOB-1104: consolidate logic ObsDetailsContainer and ObsDetailsDefaultModeContainer
This commit is contained in:
Seth Peterson
2026-02-09 12:17:25 -06:00
committed by GitHub
5 changed files with 470 additions and 537 deletions

View File

@@ -2,41 +2,29 @@
import {
useNetInfo,
} from "@react-native-community/netinfo";
import { useFocusEffect, useNavigation, useRoute } from "@react-navigation/native";
import { useQueryClient } from "@tanstack/react-query";
import { fetchSubscriptions } from "api/observations";
import { useRoute } from "@react-navigation/native";
import IdentificationSheets from "components/ObsDetailsDefaultMode/IdentificationSheets";
import useMarkViewedMutation
from "components/ObsDetailsSharedComponents/hooks/useMarkViewedMutation";
import { RealmContext } from "providers/contexts";
import useObsDetailsSharedLogic
from "components/ObsDetailsSharedComponents/hooks/useObsDetailsSharedLogic";
import type { Node } from "react";
import React, {
useCallback,
useEffect,
useReducer,
useState,
} from "react";
import { LogBox } from "react-native";
import Observation from "realmModels/Observation";
import safeRealmWrite from "sharedHelpers/safeRealmWrite";
import {
useAuthenticatedQuery,
useCurrentUser,
useLayoutPrefs,
useLocalObservation,
useObservationsUpdates,
useTranslation,
} from "sharedHooks";
import useRemoteObservation, {
fetchRemoteObservationKey,
} from "sharedHooks/useRemoteObservation";
import useRemoteObservation from "sharedHooks/useRemoteObservation";
import { OBS_DETAILS_TAB } from "stores/createLayoutSlice";
import useStore from "stores/useStore";
import ObsDetails from "./ObsDetails";
const { useRealm } = RealmContext;
// this is getting triggered by passing dates, like _created_at, through
// react navigation via the observation object. it doesn't seem to
// actually be breaking anything, for the moment (May 2, 2022)
@@ -44,77 +32,7 @@ LogBox.ignoreLogs( [
"Non-serializable values were found in the navigation state",
] );
const sortItems = ( ids, comments ) => ids.concat( [...comments] ).sort(
( a, b ) => ( new Date( a.created_at ) - new Date( b.created_at ) ),
);
const initialState = {
activityItems: [],
addingActivityItem: false,
agreeIdentification: null,
observationShown: null,
showAddCommentSheet: false,
showAgreeWithIdSheet: false,
};
const SHOW_AGREE_SHEET = "SHOW_AGREE_SHEET";
const HIDE_AGREE_SHEET = "HIDE_AGREE_SHEET";
const SET_ADD_COMMENT_SHEET = "SET_ADD_COMMENT_SHEET";
const SET_INITIAL_OBSERVATION = "SET_INITIAL_OBSERVATION";
const ADD_ACTIVITY_ITEM = "ADD_ACTIVITY_ITEM";
const LOADING_ACTIVITY_ITEM = "LOADING_ACTIVITY_ITEM";
const reducer = ( state, action ) => {
switch ( action.type ) {
case SET_INITIAL_OBSERVATION:
return {
...state,
observationShown: action.observationShown,
activityItems: sortItems(
action.observationShown?.identifications || [],
action.observationShown?.comments || [],
),
};
case ADD_ACTIVITY_ITEM:
return {
...state,
observationShown: action.observationShown,
addingActivityItem: false,
activityItems: sortItems(
action.observationShown?.identifications || [],
action.observationShown?.comments || [],
),
};
case LOADING_ACTIVITY_ITEM:
return {
...state,
addingActivityItem: true,
};
case SHOW_AGREE_SHEET:
return {
...state,
showAgreeWithIdSheet: true,
agreeIdentification: action.agreeIdentification,
};
case HIDE_AGREE_SHEET:
return {
...state,
showAgreeWithIdSheet: false,
agreeIdentification: null,
};
case SET_ADD_COMMENT_SHEET:
return {
...state,
showAddCommentSheet: action.showAddCommentSheet,
};
default:
throw new Error( );
}
};
const ObsDetailsContainer = ( ): Node => {
const setObservations = useStore( state => state.setObservations );
const {
obsDetailsTab,
setObsDetailsTab,
@@ -125,23 +43,10 @@ const ObsDetailsContainer = ( ): Node => {
targetActivityItemID,
uuid,
} = params;
const navigation = useNavigation( );
const realm = useRealm( );
const { t } = useTranslation( );
const { isConnected } = useNetInfo( );
const [state, dispatch] = useReducer( reducer, initialState );
const [remoteObsWasDeleted, setRemoteObsWasDeleted] = useState( false );
const {
activityItems,
addingActivityItem,
agreeIdentification,
observationShown,
showAddCommentSheet,
showAgreeWithIdSheet,
} = state;
const queryClient = useQueryClient( );
const {
localObservation,
markDeletedLocally,
@@ -163,23 +68,6 @@ const ObsDetailsContainer = ( ): Node => {
useMarkViewedMutation( localObservation, markViewedLocally, remoteObservation );
// If we tried to get a remote observation but it no longer exists, the user
// can't do anything so we need to send them back and remove the local
// copy of this observation
useEffect( ( ) => {
setRemoteObsWasDeleted( fetchRemoteObservationError?.status === 404 );
}, [fetchRemoteObservationError?.status] );
const confirmRemoteObsWasDeleted = useCallback( ( ) => {
if ( localObservation ) {
markDeletedLocally( );
}
if ( navigation.canGoBack( ) ) navigation.goBack( );
}, [
localObservation,
markDeletedLocally,
navigation,
] );
const observation = localObservation || Observation.mapApiToRealm( remoteObservation );
// In theory the only situation in which an observation would not have a
@@ -191,51 +79,39 @@ const ObsDetailsContainer = ( ): Node => {
|| ( !observation?.user && !observation?.id )
);
const { data: subscriptions, refetch: refetchSubscriptions } = useAuthenticatedQuery(
[
"fetchSubscriptions",
],
optsWithAuth => fetchSubscriptions( { uuid, fields: "user_id" }, optsWithAuth ),
{
enabled: !!( currentUser ) && !belongsToCurrentUser,
},
);
const invalidateRemoteObservationFetch = useCallback( ( ) => {
if ( observation?.uuid ) {
queryClient.invalidateQueries( {
queryKey: [fetchRemoteObservationKey, observation.uuid],
} );
}
}, [queryClient, observation?.uuid] );
useFocusEffect(
// this ensures activity items load after a user taps suggest id
// and adds a remote id on the Suggestions screen
useCallback( ( ) => {
invalidateRemoteObservationFetch( );
}, [invalidateRemoteObservationFetch] ),
);
useEffect( ( ) => {
if ( !observationShown ) {
dispatch( {
type: SET_INITIAL_OBSERVATION,
observationShown: observation,
} );
}
}, [observation, observationShown] );
useEffect( ( ) => {
// if observation does not belong to current user, show
// new activity items after a refetch
if ( remoteObservation && !isRefetching ) {
dispatch( {
type: ADD_ACTIVITY_ITEM,
observationShown: Observation.mapApiToRealm( remoteObservation ),
} );
}
}, [remoteObservation, isRefetching] );
const {
activityItems,
addingActivityItem,
agreeIdentification,
observationShown,
showAddCommentSheet,
showAgreeWithIdSheet,
subscriptionResults,
openAddCommentSheet,
hideAddCommentSheet,
openAgreeWithIdSheet,
closeAgreeWithIdSheet,
navToSuggestions,
invalidateQueryAndRefetch,
handleIdentificationMutationSuccess,
handleCommentMutationSuccess,
confirmRemoteObsWasDeleted,
loadActivityItem,
refetchSubscriptions,
} = useObsDetailsSharedLogic( {
observation,
uuid,
localObservation,
remoteObservation,
markViewedLocally,
markDeletedLocally,
setRemoteObsWasDeleted,
fetchRemoteObservationError,
currentUser,
belongsToCurrentUser,
isRefetching,
refetchRemoteObservation,
} );
const tabs = [
{
@@ -252,116 +128,8 @@ const ObsDetailsContainer = ( ): Node => {
},
];
const hasPhotos = observation?.observationPhotos?.length > 0;
const { refetch: refetchObservationUpdates } = useObservationsUpdates(
!!currentUser && !!observation,
);
const openAddCommentSheet = useCallback( ( ) => {
dispatch( {
type: SET_ADD_COMMENT_SHEET,
showAddCommentSheet: true,
} );
}, [] );
const hideAddCommentSheet = useCallback( ( ) => dispatch( {
type: SET_ADD_COMMENT_SHEET,
showAddCommentSheet: false,
} ), [] );
const openAgreeWithIdSheet = useCallback( taxon => {
dispatch( {
type: SHOW_AGREE_SHEET,
agreeIdentification: { taxon },
} );
}, [] );
const navToSuggestions = useCallback( ( ) => {
setObservations( [observation] );
if ( hasPhotos ) {
navigation.push( "Suggestions", {
entryScreen: "ObsDetails",
lastScreen: "ObsDetails",
hideSkip: true,
} );
} else {
// Go directly to taxon search in case there are no photos
navigation.navigate( "SuggestionsTaxonSearch", { lastScreen: "ObsDetails" } );
}
}, [hasPhotos, navigation, observation, setObservations] );
const showActivityTab = obsDetailsTab === OBS_DETAILS_TAB.ACTIVITY;
const invalidateQueryAndRefetch = useCallback( ( ) => {
invalidateRemoteObservationFetch( );
refetchRemoteObservation( );
refetchObservationUpdates( );
}, [invalidateRemoteObservationFetch, refetchObservationUpdates, refetchRemoteObservation] );
const subscriptionResults = !belongsToCurrentUser
? subscriptions?.results
: [];
const handleIdentificationMutationSuccess = useCallback( data => {
refetchRemoteObservation( );
if ( belongsToCurrentUser ) {
const createdIdent = data[0];
// Try to find an existing taxon b/c otherwise realm will try to
// create the taxon when updating the observation and error out
let taxon;
if ( createdIdent.taxon?.id ) {
taxon = realm?.objectForPrimaryKey( "Taxon", createdIdent.taxon.id );
}
taxon = taxon || createdIdent.taxon;
safeRealmWrite( realm, ( ) => {
createdIdent.user = currentUser;
if ( taxon ) createdIdent.taxon = taxon;
localObservation?.identifications?.push( createdIdent );
}, "setting local identification in ObsDetailsContainer" );
if ( uuid ) {
const updatedLocalObservation = realm.objectForPrimaryKey( "Observation", uuid );
dispatch( { type: ADD_ACTIVITY_ITEM, observationShown: updatedLocalObservation } );
}
}
}, [
belongsToCurrentUser,
currentUser,
localObservation?.identifications,
realm,
refetchRemoteObservation,
uuid,
] );
const handleCommentMutationSuccess = useCallback( data => {
refetchRemoteObservation( );
if ( belongsToCurrentUser ) {
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 } );
}
}, [
belongsToCurrentUser,
currentUser,
localObservation?.comments,
realm,
refetchRemoteObservation,
uuid,
] );
const closeAgreeWithIdSheet = useCallback( ( ) => {
dispatch( { type: HIDE_AGREE_SHEET } );
}, [] );
const loadActivityItem = useCallback( ( ) => {
dispatch( { type: LOADING_ACTIVITY_ITEM } );
}, [] );
return observationShown && (
<>
<ObsDetails

View File

@@ -1,33 +1,15 @@
// @flow
import { useFocusEffect, useNavigation } from "@react-navigation/native";
import { useQueryClient } from "@tanstack/react-query";
import { fetchSubscriptions } from "api/observations";
import IdentificationSheets from "components/ObsDetailsDefaultMode/IdentificationSheets";
import useMarkViewedMutation
from "components/ObsDetailsSharedComponents/hooks/useMarkViewedMutation";
import { RealmContext } from "providers/contexts";
import useObsDetailsSharedLogic
from "components/ObsDetailsSharedComponents/hooks/useObsDetailsSharedLogic";
import type { Node } from "react";
import React, {
useCallback,
useEffect,
useReducer,
} from "react";
import React from "react";
import { LogBox } from "react-native";
import Observation from "realmModels/Observation";
import safeRealmWrite from "sharedHelpers/safeRealmWrite";
import {
useAuthenticatedQuery,
useObservationsUpdates,
} from "sharedHooks";
import {
fetchRemoteObservationKey,
} from "sharedHooks/useRemoteObservation";
import useStore from "stores/useStore";
import ObsDetailsDefaultMode from "./ObsDetailsDefaultMode";
const { useRealm } = RealmContext;
// this is getting triggered by passing dates, like _created_at, through
// react navigation via the observation object. it doesn't seem to
// actually be breaking anything, for the moment (May 2, 2022)
@@ -35,73 +17,6 @@ LogBox.ignoreLogs( [
"Non-serializable values were found in the navigation state",
] );
const sortItems = ( ids, comments ) => ids.concat( [...comments] ).sort(
( a, b ) => ( new Date( a.created_at ) - new Date( b.created_at ) ),
);
const SHOW_AGREE_SHEET = "SHOW_AGREE_SHEET";
const HIDE_AGREE_SHEET = "HIDE_AGREE_SHEET";
const SET_ADD_COMMENT_SHEET = "SET_ADD_COMMENT_SHEET";
const SET_INITIAL_OBSERVATION = "SET_INITIAL_OBSERVATION";
const ADD_ACTIVITY_ITEM = "ADD_ACTIVITY_ITEM";
const LOADING_ACTIVITY_ITEM = "LOADING_ACTIVITY_ITEM";
const initialState = {
activityItems: [],
addingActivityItem: false,
observationShown: null,
showAddCommentSheet: false,
};
const reducer = ( state, action ) => {
switch ( action.type ) {
case SET_INITIAL_OBSERVATION:
return {
...state,
observationShown: action.observationShown,
activityItems: sortItems(
action.observationShown?.identifications || [],
action.observationShown?.comments || [],
),
};
case ADD_ACTIVITY_ITEM:
return {
...state,
observationShown: action.observationShown,
addingActivityItem: false,
activityItems: sortItems(
action.observationShown?.identifications || [],
action.observationShown?.comments || [],
),
};
case LOADING_ACTIVITY_ITEM:
return {
...state,
addingActivityItem: true,
};
case SHOW_AGREE_SHEET:
return {
...state,
showAgreeWithIdSheet: true,
agreeIdentification: action.agreeIdentification,
};
case HIDE_AGREE_SHEET:
return {
...state,
showAgreeWithIdSheet: false,
agreeIdentification: null,
};
case SET_ADD_COMMENT_SHEET:
return {
...state,
commentIsOptional: action.commentIsOptional,
showAddCommentSheet: action.showAddCommentSheet,
};
default:
throw new Error( );
}
};
type Props = {
belongsToCurrentUser: boolean,
currentUser: ?Object,
@@ -121,11 +36,6 @@ type Props = {
}
const ObsDetailsDefaultModeContainer = ( props: Props ): Node => {
const setObservations = useStore( state => state.setObservations );
const navigation = useNavigation( );
const realm = useRealm( );
const [state, dispatch] = useReducer( reducer, initialState );
const {
observation,
targetActivityItemID,
@@ -144,6 +54,8 @@ const ObsDetailsDefaultModeContainer = ( props: Props ): Node => {
remoteObsWasDeleted,
} = props;
useMarkViewedMutation( localObservation, markViewedLocally, remoteObservation );
const {
activityItems,
addingActivityItem,
@@ -151,186 +63,33 @@ const ObsDetailsDefaultModeContainer = ( props: Props ): Node => {
observationShown,
showAddCommentSheet,
showAgreeWithIdSheet,
} = state;
const queryClient = useQueryClient( );
useMarkViewedMutation( localObservation, markViewedLocally, remoteObservation );
// If we tried to get a remote observation but it no longer exists, the user
// can't do anything so we need to send them back and remove the local
// copy of this observation
useEffect( ( ) => {
setRemoteObsWasDeleted( fetchRemoteObservationError?.status === 404 );
}, [fetchRemoteObservationError?.status, setRemoteObsWasDeleted] );
const confirmRemoteObsWasDeleted = useCallback( ( ) => {
if ( localObservation ) {
markDeletedLocally( );
}
if ( navigation.canGoBack( ) ) navigation.goBack( );
}, [
subscriptionResults,
wasSynced,
openAddCommentSheet,
hideAddCommentSheet,
openAgreeWithIdSheet,
closeAgreeWithIdSheet,
navToSuggestions,
invalidateQueryAndRefetch,
handleIdentificationMutationSuccess,
handleCommentMutationSuccess,
confirmRemoteObsWasDeleted,
loadActivityItem,
refetchSubscriptions,
} = useObsDetailsSharedLogic( {
observation,
uuid,
localObservation,
remoteObservation,
markViewedLocally,
markDeletedLocally,
navigation,
] );
const wasSynced = !!( localObservation && localObservation?.wasSynced() );
const hasPhotos = observation?.observationPhotos?.length > 0;
const { data: subscriptions, refetch: refetchSubscriptions } = useAuthenticatedQuery(
[
"fetchSubscriptions",
],
optsWithAuth => fetchSubscriptions( { uuid, fields: "user_id" }, optsWithAuth ),
{
enabled: !!( currentUser ) && !belongsToCurrentUser,
},
);
const invalidateRemoteObservationFetch = useCallback( ( ) => {
if ( observation?.uuid ) {
queryClient.invalidateQueries( {
queryKey: [fetchRemoteObservationKey, observation.uuid],
} );
}
}, [queryClient, observation?.uuid] );
useFocusEffect(
// this ensures activity items load after a user taps suggest id
// and adds a remote id on the Suggestions screen
useCallback( ( ) => {
invalidateRemoteObservationFetch( );
}, [invalidateRemoteObservationFetch] ),
);
useEffect( ( ) => {
if ( !observationShown ) {
dispatch( {
type: SET_INITIAL_OBSERVATION,
observationShown: observation,
} );
}
}, [observation, observationShown] );
useEffect( ( ) => {
// if observation does not belong to current user, show
// new activity items after a refetch
if ( remoteObservation && !isRefetching ) {
dispatch( {
type: ADD_ACTIVITY_ITEM,
observationShown: Observation.mapApiToRealm( remoteObservation ),
} );
}
}, [remoteObservation, isRefetching] );
const { refetch: refetchObservationUpdates } = useObservationsUpdates(
!!currentUser && !!observation,
);
const openAddCommentSheet = useCallback( ( { isOptional = false } ) => {
dispatch( {
type: SET_ADD_COMMENT_SHEET,
showAddCommentSheet: true,
commentIsOptional: isOptional || false,
} );
}, [] );
const hideAddCommentSheet = useCallback( ( ) => dispatch( {
type: SET_ADD_COMMENT_SHEET,
showAddCommentSheet: false,
comment: null,
} ), [] );
const openAgreeWithIdSheet = useCallback( taxon => {
dispatch( {
type: SHOW_AGREE_SHEET,
agreeIdentification: { taxon },
} );
}, [] );
const navToSuggestions = useCallback( ( ) => {
setObservations( [observation] );
if ( hasPhotos ) {
navigation.push( "Suggestions", {
entryScreen: "ObsDetails",
lastScreen: "ObsDetails",
hideSkip: true,
} );
} else {
// Go directly to taxon search in case there are no photos
navigation.navigate( "SuggestionsTaxonSearch", { lastScreen: "ObsDetails" } );
}
}, [hasPhotos, navigation, observation, setObservations] );
const invalidateQueryAndRefetch = useCallback( ( ) => {
invalidateRemoteObservationFetch( );
refetchRemoteObservation( );
refetchObservationUpdates( );
}, [invalidateRemoteObservationFetch, refetchObservationUpdates, refetchRemoteObservation] );
const subscriptionResults = !belongsToCurrentUser
? subscriptions?.results
: [];
const handleIdentificationMutationSuccess = useCallback( data => {
refetchRemoteObservation( );
if ( belongsToCurrentUser ) {
const createdIdent = data[0];
// Try to find an existing taxon b/c otherwise realm will try to
// create the taxon when updating the observation and error out
let taxon;
if ( createdIdent.taxon?.id ) {
taxon = realm?.objectForPrimaryKey( "Taxon", createdIdent.taxon.id );
}
taxon = taxon || createdIdent.taxon;
safeRealmWrite( realm, ( ) => {
createdIdent.user = currentUser;
if ( taxon ) createdIdent.taxon = taxon;
localObservation?.identifications?.push( createdIdent );
}, "setting local identification in ObsDetailsContainer" );
if ( uuid ) {
const updatedLocalObservation = realm.objectForPrimaryKey( "Observation", uuid );
dispatch( { type: ADD_ACTIVITY_ITEM, observationShown: updatedLocalObservation } );
}
}
}, [
belongsToCurrentUser,
setRemoteObsWasDeleted,
fetchRemoteObservationError,
currentUser,
localObservation?.identifications,
realm,
refetchRemoteObservation,
uuid,
] );
const handleCommentMutationSuccess = useCallback( data => {
refetchRemoteObservation( );
if ( belongsToCurrentUser ) {
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 } );
}
}, [
belongsToCurrentUser,
currentUser,
localObservation?.comments,
realm,
isRefetching,
refetchRemoteObservation,
uuid,
] );
const closeAgreeWithIdSheet = useCallback( ( ) => {
dispatch( { type: HIDE_AGREE_SHEET } );
}, [] );
const loadActivityItem = useCallback( ( ) => {
dispatch( { type: LOADING_ACTIVITY_ITEM } );
}, [] );
} );
return observationShown && (
<>

View File

@@ -0,0 +1,398 @@
import type { NavigationProp, ParamListBase } from "@react-navigation/native";
import { useFocusEffect, useNavigation } from "@react-navigation/native";
import { useQueryClient } from "@tanstack/react-query";
import { fetchSubscriptions } from "api/observations";
import type { ApiComment, ApiIdentification, ApiObservation } from "api/types";
import { RealmContext } from "providers/contexts";
import {
useCallback,
useEffect,
useReducer,
} from "react";
import Observation from "realmModels/Observation";
import type { RealmObservation, RealmTaxon, RealmUser } from "realmModels/types";
import { log } from "sharedHelpers/logger";
import safeRealmWrite from "sharedHelpers/safeRealmWrite";
import {
useAuthenticatedQuery,
useObservationsUpdates,
} from "sharedHooks";
import {
fetchRemoteObservationKey,
} from "sharedHooks/useRemoteObservation";
import useStore from "stores/useStore";
const { useRealm } = RealmContext;
interface ActivityItem {
created_at: string;
}
const sortItems = (
ids: ActivityItem[],
comments: ActivityItem[],
): ActivityItem[] => ids.concat( [...comments] ).sort(
( a, b ) => ( new Date( a.created_at ) - new Date( b.created_at ) ),
);
const SHOW_AGREE_SHEET = "SHOW_AGREE_SHEET";
const HIDE_AGREE_SHEET = "HIDE_AGREE_SHEET";
const SET_ADD_COMMENT_SHEET = "SET_ADD_COMMENT_SHEET";
const SET_INITIAL_OBSERVATION = "SET_INITIAL_OBSERVATION";
const ADD_ACTIVITY_ITEM = "ADD_ACTIVITY_ITEM";
const LOADING_ACTIVITY_ITEM = "LOADING_ACTIVITY_ITEM";
interface AgreeIdentification {
taxon: RealmTaxon;
}
interface State {
activityItems: ActivityItem[];
addingActivityItem: boolean;
agreeIdentification: AgreeIdentification | null;
observationShown: ApiObservation | null;
showAddCommentSheet: boolean;
showAgreeWithIdSheet: boolean;
}
type Action =
| { type: typeof SET_INITIAL_OBSERVATION; observationShown: ApiObservation }
| { type: typeof ADD_ACTIVITY_ITEM; observationShown: ApiObservation }
| { type: typeof LOADING_ACTIVITY_ITEM }
| { type: typeof SHOW_AGREE_SHEET; agreeIdentification: AgreeIdentification }
| { type: typeof HIDE_AGREE_SHEET }
| { type: typeof SET_ADD_COMMENT_SHEET; showAddCommentSheet: boolean };
const initialState: State = {
activityItems: [],
addingActivityItem: false,
agreeIdentification: null,
observationShown: null,
showAddCommentSheet: false,
showAgreeWithIdSheet: false,
};
const logger = log.extend( "useObsDetailsSharedLogic" );
const reducer = ( state: State, action: Action ): State => {
switch ( action.type ) {
case SET_INITIAL_OBSERVATION:
return {
...state,
observationShown: action.observationShown,
activityItems: sortItems(
action.observationShown?.identifications || [],
action.observationShown?.comments || [],
),
};
case ADD_ACTIVITY_ITEM:
return {
...state,
observationShown: action.observationShown,
addingActivityItem: false,
activityItems: sortItems(
action.observationShown?.identifications || [],
action.observationShown?.comments || [],
),
};
case LOADING_ACTIVITY_ITEM:
return {
...state,
addingActivityItem: true,
};
case SHOW_AGREE_SHEET:
return {
...state,
showAgreeWithIdSheet: true,
agreeIdentification: action.agreeIdentification,
};
case HIDE_AGREE_SHEET:
return {
...state,
showAgreeWithIdSheet: false,
agreeIdentification: null,
};
case SET_ADD_COMMENT_SHEET:
return {
...state,
showAddCommentSheet: action.showAddCommentSheet,
};
default:
logger.error( "Unknown action in useObsDetailsSharedLogic reducer: ", action );
return state;
}
};
interface UseObsDetailsSharedLogicParams {
observation: RealmObservation;
uuid: string;
localObservation: RealmObservation | null;
remoteObservation: RealmObservation | null;
markViewedLocally: ( ) => void;
markDeletedLocally: ( ) => void;
setRemoteObsWasDeleted: ( deleted: boolean ) => void;
fetchRemoteObservationError: { status?: number } | null;
currentUser: RealmUser | null;
belongsToCurrentUser: boolean;
isRefetching: boolean;
refetchRemoteObservation: ( ) => void;
}
interface UseObsDetailsSharedLogicReturn {
// State
activityItems: ActivityItem[];
addingActivityItem: boolean;
agreeIdentification: AgreeIdentification | null;
observationShown: RealmObservation | null;
showAddCommentSheet: boolean;
showAgreeWithIdSheet: boolean;
// Computed
subscriptionResults: unknown[];
wasSynced: boolean;
// Callbacks
openAddCommentSheet: () => void;
hideAddCommentSheet: () => void;
openAgreeWithIdSheet: ( taxon: RealmTaxon ) => void;
closeAgreeWithIdSheet: () => void;
navToSuggestions: () => void;
invalidateQueryAndRefetch: () => void;
handleIdentificationMutationSuccess: ( data: ApiIdentification[] ) => void;
handleCommentMutationSuccess: ( data: ApiComment[] ) => void;
confirmRemoteObsWasDeleted: () => void;
loadActivityItem: () => void;
refetchSubscriptions: () => void;
}
const useObsDetailsSharedLogic = ( {
observation,
uuid,
localObservation,
markDeletedLocally,
remoteObservation,
setRemoteObsWasDeleted,
fetchRemoteObservationError,
currentUser,
belongsToCurrentUser,
isRefetching,
refetchRemoteObservation,
}: UseObsDetailsSharedLogicParams ): UseObsDetailsSharedLogicReturn => {
const setObservations = useStore( state => state.setObservations );
const navigation = useNavigation<NavigationProp<ParamListBase>>( );
const realm = useRealm( );
const [state, dispatch] = useReducer( reducer, initialState );
const queryClient = useQueryClient( );
const {
activityItems,
addingActivityItem,
agreeIdentification,
observationShown,
showAddCommentSheet,
showAgreeWithIdSheet,
} = state;
// If we tried to get a remote observation but it no longer exists, the user
// can't do anything so we need to send them back and remove the local
// copy of this observation
useEffect( ( ) => {
setRemoteObsWasDeleted( fetchRemoteObservationError?.status === 404 );
}, [fetchRemoteObservationError?.status, setRemoteObsWasDeleted] );
const confirmRemoteObsWasDeleted = useCallback( ( ) => {
if ( localObservation ) {
markDeletedLocally( );
}
if ( navigation.canGoBack( ) ) navigation.goBack( );
}, [
localObservation,
markDeletedLocally,
navigation,
] );
const wasSynced = !!( localObservation && localObservation?.wasSynced() );
const hasPhotos = observation?.observationPhotos?.length > 0;
const { data: subscriptions, refetch: refetchSubscriptions } = useAuthenticatedQuery(
[
"fetchSubscriptions",
],
optsWithAuth => fetchSubscriptions( { uuid, fields: "user_id" }, optsWithAuth ),
{
enabled: !!( currentUser ) && !belongsToCurrentUser,
},
);
const invalidateRemoteObservationFetch = useCallback( ( ) => {
if ( observation?.uuid ) {
queryClient.invalidateQueries( {
queryKey: [fetchRemoteObservationKey, observation.uuid],
} );
}
}, [queryClient, observation?.uuid] );
useFocusEffect(
// this ensures activity items load after a user taps suggest id
// and adds a remote id on the Suggestions screen
useCallback( ( ) => {
invalidateRemoteObservationFetch( );
}, [invalidateRemoteObservationFetch] ),
);
useEffect( ( ) => {
if ( !observationShown ) {
dispatch( {
type: SET_INITIAL_OBSERVATION,
observationShown: observation,
} );
}
}, [observation, observationShown] );
useEffect( ( ) => {
// if observation does not belong to current user, show
// new activity items after a refetch
if ( remoteObservation && !isRefetching ) {
dispatch( {
type: ADD_ACTIVITY_ITEM,
observationShown: Observation.mapApiToRealm( remoteObservation ),
} );
}
}, [remoteObservation, isRefetching] );
const { refetch: refetchObservationUpdates } = useObservationsUpdates(
!!currentUser && !!observation,
);
const openAddCommentSheet = useCallback( ( ) => {
dispatch( {
type: SET_ADD_COMMENT_SHEET,
showAddCommentSheet: true,
} );
}, [] );
const hideAddCommentSheet = useCallback( ( ) => dispatch( {
type: SET_ADD_COMMENT_SHEET,
showAddCommentSheet: false,
} ), [] );
const openAgreeWithIdSheet = useCallback( ( taxon: RealmTaxon ) => {
dispatch( {
type: SHOW_AGREE_SHEET,
agreeIdentification: { taxon },
} );
}, [] );
const navToSuggestions = useCallback( ( ) => {
setObservations( [observation] );
if ( hasPhotos ) {
navigation.push( "Suggestions", {
entryScreen: "ObsDetails",
lastScreen: "ObsDetails",
hideSkip: true,
} );
} else {
// Go directly to taxon search in case there are no photos
navigation.navigate( "SuggestionsTaxonSearch", { lastScreen: "ObsDetails" } );
}
}, [hasPhotos, navigation, observation, setObservations] );
const invalidateQueryAndRefetch = useCallback( ( ) => {
invalidateRemoteObservationFetch( );
refetchRemoteObservation( );
refetchObservationUpdates( );
}, [invalidateRemoteObservationFetch, refetchObservationUpdates, refetchRemoteObservation] );
const subscriptionResults = !belongsToCurrentUser
? subscriptions?.results
: [];
const handleIdentificationMutationSuccess = useCallback( ( data: ApiIdentification[] ) => {
refetchRemoteObservation( );
if ( belongsToCurrentUser ) {
const createdIdent = data[0];
// Try to find an existing taxon b/c otherwise realm will try to
// create the taxon when updating the observation and error out
let taxon;
if ( createdIdent.taxon?.id ) {
taxon = realm?.objectForPrimaryKey( "Taxon", createdIdent.taxon.id );
}
taxon = taxon || createdIdent.taxon;
safeRealmWrite( realm, ( ) => {
createdIdent.user = currentUser;
if ( taxon ) createdIdent.taxon = taxon;
localObservation?.identifications?.push( createdIdent );
}, "setting local identification in ObsDetailsContainer" );
if ( uuid ) {
const updatedLocalObservation = realm.objectForPrimaryKey( "Observation", uuid );
dispatch( { type: ADD_ACTIVITY_ITEM, observationShown: updatedLocalObservation } );
}
}
}, [
belongsToCurrentUser,
currentUser,
localObservation?.identifications,
realm,
refetchRemoteObservation,
uuid,
] );
const handleCommentMutationSuccess = useCallback( ( data: ApiComment[] ) => {
refetchRemoteObservation( );
if ( belongsToCurrentUser ) {
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 } );
}
}, [
belongsToCurrentUser,
currentUser,
localObservation?.comments,
realm,
refetchRemoteObservation,
uuid,
] );
const closeAgreeWithIdSheet = useCallback( ( ) => {
dispatch( { type: HIDE_AGREE_SHEET } );
}, [] );
const loadActivityItem = useCallback( ( ) => {
dispatch( { type: LOADING_ACTIVITY_ITEM } );
}, [] );
return {
// State
activityItems,
addingActivityItem,
agreeIdentification,
observationShown,
showAddCommentSheet,
showAgreeWithIdSheet,
// Computed
subscriptionResults,
wasSynced,
// Callbacks
openAddCommentSheet,
hideAddCommentSheet,
openAgreeWithIdSheet,
closeAgreeWithIdSheet,
navToSuggestions,
invalidateQueryAndRefetch,
handleIdentificationMutationSuccess,
handleCommentMutationSuccess,
confirmRemoteObsWasDeleted,
loadActivityItem,
refetchSubscriptions,
};
};
export default useObsDetailsSharedLogic;

View File

@@ -99,6 +99,7 @@ const StandardBottomSheet = ( {
handleSnapPress( );
}, [hidden, handleSnapPress] );
// To me, this implies this is a good candidate for splitting into 2 components
const BottomSheetComponent = insideModal
? BottomSheet
: BottomSheetModal;

View File

@@ -11,6 +11,13 @@ import { Dimensions, StyleSheet } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTranslation } from "sharedHooks";
// This component is an iteration on BottomSheet.
// The main motivation was to avoid messing with the inconsistent use of the
// onPressClose prop, whis gets passed to onDismiss and called differently depending
// on whether the X button or backdrop is pressed.
// To start, it contains just what's necessary for the usage in SuggestIDSheet.
const { width } = Dimensions.get( "window" );
const styles = StyleSheet.create( {