diff --git a/.eslintrc.js b/.eslintrc.js index 63633947e..a150efbff 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -170,6 +170,12 @@ module.exports = { "import/consistent-type-specifier-style": "off" } }, + { + files: ["*.test.js", "*.test.tsx"], + rules: { + "react/jsx-props-no-spreading": "off" + } + }, { files: ["**/__mocks__/**/*", "**/*mock*", "**/*.mock.*"], rules: { diff --git a/src/api/types.d.ts b/src/api/types.d.ts index 14285e2f7..b2a79dff6 100644 --- a/src/api/types.d.ts +++ b/src/api/types.d.ts @@ -105,12 +105,17 @@ export interface ApiUser { export interface ApiComment { body?: string; user?: ApiUser; + id: number; + hidden?: boolean; + uuid: string; + created_at: string; } export interface ApiIdentification { body?: string; taxon?: ApiTaxon; user?: ApiUser; + hidden?: boolean; } export interface ApiNotification { @@ -137,6 +142,9 @@ export interface ApiObservation extends ApiRecord { time_observed_at?: string; user?: ApiUser; uuid: string; + comments?: ApiComment[]; + identifications?: ApiIdentification[]; + taxon?: ApiTaxon; } export interface ApiSuggestion { diff --git a/src/components/Match/MatchHeader.tsx b/src/components/Match/MatchHeader.tsx index cef3596b3..533573e73 100644 --- a/src/components/Match/MatchHeader.tsx +++ b/src/components/Match/MatchHeader.tsx @@ -15,9 +15,10 @@ import { useTranslation } from "sharedHooks"; interface Props { topSuggestion?: ApiSuggestion; + hideObservationStatus?: boolean } -const MatchHeader = ( { topSuggestion }: Props ) => { +const MatchHeader = ( { topSuggestion, hideObservationStatus }: Props ) => { const { t } = useTranslation( ); const taxon = topSuggestion?.taxon; @@ -87,17 +88,19 @@ const MatchHeader = ( { topSuggestion }: Props ) => { return ( - {generateCongratulatoryText( )} + {!hideObservationStatus && {generateCongratulatoryText( )}} {showSuggestedTaxon( )} - - - {t( "X-percent", { count: confidence } )} - - - {t( "Confidence--label" )} - - + { !hideObservationStatus && ( + + + {t( "X-percent", { count: confidence } )} + + + {t( "Confidence--label" )} + + + )} ); diff --git a/src/components/Match/PhotosSection.tsx b/src/components/Match/PhotosSection.tsx index b9b642038..93a63e9e0 100644 --- a/src/components/Match/PhotosSection.tsx +++ b/src/components/Match/PhotosSection.tsx @@ -17,14 +17,16 @@ type Props = { representativePhoto: ApiPhoto, taxon?: ApiTaxon | RealmTaxon, obsPhotos: RealmObservationPhoto[], - navToTaxonDetails: ( photo: ApiPhoto ) => void + navToTaxonDetails: ( photo: ApiPhoto ) => void, + hideTaxonPhotos?: boolean } const PhotosSection = ( { representativePhoto, taxon, obsPhotos, - navToTaxonDetails + navToTaxonDetails, + hideTaxonPhotos }: Props ) => { const [displayPortraitLayout, setDisplayPortraitLayout] = useState( null ); const [mediaViewerVisible, setMediaViewerVisible] = useState( false ); @@ -88,34 +90,36 @@ const PhotosSection = ( { let observationPhotoClass = "w-full h-full"; let taxonPhotosContainerClass; let taxonPhotoClass; - // If there is only one taxon photo: obs photo a square, - // taxon photo a square in the lower right corner of the obs photo - if ( bestTaxonPhotos.length === 1 ) { - containerClass = "flex-row relative"; - observationPhotoClass = "w-full h-full"; - taxonPhotosContainerClass = "absolute bottom-0 right-0 w-1/3 h-1/3"; - taxonPhotoClass = "w-full h-full border-l-[3px] border-t-[3px] border-white"; - } - if ( bestTaxonPhotos.length > 1 ) { - if ( displayPortraitLayout ) { - containerClass = "flex-row"; - observationPhotoClass = "w-2/3 h-full pr-[3px]"; - if ( bestTaxonPhotos.length === 2 ) { - taxonPhotosContainerClass = "flex-col w-1/3 h-full space-y-[3px]"; - taxonPhotoClass = "w-full h-1/2"; + if ( !hideTaxonPhotos ) { + // If there is only one taxon photo: obs photo a square, + // taxon photo a square in the lower right corner of the obs photo + if ( bestTaxonPhotos.length === 1 ) { + containerClass = "flex-row relative"; + observationPhotoClass = "w-full h-full"; + taxonPhotosContainerClass = "absolute bottom-0 right-0 w-1/3 h-1/3"; + taxonPhotoClass = "w-full h-full border-l-[3px] border-t-[3px] border-white"; + } + if ( bestTaxonPhotos.length > 1 ) { + if ( displayPortraitLayout ) { + containerClass = "flex-row"; + observationPhotoClass = "w-2/3 h-full pr-[3px]"; + if ( bestTaxonPhotos.length === 2 ) { + taxonPhotosContainerClass = "flex-col w-1/3 h-full space-y-[3px]"; + taxonPhotoClass = "w-full h-1/2"; + } else { + taxonPhotosContainerClass = "flex-col w-1/3 h-full space-y-[3px]"; + taxonPhotoClass = "w-full h-1/3"; + } } else { - taxonPhotosContainerClass = "flex-col w-1/3 h-full space-y-[3px]"; - taxonPhotoClass = "w-full h-1/3"; - } - } else { - containerClass = "flex-col"; - observationPhotoClass = "w-full h-2/3 pb-[3px]"; - if ( bestTaxonPhotos.length === 2 ) { - taxonPhotosContainerClass = "flex-row w-full h-1/3 space-x-[3px]"; - taxonPhotoClass = "w-1/2 h-full"; - } else { - taxonPhotosContainerClass = "flex-row w-full h-1/3 space-x-[3px]"; - taxonPhotoClass = "w-1/3 h-full"; + containerClass = "flex-col"; + observationPhotoClass = "w-full h-2/3 pb-[3px]"; + if ( bestTaxonPhotos.length === 2 ) { + taxonPhotosContainerClass = "flex-row w-full h-1/3 space-x-[3px]"; + taxonPhotoClass = "w-1/2 h-full"; + } else { + taxonPhotosContainerClass = "flex-row w-full h-1/3 space-x-[3px]"; + taxonPhotoClass = "w-1/3 h-full"; + } } } } @@ -191,9 +195,9 @@ const PhotosSection = ( { } return ( - + {renderObservationPhoto( )} - {bestTaxonPhotos.length > 0 && renderTaxonPhotos( )} + {!hideTaxonPhotos && bestTaxonPhotos.length > 0 && renderTaxonPhotos( )} setMediaViewerVisible( false )} diff --git a/src/components/ObsDetailsDefaultMode/CommunitySection/CommunitySection.js b/src/components/ObsDetailsDefaultMode/CommunitySection/CommunitySection.js index d7a3d087e..7554d05a6 100644 --- a/src/components/ObsDetailsDefaultMode/CommunitySection/CommunitySection.js +++ b/src/components/ObsDetailsDefaultMode/CommunitySection/CommunitySection.js @@ -14,7 +14,7 @@ type Props = { activityItems: Array, openAgreeWithIdSheet: Function, isConnected: boolean, - targetItemID: number, + targetItemID: ?number, // TODO change to LayoutEvent from react-native if/when switching to TS onLayoutTargetItem: ( event: Object ) => void } diff --git a/src/components/ObsDetailsDefaultMode/MapSection/MapSection.js b/src/components/ObsDetailsDefaultMode/MapSection/MapSection.js index 0dd14907b..0b64bb04e 100644 --- a/src/components/ObsDetailsDefaultMode/MapSection/MapSection.js +++ b/src/components/ObsDetailsDefaultMode/MapSection/MapSection.js @@ -63,7 +63,7 @@ const MapSection = ( { observation, taxon }: Props ) => { } return ( - + void, observation: RealmObservation & Observation & { id: number }, openAddCommentSheet: () => void, @@ -52,7 +51,6 @@ const ObsDetailsDefaultMode = ( { belongsToCurrentUser, currentUser, isConnected, - isSimpleMode, navToSuggestions, observation, openAddCommentSheet, @@ -79,7 +77,7 @@ const ObsDetailsDefaultMode = ( { const showFloatingButtons = currentUser && !isSavedObservationByCurrentUser; return ( - + - {!isSimpleMode && ( - - )} + @@ -113,10 +109,7 @@ const ObsDetailsDefaultMode = ( { observation={observation} isSimpleMode /> - + - {!isSimpleMode && } + - {!isSimpleMode && ( - <> - - {addingActivityItem && ( - - - - )} - - - - + + {addingActivityItem && ( + + + )} + + + {showFloatingButtons && ( { } }; -const ObsDetailsDefaultModeContainer = ( ): Node => { +type Props = { + belongsToCurrentUser: boolean, + currentUser: ?Object, + fetchRemoteObservationError: ?Object, + isConnected: boolean, + isRefetching: boolean, + localObservation: ?Object, + markDeletedLocally: Function, + markViewedLocally: Function, + observation: Object, + refetchRemoteObservation: Function, + remoteObservation: ?Object, + remoteObsWasDeleted: boolean, + setRemoteObsWasDeleted: Function, + targetActivityItemID?: ?number, + uuid: string +} + +const ObsDetailsDefaultModeContainer = ( props: Props ): Node => { const setObservations = useStore( state => state.setObservations ); - const currentUser = useCurrentUser( ); - const { params } = useRoute(); - const { - targetActivityItemID, - uuid - } = params; const navigation = useNavigation( ); const realm = useRealm( ); - const { isConnected } = useNetInfo( ); const [state, dispatch] = useReducer( reducer, initialState ); - const [remoteObsWasDeleted, setRemoteObsWasDeleted] = useState( false ); + + const { + observation, + targetActivityItemID, + uuid, + localObservation, + markViewedLocally, + markDeletedLocally, + remoteObservation, + setRemoteObsWasDeleted, + fetchRemoteObservationError, + currentUser, + belongsToCurrentUser, + isRefetching, + refetchRemoteObservation, + isConnected, + remoteObsWasDeleted + } = props; const { activityItems, @@ -133,26 +154,6 @@ const ObsDetailsDefaultModeContainer = ( ): Node => { } = state; const queryClient = useQueryClient( ); - const { - localObservation, - markDeletedLocally, - markViewedLocally - } = useLocalObservation( uuid ); - const wasSynced = localObservation && localObservation?.wasSynced(); - - const fetchRemoteObservationEnabled = !!( - !remoteObsWasDeleted - && ( !localObservation || localObservation?.wasSynced( ) ) - && isConnected - ); - - const { - remoteObservation, - refetchRemoteObservation, - isRefetching, - fetchRemoteObservationError - } = useRemoteObservation( uuid, fetchRemoteObservationEnabled ); - useMarkViewedMutation( localObservation, markViewedLocally, remoteObservation ); // If we tried to get a remote observation but it no longer exists, the user @@ -160,7 +161,8 @@ const ObsDetailsDefaultModeContainer = ( ): Node => { // copy of this observation useEffect( ( ) => { setRemoteObsWasDeleted( fetchRemoteObservationError?.status === 404 ); - }, [fetchRemoteObservationError?.status] ); + }, [fetchRemoteObservationError?.status, setRemoteObsWasDeleted] ); + const confirmRemoteObsWasDeleted = useCallback( ( ) => { if ( localObservation ) { markDeletedLocally( ); @@ -172,27 +174,10 @@ const ObsDetailsDefaultModeContainer = ( ): Node => { navigation ] ); - const observation = localObservation || Observation.mapApiToRealm( remoteObservation ); + const wasSynced = !!( localObservation && localObservation?.wasSynced() ); + const hasPhotos = observation?.observationPhotos?.length > 0; - // In theory the only situation in which an observation would not have a - // user is when a user is not signed but has made a new observation in the - // app. Also in theory that user should not be able to get to ObsDetail for - // those observations, just ObsEdit. But.... let's be safe. - const belongsToCurrentUser = ( - observation?.user?.id === currentUser?.id - || ( !observation?.user && !observation?.id ) - ); - - const isSimpleMode = useMemo( () => ( - // Simple mode applies only when: - // 1. It's the current user's observation (or an observation being created) - // 2. AND the observation hasn't been synced yet - ( belongsToCurrentUser || !observation?.user ) - && localObservation - && !localObservation.wasSynced() - ), [belongsToCurrentUser, localObservation, observation?.user] ); - const { data: subscriptions, refetch: refetchSubscriptions } = useAuthenticatedQuery( [ "fetchSubscriptions" @@ -325,7 +310,7 @@ const ObsDetailsDefaultModeContainer = ( ): Node => { const localComments = localObservation?.comments; const newComment = data[0]; newComment.user = currentUser; - localComments.push( newComment ); + localComments?.push( newComment ); }, "setting local comment in ObsDetailsContainer" ); const updatedLocalObservation = realm.objectForPrimaryKey( "Observation", uuid ); dispatch( { type: "ADD_ACTIVITY_ITEM", observationShown: updatedLocalObservation } ); @@ -355,7 +340,6 @@ const ObsDetailsDefaultModeContainer = ( ): Node => { belongsToCurrentUser={belongsToCurrentUser} currentUser={currentUser} isConnected={isConnected} - isSimpleMode={isSimpleMode} navToSuggestions={navToSuggestions} observation={observationShown} openAddCommentSheet={openAddCommentSheet} diff --git a/src/components/ObsDetailsDefaultMode/ObsDetailsDefaultModeScreensWrapper.tsx b/src/components/ObsDetailsDefaultMode/ObsDetailsDefaultModeScreensWrapper.tsx new file mode 100644 index 000000000..9ada16fef --- /dev/null +++ b/src/components/ObsDetailsDefaultMode/ObsDetailsDefaultModeScreensWrapper.tsx @@ -0,0 +1,99 @@ +import { useNetInfo } from "@react-native-community/netinfo"; +import { useRoute } from "@react-navigation/native"; +import ObsDetailsDefaultModeContainer + from "components/ObsDetailsDefaultMode/ObsDetailsDefaultModeContainer"; +import React, { useMemo, useState } from "react"; +import Observation from "realmModels/Observation"; +import { + useCurrentUser, + useLocalObservation, + useRemoteObservation +} from "sharedHooks"; + +import SavedMatchContainer from "./SavedMatch/SavedMatchContainer"; + +type RouteParams = { + targetActivityItemID?: number, + uuid: string, +} + +const ObsDetailsDefaultModeScreensWrapper = () => { + const { params } = useRoute(); + const { + targetActivityItemID, + uuid + } = params as RouteParams; + const currentUser = useCurrentUser( ); + const isConnected = !!useNetInfo( ).isConnected; + + const [remoteObsWasDeleted, setRemoteObsWasDeleted] = useState( false ); + + const { + localObservation, + markDeletedLocally, + markViewedLocally + } = useLocalObservation( uuid ); + + const fetchRemoteObservationEnabled = !!( + !remoteObsWasDeleted + && ( !localObservation || localObservation?.wasSynced( ) ) + && isConnected + ); + + const { + remoteObservation, + refetchRemoteObservation, + isRefetching, + fetchRemoteObservationError + } = useRemoteObservation( uuid, fetchRemoteObservationEnabled ); + + const observation = localObservation || Observation.mapApiToRealm( remoteObservation ); + + // In theory the only situation in which an observation would not have a + // user is when a user is not signed but has made a new observation in the + // app. Also in theory that user should not be able to get to ObsDetail for + // those observations, just ObsEdit. But.... let's be safe. + const belongsToCurrentUser = ( + observation?.user?.id === currentUser?.id + || ( !observation?.user && !observation?.id ) + ); + + const showSavedMatch = useMemo( () => ( + // Saved match screen is used when: + // 1. It's the current user's observation (or an observation being created) + // 2. AND the observation hasn't been synced yet + !!( ( belongsToCurrentUser || !observation?.user ) + && localObservation + && !localObservation.wasSynced() ) + ), [belongsToCurrentUser, localObservation, observation?.user] ); + + if ( showSavedMatch ) { + return ( + + ); + } + + return ( + + ); +}; + +export default ObsDetailsDefaultModeScreensWrapper; diff --git a/src/components/ObsDetailsDefaultMode/SavedMatch/SavedMatch.tsx b/src/components/ObsDetailsDefaultMode/SavedMatch/SavedMatch.tsx new file mode 100644 index 000000000..1421a93ee --- /dev/null +++ b/src/components/ObsDetailsDefaultMode/SavedMatch/SavedMatch.tsx @@ -0,0 +1,69 @@ +import { useNetInfo } from "@react-native-community/netinfo"; +import { matchCardClassBottom, matchCardClassTop } from "components/Match/Match"; +import MatchHeader from "components/Match/MatchHeader"; +import PhotosSection from "components/Match/PhotosSection"; +import LocationSection from "components/ObsDetailsDefaultMode/LocationSection/LocationSection"; +import MapSection from "components/ObsDetailsDefaultMode/MapSection/MapSection"; +import { Button, ScrollViewWrapper } from "components/SharedComponents"; +import { View } from "components/styledComponents"; +import _ from "lodash"; +import React from "react"; +import type { RealmObservation } from "realmModels/types"; +import { useTranslation } from "sharedHooks"; + +import SavedMatchHeaderRight from "./SavedMatchHeaderRight"; + +interface Props { + observation: RealmObservation, + navToTaxonDetails: ( ) => void, +} + +const SavedMatch = ( { + observation, + navToTaxonDetails +}: Props ) => { + const { t } = useTranslation( ); + const { isConnected } = useNetInfo( ); + + const latitude = observation?.privateLatitude || observation?.latitude; + const { taxon } = observation; + + return ( + + + + + + + + {latitude && ( + + )} + + + { + isConnected && ( +