mirror of
https://github.com/inaturalist/iNaturalistReactNative.git
synced 2026-05-18 21:35:46 -04:00
Refactor ObsDetails and show ID after agree button pressed (#738)
This commit is contained in:
committed by
GitHub
parent
a17f8e026d
commit
5bfa7940e1
@@ -1,128 +0,0 @@
|
||||
// @flow
|
||||
|
||||
import ActivityHeader from "components/ObsDetails/ActivityHeader";
|
||||
import AgreeWithIDSheet from "components/ObsDetails/Sheets/AgreeWithIDSheet";
|
||||
import {
|
||||
Divider, INatIcon, UserText
|
||||
} from "components/SharedComponents";
|
||||
import DisplayTaxon from "components/SharedComponents/DisplayTaxon";
|
||||
import {
|
||||
Pressable, View
|
||||
} from "components/styledComponents";
|
||||
import { t } from "i18next";
|
||||
import _ from "lodash";
|
||||
import type { Node } from "react";
|
||||
import React, { useState } from "react";
|
||||
import { textStyles } from "styles/obsDetails/obsDetails";
|
||||
|
||||
import AddCommentModal from "./AddCommentModal";
|
||||
|
||||
type Props = {
|
||||
item: Object,
|
||||
navToTaxonDetails: Function,
|
||||
toggleRefetch: Function,
|
||||
refetchRemoteObservation: Function,
|
||||
onAgree: Function,
|
||||
currentUserId?: Number,
|
||||
observationUUID: string,
|
||||
userAgreedId?: string
|
||||
}
|
||||
|
||||
const ActivityItem = ( {
|
||||
item, navToTaxonDetails, toggleRefetch, refetchRemoteObservation, onAgree, currentUserId,
|
||||
observationUUID, userAgreedId
|
||||
}: Props ): Node => {
|
||||
const { taxon, user } = item;
|
||||
const userId = currentUserId;
|
||||
const showAgreeButton = taxon && user && user.id !== userId && taxon.rank_level <= 10
|
||||
&& userAgreedId !== taxon?.id;
|
||||
const [showAgreeWithIdSheet, setShowAgreeWithIdSheet] = useState( false );
|
||||
const [showCommentBox, setShowCommentBox] = useState( false );
|
||||
const [comment, setComment] = useState( "" );
|
||||
|
||||
const isCurrent = item.current !== undefined
|
||||
? item.current
|
||||
: undefined;
|
||||
|
||||
const idWithdrawn = isCurrent !== undefined && !isCurrent;
|
||||
|
||||
const onAgreePressed = () => {
|
||||
const agreeParams = {
|
||||
observation_id: observationUUID,
|
||||
taxon_id: taxon?.id,
|
||||
body: comment
|
||||
};
|
||||
|
||||
onAgree( agreeParams );
|
||||
setShowAgreeWithIdSheet( false );
|
||||
};
|
||||
|
||||
const openCommentBox = () => setShowCommentBox( true );
|
||||
|
||||
const onCommentAdded = newComment => {
|
||||
setComment( newComment );
|
||||
};
|
||||
|
||||
const agreeIdSheetDiscardChanges = () => {
|
||||
setComment( "" );
|
||||
setShowAgreeWithIdSheet( false );
|
||||
};
|
||||
|
||||
const onIDAgreePressed = () => {
|
||||
setShowAgreeWithIdSheet( true );
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="flex-column ml-[15px]">
|
||||
<ActivityHeader
|
||||
item={item}
|
||||
refetchRemoteObservation={refetchRemoteObservation}
|
||||
toggleRefetch={toggleRefetch}
|
||||
/>
|
||||
{taxon && (
|
||||
<View className="flex-row items-center justify-between mr-[23px] mb-4">
|
||||
<DisplayTaxon
|
||||
taxon={taxon}
|
||||
handlePress={navToTaxonDetails}
|
||||
accessibilityLabel={t( "Navigate-to-taxon-details" )}
|
||||
withdrawn={idWithdrawn}
|
||||
/>
|
||||
{ showAgreeButton && (
|
||||
<Pressable
|
||||
testID="ActivityItem.AgreeIdButton"
|
||||
accessibilityRole="button"
|
||||
onPress={onIDAgreePressed}
|
||||
>
|
||||
<INatIcon name="id-agree" size={33} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
{ !_.isEmpty( item?.body ) && (
|
||||
<View className="flex-row">
|
||||
<UserText baseStyle={textStyles.activityItemBody} text={item.body} />
|
||||
</View>
|
||||
)}
|
||||
<Divider />
|
||||
<AgreeWithIDSheet
|
||||
showAgreeWithIdSheet={showAgreeWithIdSheet}
|
||||
comment={comment}
|
||||
openCommentBox={openCommentBox}
|
||||
taxon={taxon}
|
||||
discardChanges={agreeIdSheetDiscardChanges}
|
||||
handleClose={agreeIdSheetDiscardChanges}
|
||||
onAgree={onAgreePressed}
|
||||
/>
|
||||
<AddCommentModal
|
||||
edit
|
||||
commentToEdit={comment}
|
||||
onCommentAdded={onCommentAdded}
|
||||
title={t( "ADD-OPTIONAL-COMMENT" )}
|
||||
showCommentBox={showCommentBox}
|
||||
setShowCommentBox={setShowCommentBox}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActivityItem;
|
||||
@@ -1,131 +0,0 @@
|
||||
// @flow
|
||||
import createIdentification from "api/identifications";
|
||||
import { Text, View } from "components/styledComponents";
|
||||
import { formatISO } from "date-fns";
|
||||
import { t } from "i18next";
|
||||
import * as React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert } from "react-native";
|
||||
import useAuthenticatedMutation from "sharedHooks/useAuthenticatedMutation";
|
||||
import useCurrentUser from "sharedHooks/useCurrentUser";
|
||||
|
||||
import ActivityItem from "./ActivityItem";
|
||||
|
||||
type Props = {
|
||||
observation:Object,
|
||||
comments:Array<Object>,
|
||||
navToTaxonDetails: Function,
|
||||
toggleRefetch: Function,
|
||||
refetchRemoteObservation: Function,
|
||||
uuid: string
|
||||
}
|
||||
|
||||
const ActivityTab = ( {
|
||||
observation, comments, navToTaxonDetails,
|
||||
toggleRefetch, refetchRemoteObservation, uuid
|
||||
}: Props ): React.Node => {
|
||||
const currentUser = useCurrentUser( );
|
||||
const userId = currentUser?.id;
|
||||
const [ids, setIds] = useState<Array<Object>>( [] );
|
||||
|
||||
const onAgreeErrorAlert = ( ) => {
|
||||
Alert.alert(
|
||||
"Cannot agree with ID at this time"
|
||||
);
|
||||
};
|
||||
|
||||
const onIDAdded = async identification => {
|
||||
// Add temporary ID to observation.identifications ("ghosted" ID, while we're trying to add it)
|
||||
const newId = {
|
||||
body: identification.body,
|
||||
taxon: identification.taxon,
|
||||
user: {
|
||||
id: userId,
|
||||
login: currentUser?.login,
|
||||
signedIn: true,
|
||||
icon_url: currentUser?.icon_url
|
||||
},
|
||||
created_at: formatISO( Date.now() ),
|
||||
uuid: identification.uuid,
|
||||
vision: false,
|
||||
// This tells us to render is ghosted (since it's temporarily visible
|
||||
// until getting a response from the server)
|
||||
temporary: true
|
||||
};
|
||||
setIds( [newId, ...ids] );
|
||||
};
|
||||
|
||||
const createIdentificationMutation = useAuthenticatedMutation(
|
||||
( params, optsWithAuth ) => createIdentification( params, optsWithAuth ),
|
||||
{
|
||||
onSuccess: data => {
|
||||
// TODO
|
||||
// reload activity list to update suggest id icon
|
||||
toggleRefetch();
|
||||
onIDAdded( data[0] );
|
||||
if ( refetchRemoteObservation ) {
|
||||
refetchRemoteObservation( );
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
onAgreeErrorAlert();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const onAgree = agreeParams => {
|
||||
createIdentificationMutation.mutate( { identification: agreeParams } );
|
||||
};
|
||||
|
||||
// finds the user's most recent id
|
||||
const findRecentUserAgreedToID = () => {
|
||||
const currentIds = observation?.identifications;
|
||||
const userAgree = currentIds.filter( id => id.user?.id === userId );
|
||||
return userAgree.length > 0
|
||||
? userAgree[userAgree.length - 1].taxon.id
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const userAgreedToId = findRecentUserAgreedToID();
|
||||
|
||||
useEffect( ( ) => {
|
||||
// set initial ids for activity tab
|
||||
const currentIds = observation?.identifications;
|
||||
if ( currentIds
|
||||
&& ids.length === 0
|
||||
&& currentIds.length !== ids.length ) {
|
||||
setIds( currentIds );
|
||||
}
|
||||
}, [observation, ids] );
|
||||
|
||||
if ( comments.length === 0 && ids.length === 0 ) {
|
||||
return <Text>{t( "No-comments-or-ids-to-display" )}</Text>;
|
||||
}
|
||||
|
||||
const activityItems = ids.concat( [...comments] )
|
||||
.sort( ( a, b ) => ( new Date( a.created_at ) - new Date( b.created_at ) ) );
|
||||
|
||||
// this should all perform similarly to the activity tab on web
|
||||
// https://github.com/inaturalist/inaturalist/blob/df6572008f60845b8ef5972a92a9afbde6f67829/app/webpack/observations/show/components/activity_item.jsx
|
||||
const activitytemsList = activityItems.map( item => (
|
||||
<ActivityItem
|
||||
userAgreedId={userAgreedToId}
|
||||
key={item.uuid}
|
||||
observationUUID={uuid}
|
||||
item={item}
|
||||
navToTaxonDetails={navToTaxonDetails}
|
||||
toggleRefetch={toggleRefetch}
|
||||
refetchRemoteObservation={refetchRemoteObservation}
|
||||
onAgree={onAgree}
|
||||
currentUserId={userId}
|
||||
/>
|
||||
) );
|
||||
|
||||
return (
|
||||
<View testID="ActivityTab">
|
||||
{activitytemsList}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActivityTab;
|
||||
@@ -6,7 +6,6 @@ import classnames from "classnames";
|
||||
import { isCurrentUser } from "components/LoginSignUp/AuthenticationService";
|
||||
import FlagItemModal from "components/ObsDetails/FlagItemModal";
|
||||
import { Body4, INatIcon, InlineUser } from "components/SharedComponents";
|
||||
import DateDisplay from "components/SharedComponents/DateDisplay";
|
||||
import KebabMenu from "components/SharedComponents/KebabMenu";
|
||||
import {
|
||||
View
|
||||
@@ -26,12 +25,13 @@ const { useRealm } = RealmContext;
|
||||
type Props = {
|
||||
item: Object,
|
||||
refetchRemoteObservation?: Function,
|
||||
toggleRefetch?: Function,
|
||||
classNameMargin?: string
|
||||
classNameMargin?: string,
|
||||
idWithdrawn: boolean
|
||||
}
|
||||
|
||||
const ActivityHeader = ( {
|
||||
item, refetchRemoteObservation, toggleRefetch, classNameMargin
|
||||
item, refetchRemoteObservation, classNameMargin,
|
||||
idWithdrawn
|
||||
}:Props ): Node => {
|
||||
const [currentUser, setCurrentUser] = useState( null );
|
||||
const [kebabMenuVisible, setKebabMenuVisible] = useState( false );
|
||||
@@ -40,11 +40,6 @@ const ActivityHeader = ( {
|
||||
const realm = useRealm( );
|
||||
const queryClient = useQueryClient( );
|
||||
const { user } = item;
|
||||
const isCurrent = item.current !== undefined
|
||||
? item.current
|
||||
: undefined;
|
||||
|
||||
const idWithdrawn = isCurrent !== undefined && !isCurrent;
|
||||
|
||||
const itemType = item.category
|
||||
? "Identification"
|
||||
@@ -121,59 +116,58 @@ const ActivityHeader = ( {
|
||||
);
|
||||
};
|
||||
|
||||
const ifCommentOrID = () => (
|
||||
<View className="flex-row items-center space-x-[15px]">
|
||||
{renderIcon()}
|
||||
{
|
||||
renderStatus()
|
||||
}
|
||||
{item.created_at
|
||||
return (
|
||||
<View className={classnames( "flex-row justify-between", classNameMargin )}>
|
||||
<InlineUser user={user} />
|
||||
<View className="flex-row items-center space-x-[15px]">
|
||||
{renderIcon()}
|
||||
{
|
||||
renderStatus()
|
||||
}
|
||||
{item.created_at
|
||||
&& (
|
||||
<Body4>
|
||||
{formatIdDate( item.updated_at || item.created_at, t )}
|
||||
</Body4>
|
||||
)}
|
||||
{
|
||||
item.body && currentUser
|
||||
? (
|
||||
<KebabMenu
|
||||
visible={kebabMenuVisible}
|
||||
setVisible={setKebabMenuVisible}
|
||||
>
|
||||
<Menu.Item
|
||||
onPress={async ( ) => {
|
||||
{
|
||||
item.body && currentUser
|
||||
? (
|
||||
<KebabMenu
|
||||
visible={kebabMenuVisible}
|
||||
setVisible={setKebabMenuVisible}
|
||||
>
|
||||
<Menu.Item
|
||||
onPress={async ( ) => {
|
||||
// first delete locally
|
||||
Comment.deleteComment( item.uuid, realm );
|
||||
// then delete remotely
|
||||
deleteCommentMutation.mutate( item.uuid );
|
||||
if ( toggleRefetch ) {
|
||||
toggleRefetch( );
|
||||
}
|
||||
setKebabMenuVisible( false );
|
||||
}}
|
||||
title={t( "Delete-comment" )}
|
||||
/>
|
||||
</KebabMenu>
|
||||
)
|
||||
: (
|
||||
<KebabMenu
|
||||
visible={kebabMenuVisible}
|
||||
setVisible={setKebabMenuVisible}
|
||||
>
|
||||
{!currentUser
|
||||
? (
|
||||
<Menu.Item
|
||||
onPress={() => setFlagModalVisible( true )}
|
||||
title={t( "Flag" )}
|
||||
testID="MenuItem.Flag"
|
||||
/>
|
||||
)
|
||||
: undefined}
|
||||
<View />
|
||||
</KebabMenu>
|
||||
)
|
||||
}
|
||||
{!currentUser
|
||||
Comment.deleteComment( item.uuid, realm );
|
||||
// then delete remotely
|
||||
deleteCommentMutation.mutate( item.uuid );
|
||||
setKebabMenuVisible( false );
|
||||
}}
|
||||
title={t( "Delete-comment" )}
|
||||
/>
|
||||
</KebabMenu>
|
||||
)
|
||||
: (
|
||||
<KebabMenu
|
||||
visible={kebabMenuVisible}
|
||||
setVisible={setKebabMenuVisible}
|
||||
>
|
||||
{!currentUser
|
||||
? (
|
||||
<Menu.Item
|
||||
onPress={() => setFlagModalVisible( true )}
|
||||
title={t( "Flag" )}
|
||||
testID="MenuItem.Flag"
|
||||
/>
|
||||
)
|
||||
: undefined}
|
||||
<View />
|
||||
</KebabMenu>
|
||||
)
|
||||
}
|
||||
{!currentUser
|
||||
&& (
|
||||
<FlagItemModal
|
||||
id={item.id}
|
||||
@@ -183,15 +177,7 @@ const ActivityHeader = ( {
|
||||
onItemFlagged={onItemFlagged}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View className={classnames( "flex-row justify-between", classNameMargin )}>
|
||||
<InlineUser user={user} />
|
||||
{( item._created_at )
|
||||
? <DateDisplay dateString={item.created_at} />
|
||||
: ifCommentOrID()}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
82
src/components/ObsDetails/ActivityTab/ActivityItem.js
Normal file
82
src/components/ObsDetails/ActivityTab/ActivityItem.js
Normal file
@@ -0,0 +1,82 @@
|
||||
// @flow
|
||||
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import {
|
||||
Divider, INatIcon, UserText
|
||||
} from "components/SharedComponents";
|
||||
import DisplayTaxon from "components/SharedComponents/DisplayTaxon";
|
||||
import {
|
||||
Pressable, View
|
||||
} from "components/styledComponents";
|
||||
import { t } from "i18next";
|
||||
import _ from "lodash";
|
||||
import type { Node } from "react";
|
||||
import React from "react";
|
||||
import { textStyles } from "styles/obsDetails/obsDetails";
|
||||
|
||||
import ActivityHeader from "./ActivityHeader";
|
||||
|
||||
type Props = {
|
||||
item: Object,
|
||||
refetchRemoteObservation: Function,
|
||||
currentUserId?: Number,
|
||||
userAgreedId?: string,
|
||||
onIDAgreePressed: Function
|
||||
}
|
||||
|
||||
const ActivityItem = ( {
|
||||
item, refetchRemoteObservation, currentUserId,
|
||||
userAgreedId, onIDAgreePressed
|
||||
}: Props ): Node => {
|
||||
const navigation = useNavigation( );
|
||||
const { taxon, user } = item;
|
||||
const userId = currentUserId;
|
||||
const isCurrent = item.current !== undefined
|
||||
? item.current
|
||||
: undefined;
|
||||
|
||||
const idWithdrawn = isCurrent !== undefined && !isCurrent;
|
||||
|
||||
const showAgreeButton = ( user?.id !== userId )
|
||||
&& taxon?.rank_level <= 10
|
||||
&& ( userAgreedId !== taxon?.id );
|
||||
|
||||
const navToTaxonDetails = ( ) => navigation.navigate( "TaxonDetails", { id: taxon.id } );
|
||||
|
||||
return (
|
||||
<View className="flex-column ml-[15px]">
|
||||
<ActivityHeader
|
||||
item={item}
|
||||
refetchRemoteObservation={refetchRemoteObservation}
|
||||
idWithdrawn={idWithdrawn}
|
||||
/>
|
||||
{taxon && (
|
||||
<View className="flex-row items-center justify-between mr-[23px] mb-4">
|
||||
<DisplayTaxon
|
||||
taxon={taxon}
|
||||
handlePress={navToTaxonDetails}
|
||||
accessibilityLabel={t( "Navigate-to-taxon-details" )}
|
||||
withdrawn={idWithdrawn}
|
||||
/>
|
||||
{ showAgreeButton && (
|
||||
<Pressable
|
||||
testID="ActivityItem.AgreeIdButton"
|
||||
accessibilityRole="button"
|
||||
onPress={onIDAgreePressed}
|
||||
>
|
||||
<INatIcon name="id-agree" size={33} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
{ !_.isEmpty( item?.body ) && (
|
||||
<View className="flex-row">
|
||||
<UserText baseStyle={textStyles.activityItemBody} text={item.body} />
|
||||
</View>
|
||||
)}
|
||||
<Divider />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActivityItem;
|
||||
55
src/components/ObsDetails/ActivityTab/ActivityTab.js
Normal file
55
src/components/ObsDetails/ActivityTab/ActivityTab.js
Normal file
@@ -0,0 +1,55 @@
|
||||
// @flow
|
||||
import { Body3 } from "components/SharedComponents";
|
||||
import { View } from "components/styledComponents";
|
||||
import * as React from "react";
|
||||
import { useCurrentUser, useTranslation } from "sharedHooks";
|
||||
|
||||
import ActivityItem from "./ActivityItem";
|
||||
|
||||
type Props = {
|
||||
observation:Object,
|
||||
refetchRemoteObservation: Function,
|
||||
activityItems: Array<Object>,
|
||||
onIDAgreePressed: Function
|
||||
}
|
||||
|
||||
const ActivityTab = ( {
|
||||
observation,
|
||||
refetchRemoteObservation,
|
||||
activityItems,
|
||||
onIDAgreePressed
|
||||
}: Props ): React.Node => {
|
||||
const { t } = useTranslation( );
|
||||
const currentUser = useCurrentUser( );
|
||||
const userId = currentUser?.id;
|
||||
|
||||
// finds the user's most recent id
|
||||
const findRecentUserAgreedToID = ( ) => {
|
||||
const currentIds = observation?.identifications;
|
||||
const userAgree = currentIds.filter( id => id.user?.id === userId );
|
||||
return userAgree.length > 0 && userAgree[userAgree.length - 1].current
|
||||
? userAgree[userAgree.length - 1].taxon.id
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const userAgreedToId = findRecentUserAgreedToID( );
|
||||
|
||||
return (
|
||||
<View testID="ActivityTab">
|
||||
{activityItems.length === 0
|
||||
? <Body3>{t( "No-comments-or-ids-to-display" )}</Body3>
|
||||
: activityItems.map( item => (
|
||||
<ActivityItem
|
||||
userAgreedId={userAgreedToId}
|
||||
key={item.uuid}
|
||||
item={item}
|
||||
refetchRemoteObservation={refetchRemoteObservation}
|
||||
onIDAgreePressed={onIDAgreePressed}
|
||||
currentUserId={userId}
|
||||
/>
|
||||
) )}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActivityTab;
|
||||
60
src/components/ObsDetails/ActivityTab/FloatingButtons.js
Normal file
60
src/components/ObsDetails/ActivityTab/FloatingButtons.js
Normal file
@@ -0,0 +1,60 @@
|
||||
// @flow
|
||||
import {
|
||||
Button
|
||||
} from "components/SharedComponents";
|
||||
import { View } from "components/styledComponents";
|
||||
import type { Node } from "react";
|
||||
import React from "react";
|
||||
import {
|
||||
useTranslation
|
||||
} from "sharedHooks";
|
||||
import { getShadowStyle } from "styles/global";
|
||||
import colors from "styles/tailwindColors";
|
||||
|
||||
type Props = {
|
||||
navToAddID: Function,
|
||||
showCommentBox: Function,
|
||||
openCommentBox: Function
|
||||
}
|
||||
|
||||
const FloatingButtons = ( {
|
||||
navToAddID,
|
||||
openCommentBox,
|
||||
showCommentBox
|
||||
}: Props ): Node => {
|
||||
const { t } = useTranslation( );
|
||||
|
||||
return (
|
||||
<View
|
||||
className="flex-row justify-evenly bottom-[80px] bg-white pt-4 pb-8"
|
||||
style={getShadowStyle( {
|
||||
shadowColor: colors.black,
|
||||
offsetWidth: 0,
|
||||
offsetHeight: -3,
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 2,
|
||||
radius: 5,
|
||||
elevation: 5
|
||||
} )}
|
||||
>
|
||||
<Button
|
||||
text={t( "COMMENT" )}
|
||||
onPress={openCommentBox}
|
||||
className="mx-3 grow"
|
||||
testID="ObsDetail.commentButton"
|
||||
disabled={showCommentBox}
|
||||
accessibilityHint={t( "Opens-add-comment-modal" )}
|
||||
/>
|
||||
<Button
|
||||
text={t( "SUGGEST-ID" )}
|
||||
onPress={navToAddID}
|
||||
className="mx-3 grow"
|
||||
testID="ObsDetail.cvSuggestionsButton"
|
||||
accessibilityRole="link"
|
||||
accessibilityHint={t( "Navigates-to-suggest-identification" )}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default FloatingButtons;
|
||||
@@ -1,165 +0,0 @@
|
||||
// @flow
|
||||
|
||||
import {
|
||||
BottomSheetModal
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import {
|
||||
Body3, BottomSheetStandardBackdrop, Button, Heading4, INatIconButton
|
||||
} from "components/SharedComponents";
|
||||
import { BottomSheetTextInput, Pressable, View } from "components/styledComponents";
|
||||
import type { Node } from "react";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect, useRef, useState
|
||||
} from "react";
|
||||
import {
|
||||
Keyboard
|
||||
} from "react-native";
|
||||
import { useTheme } from "react-native-paper";
|
||||
import useTranslation from "sharedHooks/useTranslation";
|
||||
import { viewStyles } from "styles/obsDetails/obsDetails";
|
||||
import colors from "styles/tailwindColors";
|
||||
|
||||
type Props = {
|
||||
title?: string,
|
||||
onCommentAdded: Function,
|
||||
showCommentBox: boolean,
|
||||
setShowCommentBox: Function,
|
||||
setAddingComment?: Function,
|
||||
commentToEdit?: string,
|
||||
edit?: boolean
|
||||
}
|
||||
|
||||
const AddCommentModal = ( {
|
||||
title,
|
||||
onCommentAdded,
|
||||
showCommentBox,
|
||||
setShowCommentBox,
|
||||
setAddingComment,
|
||||
commentToEdit,
|
||||
edit
|
||||
}: Props ): Node => {
|
||||
const { t } = useTranslation( );
|
||||
const [comment, setComment] = useState( "" );
|
||||
const bottomSheetModalRef = useRef( null );
|
||||
const [snapPoint, setSnapPoint] = useState( 100 );
|
||||
const theme = useTheme();
|
||||
|
||||
// Clear the comment in a timeout so it doesn't trigger a re-render of the
|
||||
// text input *after* the bottom sheet modal gets dismissed, b/c that seems
|
||||
// to re-render the bottom sheet in a presented state, making it hard to
|
||||
// actually dismiss
|
||||
const clearComment = ( ) => setTimeout( ( ) => setComment( "" ), 100 );
|
||||
|
||||
// Make bottom sheet modal visibility reactive instead of imperative
|
||||
useEffect( ( ) => {
|
||||
if ( showCommentBox ) {
|
||||
bottomSheetModalRef.current?.present( );
|
||||
if ( edit && commentToEdit ) {
|
||||
setComment( commentToEdit );
|
||||
}
|
||||
} else {
|
||||
bottomSheetModalRef.current?.dismiss( );
|
||||
}
|
||||
}, [showCommentBox, bottomSheetModalRef, commentToEdit, edit] );
|
||||
|
||||
const renderHandle = () => <View />;
|
||||
|
||||
const renderBackdrop = props => (
|
||||
<BottomSheetStandardBackdrop props={props} />
|
||||
);
|
||||
|
||||
const clearAndCloseCommentBox = useCallback( ( ) => {
|
||||
clearComment( );
|
||||
setShowCommentBox( false );
|
||||
Keyboard.dismiss();
|
||||
}, [setShowCommentBox] );
|
||||
|
||||
const submitComment = async ( ) => {
|
||||
if ( setAddingComment ) {
|
||||
setAddingComment( true );
|
||||
}
|
||||
if ( comment.length > 0 || edit ) {
|
||||
onCommentAdded( comment );
|
||||
}
|
||||
clearAndCloseCommentBox( );
|
||||
};
|
||||
|
||||
const handleSheetChanges = useCallback( index => {
|
||||
// re-enable Add Comment button when backdrop is tapped to close modal
|
||||
if ( index === -1 ) {
|
||||
clearAndCloseCommentBox( );
|
||||
}
|
||||
}, [clearAndCloseCommentBox] );
|
||||
|
||||
const renderTextInput = () => (
|
||||
<BottomSheetTextInput
|
||||
keyboardType="default"
|
||||
className="mb-16 h-16 mt-4"
|
||||
defaultValue={comment}
|
||||
selectionColor={theme.colors.tertiary}
|
||||
activeUnderlineColor={theme.colors.background}
|
||||
placeholder={t( "Add-a-comment" )}
|
||||
autoFocus
|
||||
multiline
|
||||
onChangeText={setComment}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<BottomSheetModal
|
||||
ref={bottomSheetModalRef}
|
||||
index={0}
|
||||
enableOverDrag={false}
|
||||
enablePanDownToClose
|
||||
snapPoints={[snapPoint]}
|
||||
backdropComponent={renderBackdrop}
|
||||
handleComponent={renderHandle}
|
||||
// TODO: figure out how to style shadows/elevation using Tailwind
|
||||
style={viewStyles.bottomModal}
|
||||
onChange={handleSheetChanges}
|
||||
>
|
||||
<View
|
||||
className="flex-col p-[20px] items-center"
|
||||
onLayout={( {
|
||||
nativeEvent: {
|
||||
layout: { height }
|
||||
}
|
||||
} ) => {
|
||||
setSnapPoint( height + 20 );
|
||||
}}
|
||||
>
|
||||
<INatIconButton
|
||||
onPress={clearAndCloseCommentBox}
|
||||
className="absolute top-1 right-1"
|
||||
icon="close"
|
||||
color={colors.darkGray}
|
||||
/>
|
||||
{title
|
||||
? <Heading4>{title}</Heading4>
|
||||
: <Heading4>{t( "ADD-COMMENT" )}</Heading4>}
|
||||
|
||||
<View className="border border-lightGray p-[5px] m-[10px] my-[15px] w-full">
|
||||
{renderTextInput()}
|
||||
<Pressable
|
||||
className="absolute bottom-2 right-2"
|
||||
accessibilityRole="button"
|
||||
onPress={() => setComment( "" )}
|
||||
>
|
||||
<Body3 className="opacity-50">{t( "Clear" )}</Body3>
|
||||
</Pressable>
|
||||
</View>
|
||||
<Button
|
||||
accessibilityRole="button"
|
||||
level="primary"
|
||||
className="w-full"
|
||||
onPress={( ) => submitComment( )}
|
||||
text={t( "CONFIRM" )}
|
||||
/>
|
||||
</View>
|
||||
|
||||
</BottomSheetModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddCommentModal;
|
||||
@@ -1,7 +1,7 @@
|
||||
// @flow
|
||||
import { useRoute } from "@react-navigation/native";
|
||||
import { deleteQualityMetric, fetchQualityMetrics, setQualityMetric } from "api/qualityMetrics";
|
||||
import DQAVoteButtons from "components/ObsDetails/DQAVoteButtons";
|
||||
import DQAVoteButtons from "components/ObsDetails/DetailsTab/DQAVoteButtons";
|
||||
import PlaceholderText from "components/PlaceholderText";
|
||||
import {
|
||||
Body3,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// @flow
|
||||
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import checkCamelAndSnakeCase from "components/ObsDetails/helpers/checkCamelAndSnakeCase";
|
||||
import {
|
||||
Body4,
|
||||
Button,
|
||||
@@ -21,7 +22,6 @@ import { Alert, Linking } from "react-native";
|
||||
import { Menu, useTheme } from "react-native-paper";
|
||||
|
||||
import Attribution from "./Attribution";
|
||||
import checkCamelAndSnakeCase from "./helpers/checkCamelAndSnakeCase";
|
||||
|
||||
type Props = {
|
||||
observation: Object,
|
||||
@@ -1,303 +1,68 @@
|
||||
// @flow
|
||||
import { HeaderBackButton } from "@react-navigation/elements";
|
||||
import { useNavigation, useRoute } from "@react-navigation/native";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { createComment } from "api/comments";
|
||||
import createIdentification from "api/identifications";
|
||||
import AgreeWithIDSheet from "components/ObsDetails/Sheets/AgreeWithIDSheet";
|
||||
import {
|
||||
faveObservation,
|
||||
fetchRemoteObservation,
|
||||
markObservationUpdatesViewed,
|
||||
unfaveObservation
|
||||
} from "api/observations";
|
||||
import ActivityHeader from "components/ObsDetails/ActivityHeader";
|
||||
import {
|
||||
Button, ObservationLocation, PhotoCount, Tabs
|
||||
DateDisplay, DisplayTaxon, HideView, InlineUser, ObservationLocation, ScrollViewWrapper, Tabs,
|
||||
TextInputSheet
|
||||
} from "components/SharedComponents";
|
||||
import DisplayTaxon from "components/SharedComponents/DisplayTaxon";
|
||||
import HideView from "components/SharedComponents/HideView";
|
||||
import ObsStatus from "components/SharedComponents/ObservationsFlashList/ObsStatus";
|
||||
import PhotoScroll from "components/SharedComponents/PhotoScroll";
|
||||
import ScrollViewWrapper from "components/SharedComponents/ScrollViewWrapper";
|
||||
import { Text, View } from "components/styledComponents";
|
||||
import { formatISO } from "date-fns";
|
||||
import _ from "lodash";
|
||||
import { RealmContext } from "providers/contexts";
|
||||
import type { Node } from "react";
|
||||
import React, {
|
||||
useCallback, useEffect, useMemo, useState
|
||||
} from "react";
|
||||
import { Alert, LogBox } from "react-native";
|
||||
import React from "react";
|
||||
import { ActivityIndicator } from "react-native-paper";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Button as IconButton
|
||||
} from "react-native-paper";
|
||||
import IconMaterial from "react-native-vector-icons/MaterialIcons";
|
||||
import Observation from "realmModels/Observation";
|
||||
import useAuthenticatedMutation from "sharedHooks/useAuthenticatedMutation";
|
||||
import useAuthenticatedQuery from "sharedHooks/useAuthenticatedQuery";
|
||||
import useCurrentUser from "sharedHooks/useCurrentUser";
|
||||
import useIsConnected from "sharedHooks/useIsConnected";
|
||||
import useLocalObservation from "sharedHooks/useLocalObservation";
|
||||
import useObservationsUpdates,
|
||||
{ fetchObservationUpdatesKey } from "sharedHooks/useObservationsUpdates";
|
||||
import useTranslation from "sharedHooks/useTranslation";
|
||||
import { getShadowStyle } from "styles/global";
|
||||
import colors from "styles/tailwindColors";
|
||||
useTranslation
|
||||
} from "sharedHooks";
|
||||
|
||||
import ActivityTab from "./ActivityTab";
|
||||
import AddCommentModal from "./AddCommentModal";
|
||||
import DetailsTab from "./DetailsTab";
|
||||
import ActivityTab from "./ActivityTab/ActivityTab";
|
||||
import FloatingButtons from "./ActivityTab/FloatingButtons";
|
||||
import DetailsTab from "./DetailsTab/DetailsTab";
|
||||
import PhotoDisplayContainer from "./PhotoDisplayContainer";
|
||||
|
||||
const { useRealm } = RealmContext;
|
||||
type Props = {
|
||||
navToAddID: Function,
|
||||
onCommentAdded: Function,
|
||||
openCommentBox: Function,
|
||||
tabs: Array<Object>,
|
||||
currentTabId: string,
|
||||
showCommentBox: Function,
|
||||
addingActivityItem: Function,
|
||||
observation: Object,
|
||||
refetchRemoteObservation: Function,
|
||||
hideCommentBox: Function,
|
||||
activityItems: Array<Object>,
|
||||
showActivityTab: boolean,
|
||||
onIDAgreePressed: Function,
|
||||
showAgreeWithIdSheet: Function,
|
||||
openCommentBox: Function,
|
||||
agreeIdSheetDiscardChanges: Function,
|
||||
onAgree: Function
|
||||
}
|
||||
|
||||
// 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)
|
||||
LogBox.ignoreLogs( [
|
||||
"Non-serializable values were found in the navigation state"
|
||||
] );
|
||||
|
||||
const ACTIVITY_TAB_ID = "ACTIVITY";
|
||||
const DETAILS_TAB_ID = "DETAILS";
|
||||
|
||||
const ObsDetails = (): Node => {
|
||||
const isOnline = useIsConnected();
|
||||
const currentUser = useCurrentUser();
|
||||
const [ids, setIds] = useState<Array<Object>>( [] );
|
||||
const userId = currentUser?.id;
|
||||
const [refetch, setRefetch] = useState( false );
|
||||
const ObsDetails = ( {
|
||||
navToAddID,
|
||||
onCommentAdded,
|
||||
openCommentBox,
|
||||
tabs,
|
||||
currentTabId,
|
||||
showCommentBox,
|
||||
addingActivityItem,
|
||||
observation,
|
||||
refetchRemoteObservation,
|
||||
hideCommentBox,
|
||||
onIDAgreePressed,
|
||||
activityItems,
|
||||
showActivityTab,
|
||||
showAgreeWithIdSheet,
|
||||
agreeIdSheetDiscardChanges,
|
||||
onAgree
|
||||
}: Props ): Node => {
|
||||
const { params } = useRoute();
|
||||
const { uuid } = params;
|
||||
const [currentTabId, setCurrentTabId] = useState( ACTIVITY_TAB_ID );
|
||||
const navigation = useNavigation();
|
||||
const realm = useRealm();
|
||||
const localObservation = useLocalObservation( uuid );
|
||||
const [showCommentBox, setShowCommentBox] = useState( false );
|
||||
const [addingComment, setAddingComment] = useState( false );
|
||||
const [comments, setComments] = useState( [] );
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const remoteObservationParams = {
|
||||
fields: Observation.FIELDS
|
||||
};
|
||||
|
||||
const { data: remoteObservation, refetch: refetchRemoteObservation }
|
||||
= useAuthenticatedQuery(
|
||||
["fetchRemoteObservation", uuid],
|
||||
optsWithAuth => fetchRemoteObservation( uuid, remoteObservationParams, optsWithAuth )
|
||||
);
|
||||
|
||||
const observation = localObservation || remoteObservation;
|
||||
|
||||
const markViewedLocally = async () => {
|
||||
if ( !localObservation ) { return; }
|
||||
realm?.write( () => {
|
||||
// Flags if all comments and identifications have been viewed
|
||||
localObservation.comments_viewed = true;
|
||||
localObservation.identifications_viewed = true;
|
||||
} );
|
||||
};
|
||||
|
||||
const { refetch: refetchObservationUpdates } = useObservationsUpdates(
|
||||
!!currentUser && !!observation
|
||||
);
|
||||
|
||||
const markViewedMutation = useAuthenticatedMutation(
|
||||
( viewedParams, optsWithAuth ) => markObservationUpdatesViewed( viewedParams, optsWithAuth ),
|
||||
{
|
||||
onSuccess: () => {
|
||||
markViewedLocally();
|
||||
queryClient.invalidateQueries( ["fetchRemoteObservation", uuid] );
|
||||
queryClient.invalidateQueries( [fetchObservationUpdatesKey] );
|
||||
refetchRemoteObservation();
|
||||
refetchObservationUpdates();
|
||||
}
|
||||
}
|
||||
);
|
||||
const navigation = useNavigation( );
|
||||
const { t } = useTranslation( );
|
||||
|
||||
const taxon = observation?.taxon;
|
||||
const faves = observation?.faves;
|
||||
const observationPhotos = observation?.observationPhotos || observation?.observation_photos;
|
||||
const currentUserFaved = useCallback( () => {
|
||||
if ( faves?.length > 0 ) {
|
||||
const userFaved = faves.find( fave => fave.user_id === userId );
|
||||
return !!userFaved;
|
||||
}
|
||||
return null;
|
||||
}, [faves, userId] );
|
||||
const [userFav, setUserFav] = useState( currentUserFaved() );
|
||||
|
||||
const createUnfaveMutation = useAuthenticatedMutation(
|
||||
( faveOrUnfaveParams, optsWithAuth ) => unfaveObservation( faveOrUnfaveParams, optsWithAuth ),
|
||||
{
|
||||
onSuccess: () => {
|
||||
setRefetch( true );
|
||||
queryClient.invalidateQueries( ["fetchRemoteObservation"] );
|
||||
refetchRemoteObservation();
|
||||
refetchObservationUpdates();
|
||||
setUserFav( false );
|
||||
},
|
||||
onError: () => {
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const createFaveMutation = useAuthenticatedMutation(
|
||||
( faveOrUnfaveParams, optsWithAuth ) => faveObservation( faveOrUnfaveParams, optsWithAuth ),
|
||||
{
|
||||
onSuccess: () => {
|
||||
setRefetch( true );
|
||||
queryClient.invalidateQueries( ["fetchRemoteObservation"] );
|
||||
refetchRemoteObservation();
|
||||
refetchObservationUpdates();
|
||||
setUserFav( true );
|
||||
},
|
||||
onError: () => {
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const faveOrUnfave = async () => {
|
||||
if ( currentUserFaved() ) {
|
||||
createUnfaveMutation.mutate( { uuid } );
|
||||
} else {
|
||||
createFaveMutation.mutate( { uuid } );
|
||||
}
|
||||
};
|
||||
|
||||
const showErrorAlert = error => Alert.alert( "Error", error, [{ text: t( "OK" ) }], {
|
||||
cancelable: true
|
||||
} );
|
||||
|
||||
const toggleRefetch = () => setRefetch( !refetch );
|
||||
const openCommentBox = () => setShowCommentBox( true );
|
||||
const createCommentMutation = useAuthenticatedMutation(
|
||||
( commentParams, optsWithAuth ) => createComment( commentParams, optsWithAuth ),
|
||||
{
|
||||
onSuccess: data => {
|
||||
setComments( [...comments, data[0]] );
|
||||
},
|
||||
onError: e => {
|
||||
let error = null;
|
||||
if ( e ) {
|
||||
error = t( "Couldnt-create-comment", { error: e.message } );
|
||||
} else {
|
||||
error = t( "Couldnt-create-comment", { error: t( "Unknown-error" ) } );
|
||||
}
|
||||
|
||||
// Remove temporary comment and show error
|
||||
setComments( [...comments] );
|
||||
showErrorAlert( error );
|
||||
},
|
||||
onSettled: () => setAddingComment( false )
|
||||
}
|
||||
);
|
||||
const onCommentAdded = async commentBody => {
|
||||
createCommentMutation.mutate( {
|
||||
comment: {
|
||||
body: commentBody,
|
||||
parent_id: uuid,
|
||||
parent_type: "Observation"
|
||||
}
|
||||
} );
|
||||
};
|
||||
|
||||
const createIdentificationMutation = useAuthenticatedMutation(
|
||||
( idParams, optsWithAuth ) => createIdentification( idParams, optsWithAuth ),
|
||||
{
|
||||
onSuccess: data => {
|
||||
setIds( [...ids, data[0]] );
|
||||
},
|
||||
onError: e => {
|
||||
let error = null;
|
||||
if ( e ) {
|
||||
error = t( "Couldnt-create-identification-error", { error: e.message } );
|
||||
} else {
|
||||
error = t( "Couldnt-create-identification-unknown-error" );
|
||||
}
|
||||
|
||||
// Remove temporary ID and show error
|
||||
setIds( [...ids] );
|
||||
showErrorAlert( error );
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// reload if change to observation
|
||||
useEffect( () => {
|
||||
if ( localObservation && remoteObservation ) {
|
||||
const remoteUpdatedAt = new Date( remoteObservation?.updated_at );
|
||||
if ( remoteUpdatedAt > localObservation?.updated_at ) {
|
||||
Observation.upsertRemoteObservations( [remoteObservation], realm );
|
||||
}
|
||||
}
|
||||
}, [localObservation, remoteObservation, realm] );
|
||||
|
||||
useEffect( () => {
|
||||
if (
|
||||
localObservation
|
||||
&& localObservation.unviewed()
|
||||
&& !markViewedMutation.isLoading
|
||||
) {
|
||||
markViewedMutation.mutate( { id: uuid } );
|
||||
}
|
||||
}, [localObservation, markViewedMutation, uuid] );
|
||||
|
||||
const navToObsEdit = useCallback(
|
||||
( ) => navigation.navigate( "ObsEdit", { uuid: observation?.uuid } ),
|
||||
[navigation, observation]
|
||||
);
|
||||
|
||||
useEffect( () => {
|
||||
// set initial comments for activity currentTabId
|
||||
const currentComments = observation?.comments;
|
||||
if (
|
||||
currentComments
|
||||
&& comments.length === 0
|
||||
&& currentComments.length !== comments.length
|
||||
) {
|
||||
setComments( currentComments );
|
||||
}
|
||||
}, [observation, comments] );
|
||||
|
||||
useEffect( () => {
|
||||
// set user fav
|
||||
if ( currentUserFaved() ) {
|
||||
setUserFav( true );
|
||||
} else {
|
||||
setUserFav( false );
|
||||
}
|
||||
}, [currentUserFaved] );
|
||||
|
||||
useEffect( () => {
|
||||
if ( observation?.identifications ) {
|
||||
setIds( observation?.identifications );
|
||||
}
|
||||
}, [observation] );
|
||||
|
||||
const editButton = useMemo( ( ) => (
|
||||
<IconButton
|
||||
onPress={navToObsEdit}
|
||||
icon="pencil"
|
||||
textColor={colors.white}
|
||||
className="absolute top-3 right-3"
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t( "edit" )}
|
||||
/>
|
||||
), [navToObsEdit, t] );
|
||||
|
||||
if ( !observation ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const photos = _.compact( Array.from( observationPhotos ).map( op => op.photo ) );
|
||||
|
||||
const navToTaxonDetails = () => navigation.navigate( "TaxonDetails", { id: taxon.id } );
|
||||
|
||||
const showTaxon = () => {
|
||||
if ( !taxon ) {
|
||||
@@ -306,134 +71,13 @@ const ObsDetails = (): Node => {
|
||||
return (
|
||||
<DisplayTaxon
|
||||
taxon={taxon}
|
||||
handlePress={navToTaxonDetails}
|
||||
handlePress={( ) => navigation.navigate( "TaxonDetails", { id: taxon.id } )}
|
||||
testID={`ObsDetails.taxon.${taxon.id}`}
|
||||
accessibilityLabel={t( "Navigate-to-taxon-details" )}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const onIDAdded = async identification => {
|
||||
// Add temporary ID to observation.identifications ("ghosted" ID, while we're trying to add it)
|
||||
const newId = {
|
||||
body: identification.body,
|
||||
taxon: identification.taxon,
|
||||
user: {
|
||||
id: userId,
|
||||
login: currentUser?.login,
|
||||
signedIn: true
|
||||
},
|
||||
created_at: formatISO( Date.now() ),
|
||||
uuid: identification.uuid,
|
||||
vision: false,
|
||||
// This tells us to render is ghosted (since it's temporarily visible
|
||||
// until getting a response from the server)
|
||||
temporary: true
|
||||
};
|
||||
setIds( [...ids, newId] );
|
||||
|
||||
createIdentificationMutation.mutate( {
|
||||
identification: {
|
||||
observation_id: uuid,
|
||||
taxon_id: newId.taxon.id,
|
||||
body: newId.body
|
||||
}
|
||||
} );
|
||||
};
|
||||
|
||||
const navToAddID = ( ) => {
|
||||
navigation.navigate( "AddID", { onIDAdded, goBackOnSave: true } );
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
id: ACTIVITY_TAB_ID,
|
||||
testID: "ObsDetails.ActivityTab",
|
||||
onPress: () => setCurrentTabId( ACTIVITY_TAB_ID ),
|
||||
text: t( "ACTIVITY" )
|
||||
},
|
||||
{
|
||||
id: DETAILS_TAB_ID,
|
||||
testID: "ObsDetails.DetailsTab",
|
||||
onPress: () => setCurrentTabId( DETAILS_TAB_ID ),
|
||||
text: t( "DETAILS" )
|
||||
}
|
||||
];
|
||||
|
||||
const displayPhoto = () => {
|
||||
if ( !isOnline ) {
|
||||
// TODO show photos that are available offline
|
||||
return (
|
||||
<View className="bg-black flex-row justify-center">
|
||||
<IconMaterial
|
||||
name="wifi-off"
|
||||
color={colors.white}
|
||||
size={100}
|
||||
accessibilityRole="image"
|
||||
accessibilityLabel={t(
|
||||
"Observation-photos-unavailable-without-internet"
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if ( photos.length > 0 || observation?.observationSounds?.length > 0 ) {
|
||||
return (
|
||||
<View className="bg-black">
|
||||
<PhotoScroll photos={photos} />
|
||||
{/* TODO: a11y props are not passed down into this 3.party */}
|
||||
{ editButton }
|
||||
{userFav
|
||||
? (
|
||||
<IconButton
|
||||
icon="star"
|
||||
size={25}
|
||||
onPress={() => faveOrUnfave()}
|
||||
textColor={colors.white}
|
||||
className="absolute bottom-3 right-3"
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t( "favorite" )}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<IconButton
|
||||
icon="star-bold-outline"
|
||||
size={25}
|
||||
onPress={() => faveOrUnfave()}
|
||||
textColor={colors.white}
|
||||
className="absolute bottom-3 right-3"
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t( "favorite" )}
|
||||
/>
|
||||
)}
|
||||
<View className="absolute bottom-3 left-3">
|
||||
<PhotoCount count={photos.length
|
||||
? photos.length
|
||||
: 0}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View
|
||||
className="bg-black flex-row justify-center"
|
||||
accessible
|
||||
accessibilityLabel={t( "Observation-has-no-photos-and-no-sounds" )}
|
||||
>
|
||||
{ editButton }
|
||||
<IconMaterial
|
||||
color={colors.white}
|
||||
testID="ObsDetails.noImage"
|
||||
name="image-not-supported"
|
||||
size={100}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrollViewWrapper testID={`ObsDetails.${uuid}`}>
|
||||
@@ -445,80 +89,60 @@ const ObsDetails = (): Node => {
|
||||
Tried using transparent react-navigation header but had issues where the header
|
||||
blocked the Edit button and the header would follow scroll
|
||||
*/}
|
||||
{displayPhoto()}
|
||||
<View className="absolute top-3 left-3">
|
||||
<HeaderBackButton
|
||||
tintColor={colors.white}
|
||||
onPress={( ) => navigation.goBack( )}
|
||||
/>
|
||||
<PhotoDisplayContainer
|
||||
observation={observation}
|
||||
refetchRemoteObservation={refetchRemoteObservation}
|
||||
/>
|
||||
<View className="flex-row justify-between mx-[15px] mt-[13px]">
|
||||
<InlineUser user={observation?.user} />
|
||||
<DateDisplay dateString={observation?.created_at} />
|
||||
</View>
|
||||
|
||||
<ActivityHeader item={observation} classNameMargin="mx-[15px] mt-[13px]" />
|
||||
<View className="flex-row my-[11px] justify-between mx-3">
|
||||
{showTaxon()}
|
||||
<ObsStatus layout="vertical" observation={observation} />
|
||||
</View>
|
||||
<ObservationLocation observation={observation} classNameMargin="ml-3 mb-2" />
|
||||
<Tabs tabs={tabs} activeId={currentTabId} />
|
||||
<HideView show={currentTabId === ACTIVITY_TAB_ID}>
|
||||
<HideView show={showActivityTab}>
|
||||
<ActivityTab
|
||||
observation={observation}
|
||||
uuid={uuid}
|
||||
comments={comments}
|
||||
navToTaxonDetails={navToTaxonDetails}
|
||||
toggleRefetch={toggleRefetch}
|
||||
refetchRemoteObservation={refetchRemoteObservation}
|
||||
onIDAgreePressed={onIDAgreePressed}
|
||||
activityItems={activityItems}
|
||||
/>
|
||||
</HideView>
|
||||
<HideView noInitialRender show={currentTabId === DETAILS_TAB_ID}>
|
||||
<HideView noInitialRender show={!showActivityTab}>
|
||||
<DetailsTab observation={observation} uuid={uuid} />
|
||||
</HideView>
|
||||
{addingComment && (
|
||||
{addingActivityItem && (
|
||||
<View className="flex-row items-center justify-center">
|
||||
<ActivityIndicator size="large" />
|
||||
</View>
|
||||
)}
|
||||
</ScrollViewWrapper>
|
||||
{ ( currentTabId === ACTIVITY_TAB_ID )
|
||||
&& (
|
||||
<View
|
||||
className="flex-row justify-evenly bottom-[80px] bg-white py-3"
|
||||
style={getShadowStyle( {
|
||||
shadowColor: colors.black,
|
||||
offsetWidth: 0,
|
||||
offsetHeight: -3,
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 2,
|
||||
radius: 5,
|
||||
elevation: 5
|
||||
} )}
|
||||
>
|
||||
<Button
|
||||
text={t( "COMMENT" )}
|
||||
onPress={openCommentBox}
|
||||
className="mx-3 grow"
|
||||
testID="ObsDetail.commentButton"
|
||||
disabled={showCommentBox}
|
||||
accessibilityHint={t( "Opens-add-comment-modal" )}
|
||||
/>
|
||||
<Button
|
||||
text={t( "SUGGEST-ID" )}
|
||||
onPress={navToAddID}
|
||||
className="mx-3 grow"
|
||||
testID="ObsDetail.cvSuggestionsButton"
|
||||
accessibilityRole="link"
|
||||
accessibilityHint={t( "Navigates-to-suggest-identification" )}
|
||||
/>
|
||||
</View>
|
||||
) }
|
||||
<AddCommentModal
|
||||
// potential to move this modal to ActivityTab and have it handle comments
|
||||
// and ids but there were issues with presenting the modal in a scrollview.
|
||||
onCommentAdded={onCommentAdded}
|
||||
showCommentBox={showCommentBox}
|
||||
setShowCommentBox={setShowCommentBox}
|
||||
setAddingComment={setAddingComment}
|
||||
/>
|
||||
{showActivityTab && (
|
||||
<FloatingButtons
|
||||
navToAddID={navToAddID}
|
||||
openCommentBox={openCommentBox}
|
||||
showCommentBox={showCommentBox}
|
||||
/>
|
||||
)}
|
||||
{showAgreeWithIdSheet && (
|
||||
<AgreeWithIDSheet
|
||||
taxon={taxon}
|
||||
handleClose={( ) => agreeIdSheetDiscardChanges( )}
|
||||
onAgree={onAgree}
|
||||
/>
|
||||
)}
|
||||
{/* AddCommentSheet */}
|
||||
{showCommentBox && (
|
||||
<TextInputSheet
|
||||
handleClose={hideCommentBox}
|
||||
headerText={t( "ADD-OPTIONAL-COMMENT" )}
|
||||
snapPoints={[416]}
|
||||
confirm={textInput => onCommentAdded( textInput )}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
338
src/components/ObsDetails/ObsDetailsContainer.js
Normal file
338
src/components/ObsDetails/ObsDetailsContainer.js
Normal file
@@ -0,0 +1,338 @@
|
||||
// @flow
|
||||
import { useNavigation, useRoute } from "@react-navigation/native";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { createComment } from "api/comments";
|
||||
import createIdentification from "api/identifications";
|
||||
import {
|
||||
fetchRemoteObservation,
|
||||
markObservationUpdatesViewed
|
||||
} from "api/observations";
|
||||
import { RealmContext } from "providers/contexts";
|
||||
import type { Node } from "react";
|
||||
import React, {
|
||||
useEffect, useReducer
|
||||
} from "react";
|
||||
import { Alert, LogBox } from "react-native";
|
||||
import Observation from "realmModels/Observation";
|
||||
import {
|
||||
useAuthenticatedMutation,
|
||||
useAuthenticatedQuery,
|
||||
useCurrentUser,
|
||||
useLocalObservation,
|
||||
useTranslation
|
||||
} from "sharedHooks";
|
||||
import useObservationsUpdates,
|
||||
{ fetchObservationUpdatesKey } from "sharedHooks/useObservationsUpdates";
|
||||
|
||||
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)
|
||||
LogBox.ignoreLogs( [
|
||||
"Non-serializable values were found in the navigation state"
|
||||
] );
|
||||
|
||||
const ACTIVITY_TAB_ID = "ACTIVITY";
|
||||
const DETAILS_TAB_ID = "DETAILS";
|
||||
|
||||
const sortItems = ( ids, comments ) => ids.concat( [...comments] ).sort(
|
||||
( a, b ) => ( new Date( a.created_at ) - new Date( b.created_at ) )
|
||||
);
|
||||
|
||||
const initialState = {
|
||||
currentTabId: ACTIVITY_TAB_ID,
|
||||
addingActivityItem: false,
|
||||
showAgreeWithIdSheet: false,
|
||||
showCommentBox: false,
|
||||
observationShown: null,
|
||||
activityItems: []
|
||||
};
|
||||
|
||||
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 "CHANGE_TAB":
|
||||
return {
|
||||
...state,
|
||||
currentTabId: action.currentTabId
|
||||
};
|
||||
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: action.showAgreeWithIdSheet
|
||||
};
|
||||
case "SHOW_COMMENT_BOX":
|
||||
return {
|
||||
...state,
|
||||
showCommentBox: action.showCommentBox
|
||||
};
|
||||
default:
|
||||
throw new Error( );
|
||||
}
|
||||
};
|
||||
|
||||
const ObsDetailsContainer = ( ): Node => {
|
||||
const currentUser = useCurrentUser( );
|
||||
const { params } = useRoute();
|
||||
const { uuid } = params;
|
||||
const navigation = useNavigation( );
|
||||
const realm = useRealm( );
|
||||
const { t } = useTranslation( );
|
||||
|
||||
const [state, dispatch] = useReducer( reducer, initialState );
|
||||
|
||||
const {
|
||||
currentTabId,
|
||||
addingActivityItem,
|
||||
showAgreeWithIdSheet,
|
||||
showCommentBox,
|
||||
observationShown,
|
||||
activityItems
|
||||
} = state;
|
||||
|
||||
const queryClient = useQueryClient( );
|
||||
|
||||
const { data: remoteObservation, refetch: refetchRemoteObservation }
|
||||
= useAuthenticatedQuery(
|
||||
["fetchRemoteObservation", uuid],
|
||||
optsWithAuth => fetchRemoteObservation(
|
||||
uuid,
|
||||
{
|
||||
fields: Observation.FIELDS
|
||||
},
|
||||
optsWithAuth
|
||||
)
|
||||
);
|
||||
|
||||
const localObservation = useLocalObservation( uuid );
|
||||
const observation = localObservation || remoteObservation;
|
||||
|
||||
useEffect( ( ) => {
|
||||
if ( !observationShown ) {
|
||||
dispatch( {
|
||||
type: "SET_INITIAL_OBSERVATION",
|
||||
observationShown: observation
|
||||
} );
|
||||
}
|
||||
}, [observation, observationShown] );
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
id: ACTIVITY_TAB_ID,
|
||||
testID: "ObsDetails.ActivityTab",
|
||||
onPress: ( ) => dispatch( { type: "CHANGE_TAB", currentTabId: ACTIVITY_TAB_ID } ),
|
||||
text: t( "ACTIVITY" )
|
||||
},
|
||||
{
|
||||
id: DETAILS_TAB_ID,
|
||||
testID: "ObsDetails.DetailsTab",
|
||||
onPress: () => dispatch( { type: "CHANGE_TAB", currentTabId: DETAILS_TAB_ID } ),
|
||||
text: t( "DETAILS" )
|
||||
}
|
||||
];
|
||||
|
||||
const markViewedLocally = async () => {
|
||||
if ( !localObservation ) { return; }
|
||||
realm?.write( () => {
|
||||
// Flags if all comments and identifications have been viewed
|
||||
localObservation.comments_viewed = true;
|
||||
localObservation.identifications_viewed = true;
|
||||
} );
|
||||
};
|
||||
|
||||
const { refetch: refetchObservationUpdates } = useObservationsUpdates(
|
||||
!!currentUser && !!observation
|
||||
);
|
||||
|
||||
const markViewedMutation = useAuthenticatedMutation(
|
||||
( viewedParams, optsWithAuth ) => markObservationUpdatesViewed( viewedParams, optsWithAuth ),
|
||||
{
|
||||
onSuccess: () => {
|
||||
markViewedLocally( );
|
||||
queryClient.invalidateQueries( ["fetchRemoteObservation", uuid] );
|
||||
queryClient.invalidateQueries( [fetchObservationUpdatesKey] );
|
||||
refetchRemoteObservation( );
|
||||
refetchObservationUpdates( );
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const showErrorAlert = error => Alert.alert( "Error", error, [{ text: t( "OK" ) }], {
|
||||
cancelable: true
|
||||
} );
|
||||
|
||||
const openCommentBox = ( ) => dispatch( { type: "SHOW_COMMENT_BOX", showCommentBox: true } );
|
||||
|
||||
const createCommentMutation = useAuthenticatedMutation(
|
||||
( commentParams, optsWithAuth ) => createComment( commentParams, optsWithAuth ),
|
||||
{
|
||||
onSuccess: data => {
|
||||
realm?.write( ( ) => {
|
||||
const localComments = localObservation?.comments;
|
||||
const newComment = data[0];
|
||||
newComment.user = currentUser;
|
||||
const realmComment = realm?.create( "Comment", newComment );
|
||||
localComments.push( realmComment );
|
||||
} );
|
||||
const updatedLocalObservation = realm.objectForPrimaryKey( "Observation", uuid );
|
||||
dispatch( { type: "ADD_ACTIVITY_ITEM", observationShown: updatedLocalObservation } );
|
||||
},
|
||||
onError: e => {
|
||||
let error = null;
|
||||
if ( e ) {
|
||||
error = t( "Couldnt-create-comment", { error: e.message } );
|
||||
} else {
|
||||
error = t( "Couldnt-create-comment", { error: t( "Unknown-error" ) } );
|
||||
}
|
||||
showErrorAlert( error );
|
||||
}
|
||||
}
|
||||
);
|
||||
const onCommentAdded = commentBody => {
|
||||
dispatch( { type: "LOADING_ACTIVITY_ITEM" } );
|
||||
createCommentMutation.mutate( {
|
||||
comment: {
|
||||
body: commentBody,
|
||||
parent_id: uuid,
|
||||
parent_type: "Observation"
|
||||
}
|
||||
} );
|
||||
};
|
||||
|
||||
const createIdentificationMutation = useAuthenticatedMutation(
|
||||
( idParams, optsWithAuth ) => createIdentification( idParams, optsWithAuth ),
|
||||
{
|
||||
onSuccess: data => {
|
||||
realm?.write( ( ) => {
|
||||
const localIdentifications = localObservation?.identifications;
|
||||
const newIdentification = data[0];
|
||||
newIdentification.user = currentUser;
|
||||
newIdentification.taxon = realm?.objectForPrimaryKey(
|
||||
"Taxon",
|
||||
newIdentification.taxon.id
|
||||
) || newIdentification.taxon;
|
||||
const realmIdentification = realm?.create( "Identification", newIdentification );
|
||||
localIdentifications.push( realmIdentification );
|
||||
} );
|
||||
const updatedLocalObservation = realm.objectForPrimaryKey( "Observation", uuid );
|
||||
dispatch( { type: "ADD_ACTIVITY_ITEM", observationShown: updatedLocalObservation } );
|
||||
},
|
||||
onError: e => {
|
||||
let error = null;
|
||||
if ( e ) {
|
||||
error = t( "Couldnt-create-identification-error", { error: e.message } );
|
||||
} else {
|
||||
error = t( "Couldnt-create-identification-unknown-error" );
|
||||
}
|
||||
showErrorAlert( error );
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const onIDAdded = async identification => {
|
||||
dispatch( { type: "LOADING_ACTIVITY_ITEM" } );
|
||||
createIdentificationMutation.mutate( {
|
||||
identification: {
|
||||
observation_id: uuid,
|
||||
taxon_id: identification.taxon.id,
|
||||
body: identification.body
|
||||
}
|
||||
} );
|
||||
};
|
||||
|
||||
useEffect( ( ) => {
|
||||
if (
|
||||
localObservation
|
||||
&& localObservation.unviewed()
|
||||
&& !markViewedMutation.isLoading
|
||||
) {
|
||||
markViewedMutation.mutate( { id: uuid } );
|
||||
}
|
||||
}, [localObservation, markViewedMutation, uuid] );
|
||||
|
||||
const navToAddID = ( ) => {
|
||||
navigation.navigate( "AddID", { onIDAdded, goBackOnSave: true } );
|
||||
};
|
||||
|
||||
const showActivityTab = currentTabId === ACTIVITY_TAB_ID;
|
||||
|
||||
const refetchObservation = ( ) => {
|
||||
queryClient.invalidateQueries( ["fetchRemoteObservation"] );
|
||||
refetchRemoteObservation( );
|
||||
refetchObservationUpdates( );
|
||||
};
|
||||
|
||||
const onAgree = comment => {
|
||||
const agreeParams = {
|
||||
observation_id: observation?.uuid,
|
||||
taxon_id: observation?.taxon?.id,
|
||||
body: comment
|
||||
};
|
||||
|
||||
dispatch( { type: "LOADING_ACTIVITY_ITEM" } );
|
||||
createIdentificationMutation.mutate( { identification: agreeParams } );
|
||||
dispatch( { type: "SHOW_AGREE_SHEET", showAgreeWithIdSheet: false } );
|
||||
};
|
||||
|
||||
const agreeIdSheetDiscardChanges = ( ) => {
|
||||
console.log( "agree discard changes" );
|
||||
dispatch( { type: "SHOW_AGREE_SHEET", showAgreeWithIdSheet: false } );
|
||||
};
|
||||
|
||||
const onIDAgreePressed = ( ) => {
|
||||
dispatch( { type: "SHOW_AGREE_SHEET", showAgreeWithIdSheet: true } );
|
||||
};
|
||||
|
||||
if ( !observation ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ObsDetails
|
||||
navToAddID={navToAddID}
|
||||
onCommentAdded={onCommentAdded}
|
||||
openCommentBox={openCommentBox}
|
||||
tabs={tabs}
|
||||
currentTabId={currentTabId}
|
||||
showCommentBox={showCommentBox}
|
||||
addingActivityItem={addingActivityItem}
|
||||
observation={observation}
|
||||
refetchRemoteObservation={refetchObservation}
|
||||
activityItems={activityItems}
|
||||
showActivityTab={showActivityTab}
|
||||
showAgreeWithIdSheet={showAgreeWithIdSheet}
|
||||
agreeIdSheetDiscardChanges={agreeIdSheetDiscardChanges}
|
||||
onAgree={onAgree}
|
||||
onIDAgreePressed={onIDAgreePressed}
|
||||
hideCommentBox={( ) => dispatch( { type: "SHOW_COMMENT_BOX", showCommentBox: false } )}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ObsDetailsContainer;
|
||||
140
src/components/ObsDetails/PhotoDisplay.js
Normal file
140
src/components/ObsDetails/PhotoDisplay.js
Normal file
@@ -0,0 +1,140 @@
|
||||
// @flow
|
||||
import { HeaderBackButton } from "@react-navigation/elements";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import {
|
||||
PhotoCount
|
||||
} from "components/SharedComponents";
|
||||
import PhotoScroll from "components/SharedComponents/PhotoScroll";
|
||||
import { View } from "components/styledComponents";
|
||||
import type { Node } from "react";
|
||||
import React, {
|
||||
useMemo
|
||||
} from "react";
|
||||
import {
|
||||
Button as IconButton
|
||||
} from "react-native-paper";
|
||||
import IconMaterial from "react-native-vector-icons/MaterialIcons";
|
||||
import {
|
||||
useIsConnected,
|
||||
useTranslation
|
||||
} from "sharedHooks";
|
||||
import colors from "styles/tailwindColors";
|
||||
|
||||
type Props = {
|
||||
faveOrUnfave: Function,
|
||||
userFav: ?boolean,
|
||||
photos: Array<Object>,
|
||||
uuid: string
|
||||
}
|
||||
|
||||
const PhotoDisplay = ( {
|
||||
faveOrUnfave,
|
||||
userFav,
|
||||
photos,
|
||||
uuid
|
||||
}: Props ): Node => {
|
||||
const isOnline = useIsConnected( );
|
||||
const { t } = useTranslation( );
|
||||
const navigation = useNavigation( );
|
||||
|
||||
const editButton = useMemo( ( ) => (
|
||||
<IconButton
|
||||
onPress={( ) => navigation.navigate( "ObsEdit", { uuid } )}
|
||||
icon="pencil"
|
||||
textColor={colors.white}
|
||||
className="absolute top-3 right-3"
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t( "edit" )}
|
||||
/>
|
||||
), [t, navigation, uuid] );
|
||||
|
||||
const displayPhoto = ( ) => {
|
||||
if ( !isOnline ) {
|
||||
// TODO show photos that are available offline
|
||||
return (
|
||||
<View className="bg-black flex-row justify-center">
|
||||
<IconMaterial
|
||||
name="wifi-off"
|
||||
color={colors.white}
|
||||
size={100}
|
||||
accessibilityRole="image"
|
||||
accessibilityLabel={t(
|
||||
"Observation-photos-unavailable-without-internet"
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if ( photos.length > 0 ) {
|
||||
return (
|
||||
<View className="bg-black">
|
||||
<PhotoScroll photos={photos} />
|
||||
{/* TODO: a11y props are not passed down into this 3.party */}
|
||||
{ editButton }
|
||||
{userFav
|
||||
? (
|
||||
<IconButton
|
||||
icon="star"
|
||||
size={25}
|
||||
onPress={faveOrUnfave}
|
||||
textColor={colors.white}
|
||||
className="absolute bottom-3 right-3"
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t( "favorite" )}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<IconButton
|
||||
icon="star-bold-outline"
|
||||
size={25}
|
||||
onPress={faveOrUnfave}
|
||||
textColor={colors.white}
|
||||
className="absolute bottom-3 right-3"
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t( "favorite" )}
|
||||
/>
|
||||
)}
|
||||
<View className="absolute bottom-3 left-3">
|
||||
<PhotoCount count={photos.length
|
||||
? photos.length
|
||||
: 0}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View
|
||||
className="bg-black flex-row justify-center"
|
||||
accessible
|
||||
accessibilityLabel={t( "Observation-has-no-photos-and-no-sounds" )}
|
||||
>
|
||||
|
||||
{ editButton }
|
||||
<IconMaterial
|
||||
color={colors.white}
|
||||
testID="ObsDetails.noImage"
|
||||
name="image-not-supported"
|
||||
size={100}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{displayPhoto( )}
|
||||
<View className="absolute top-3 left-3">
|
||||
<HeaderBackButton
|
||||
tintColor={colors.white}
|
||||
onPress={( ) => navigation.goBack( )}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PhotoDisplay;
|
||||
90
src/components/ObsDetails/PhotoDisplayContainer.js
Normal file
90
src/components/ObsDetails/PhotoDisplayContainer.js
Normal file
@@ -0,0 +1,90 @@
|
||||
// @flow
|
||||
import {
|
||||
faveObservation,
|
||||
unfaveObservation
|
||||
} from "api/observations";
|
||||
import _ from "lodash";
|
||||
import type { Node } from "react";
|
||||
import React, {
|
||||
useCallback, useState
|
||||
} from "react";
|
||||
import { Alert } from "react-native";
|
||||
import {
|
||||
useAuthenticatedMutation,
|
||||
useCurrentUser,
|
||||
useTranslation
|
||||
} from "sharedHooks";
|
||||
|
||||
import PhotoDisplay from "./PhotoDisplay";
|
||||
|
||||
type Props = {
|
||||
observation: Object,
|
||||
refetchRemoteObservation: Function
|
||||
}
|
||||
|
||||
const PhotoDisplayContainer = ( { observation, refetchRemoteObservation }: Props ): Node => {
|
||||
const currentUser = useCurrentUser( );
|
||||
const userId = currentUser?.id;
|
||||
const { t } = useTranslation( );
|
||||
|
||||
const faves = observation?.faves;
|
||||
const uuid = observation?.uuid;
|
||||
|
||||
const currentUserFaved = useCallback( ( ) => {
|
||||
if ( faves?.length > 0 ) {
|
||||
const userFaved = faves.find( fave => fave.user_id === userId );
|
||||
return !!userFaved;
|
||||
}
|
||||
return null;
|
||||
}, [faves, userId] );
|
||||
|
||||
const [userFav, setUserFav] = useState( currentUserFaved( ) || false );
|
||||
|
||||
const showErrorAlert = error => Alert.alert( "Error", error, [{ text: t( "OK" ) }], {
|
||||
cancelable: true
|
||||
} );
|
||||
|
||||
const createUnfaveMutation = useAuthenticatedMutation(
|
||||
( faveOrUnfaveParams, optsWithAuth ) => unfaveObservation( faveOrUnfaveParams, optsWithAuth ),
|
||||
{
|
||||
onSuccess: ( ) => {
|
||||
refetchRemoteObservation( );
|
||||
setUserFav( false );
|
||||
},
|
||||
onError: error => showErrorAlert( error )
|
||||
}
|
||||
);
|
||||
|
||||
const createFaveMutation = useAuthenticatedMutation(
|
||||
( faveOrUnfaveParams, optsWithAuth ) => faveObservation( faveOrUnfaveParams, optsWithAuth ),
|
||||
{
|
||||
onSuccess: ( ) => {
|
||||
refetchRemoteObservation( );
|
||||
setUserFav( true );
|
||||
},
|
||||
onError: error => showErrorAlert( error )
|
||||
}
|
||||
);
|
||||
|
||||
const faveOrUnfave = ( ) => {
|
||||
if ( userFav ) {
|
||||
createUnfaveMutation.mutate( { uuid } );
|
||||
} else {
|
||||
createFaveMutation.mutate( { uuid } );
|
||||
}
|
||||
};
|
||||
|
||||
const observationPhotos = observation?.observationPhotos || observation?.observation_photos || [];
|
||||
const photos = _.compact( Array.from( observationPhotos ).map( op => op.photo ) );
|
||||
|
||||
return (
|
||||
<PhotoDisplay
|
||||
faveOrUnfave={faveOrUnfave}
|
||||
userFav={userFav}
|
||||
photos={photos}
|
||||
uuid={uuid}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PhotoDisplayContainer;
|
||||
@@ -2,27 +2,21 @@
|
||||
import {
|
||||
BottomSheet,
|
||||
Button,
|
||||
DisplayTaxonName,
|
||||
DisplayTaxon,
|
||||
INatIcon,
|
||||
List2
|
||||
List2,
|
||||
TextInputSheet
|
||||
} from "components/SharedComponents";
|
||||
import {
|
||||
Image, Pressable, Text, View
|
||||
} from "components/styledComponents";
|
||||
import { Text, View } from "components/styledComponents";
|
||||
import { t } from "i18next";
|
||||
import type { Node } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Taxon from "realmModels/Taxon";
|
||||
|
||||
type Props = {
|
||||
onAgree:Function,
|
||||
handleClose: Function,
|
||||
taxon: Object,
|
||||
discardChanges: Function,
|
||||
showAgreeWithIdSheet: boolean,
|
||||
comment:string,
|
||||
openCommentBox: Function
|
||||
}
|
||||
type Props = {
|
||||
onAgree:Function,
|
||||
handleClose: Function,
|
||||
taxon: Object
|
||||
}
|
||||
|
||||
const showTaxon = taxon => {
|
||||
if ( !taxon ) {
|
||||
@@ -30,21 +24,7 @@ const showTaxon = taxon => {
|
||||
}
|
||||
return (
|
||||
<View className="flex-row mx-[15px]">
|
||||
<Image
|
||||
source={Taxon.uri( taxon )}
|
||||
className="w-16 h-16 rounded-xl mr-3"
|
||||
accessibilityIgnoresInvertColors
|
||||
/>
|
||||
<Pressable
|
||||
className="justify-center"
|
||||
// onPress={navToTaxonDetails}
|
||||
testID={`ObsDetails.taxon.${taxon.id}`}
|
||||
accessibilityRole="link"
|
||||
accessibilityLabel={t( "Navigate-to-taxon-details" )}
|
||||
accessibilityValue={{ text: taxon.name }}
|
||||
>
|
||||
<DisplayTaxonName taxon={taxon} layout="vertical" />
|
||||
</Pressable>
|
||||
<DisplayTaxon taxon={taxon} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -52,13 +32,11 @@ const showTaxon = taxon => {
|
||||
const AgreeWithIDSheet = ( {
|
||||
onAgree,
|
||||
handleClose,
|
||||
discardChanges,
|
||||
taxon,
|
||||
showAgreeWithIdSheet,
|
||||
comment,
|
||||
openCommentBox
|
||||
taxon
|
||||
}: Props ): Node => {
|
||||
const [comment, setComment] = useState( "" );
|
||||
const [snapPoint, setSnapPoint] = useState( 263 );
|
||||
const [showCommentBox, setShowCommentBox] = useState( false );
|
||||
|
||||
useEffect( () => {
|
||||
if ( comment.length !== 0 ) {
|
||||
@@ -70,9 +48,7 @@ const AgreeWithIDSheet = ( {
|
||||
|
||||
return (
|
||||
<BottomSheet
|
||||
hidden={!showAgreeWithIdSheet}
|
||||
handleClose={handleClose}
|
||||
confirm={discardChanges}
|
||||
headerText={t( "AGREE-WITH-ID" )}
|
||||
snapPoints={[snapPoint]}
|
||||
text={t( "By-exiting-changes-not-saved" )}
|
||||
@@ -101,7 +77,7 @@ const AgreeWithIDSheet = ( {
|
||||
? (
|
||||
<Button
|
||||
text={t( "EDIT-COMMENT" )}
|
||||
onPress={openCommentBox}
|
||||
onPress={( ) => setShowCommentBox( true )}
|
||||
className="mx-2 grow"
|
||||
testID="ObsDetail.AgreeId.EditCommentButton"
|
||||
disabled={!comment}
|
||||
@@ -111,7 +87,7 @@ const AgreeWithIDSheet = ( {
|
||||
: (
|
||||
<Button
|
||||
text={t( "ADD-COMMENT" )}
|
||||
onPress={openCommentBox}
|
||||
onPress={( ) => setShowCommentBox( true )}
|
||||
className="mx-2 grow"
|
||||
testID="ObsDetail.AgreeId.commentButton"
|
||||
disabled={false}
|
||||
@@ -121,7 +97,9 @@ const AgreeWithIDSheet = ( {
|
||||
|
||||
<Button
|
||||
text={t( "AGREE" )}
|
||||
onPress={onAgree}
|
||||
onPress={( ) => {
|
||||
onAgree( comment );
|
||||
}}
|
||||
className="mx-2 grow"
|
||||
testID="ObsDetail.AgreeId.cvSuggestionsButton"
|
||||
accessibilityRole="link"
|
||||
@@ -129,6 +107,14 @@ const AgreeWithIDSheet = ( {
|
||||
level={comment && "primary"}
|
||||
/>
|
||||
</View>
|
||||
{showCommentBox && (
|
||||
<TextInputSheet
|
||||
handleClose={( ) => setShowCommentBox( false )}
|
||||
headerText={t( "ADD-OPTIONAL-COMMENT" )}
|
||||
snapPoints={[416]}
|
||||
confirm={textInput => setComment( textInput )}
|
||||
/>
|
||||
)}
|
||||
</BottomSheet>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -30,7 +30,7 @@ const DisplayTaxon = ( {
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
className="flex-row items-center"
|
||||
className="flex-row items-center shrink"
|
||||
onPress={handlePress}
|
||||
testID={testID}
|
||||
accessibilityLabel={accessibilityLabel || t( "Taxon-photo-and-name" )}
|
||||
@@ -46,7 +46,7 @@ const DisplayTaxon = ( {
|
||||
accessibilityIgnoresInvertColors
|
||||
testID="DisplayTaxon.image"
|
||||
/>
|
||||
<View className="ml-3">
|
||||
<View className="ml-3 shrink">
|
||||
<DisplayTaxonName taxon={taxon} withdrawn={withdrawn} />
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import ActivityItem from "components/ObsDetails/ActivityItem";
|
||||
import ActivityItem from "components/ObsDetails/ActivityTab/ActivityItem";
|
||||
import {
|
||||
ActivityCount,
|
||||
Body1,
|
||||
|
||||
@@ -15,7 +15,7 @@ import Messages from "components/Messages/Messages";
|
||||
import MyObservationsContainer from "components/MyObservations/MyObservationsContainer";
|
||||
import NetworkLogging from "components/NetworkLogging";
|
||||
import DataQualityAssessment from "components/ObsDetails/DataQualityAssessment";
|
||||
import ObsDetails from "components/ObsDetails/ObsDetails";
|
||||
import ObsDetailsContainer from "components/ObsDetails/ObsDetailsContainer";
|
||||
import ObsEdit from "components/ObsEdit/ObsEdit";
|
||||
import GroupPhotosContainer from "components/PhotoImporter/GroupPhotosContainer";
|
||||
import PhotoGallery from "components/PhotoImporter/PhotoGallery";
|
||||
@@ -275,7 +275,7 @@ const BottomTabs = ( ) => {
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="ObsDetails"
|
||||
component={ObsDetails}
|
||||
component={ObsDetailsContainer}
|
||||
options={{
|
||||
headerTitle: t( "Observation" ),
|
||||
headerShown: false,
|
||||
|
||||
@@ -1,22 +1,15 @@
|
||||
// @flow
|
||||
|
||||
import { RealmContext } from "providers/contexts";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const { useRealm } = RealmContext;
|
||||
|
||||
const useLocalObservation = ( uuid: string ): Object => {
|
||||
const [localObservation, setLocalObservation] = useState( null );
|
||||
|
||||
const realm = useRealm( );
|
||||
|
||||
useEffect( ( ) => {
|
||||
if ( !uuid ) { return; }
|
||||
const obs = realm.objectForPrimaryKey( "Observation", uuid );
|
||||
setLocalObservation( obs );
|
||||
}, [realm, uuid] );
|
||||
|
||||
return localObservation;
|
||||
return uuid
|
||||
? realm.objectForPrimaryKey( "Observation", uuid )
|
||||
: null;
|
||||
};
|
||||
|
||||
export default useLocalObservation;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { screen } from "@testing-library/react-native";
|
||||
import ObsDetails from "components/ObsDetails/ObsDetails";
|
||||
import ObsDetailsContainer from "components/ObsDetails/ObsDetailsContainer";
|
||||
import initI18next from "i18n/initI18next";
|
||||
import inatjs from "inaturalistjs";
|
||||
import React from "react";
|
||||
@@ -67,7 +67,7 @@ describe( "ObsDetails", () => {
|
||||
// Expect the observation in realm to have comments_viewed param not initialized
|
||||
const observation = global.realm.objects( "Observation" )[0];
|
||||
expect( observation.comments_viewed ).not.toBeTruthy();
|
||||
renderAppWithComponent( <ObsDetails /> );
|
||||
renderAppWithComponent( <ObsDetailsContainer /> );
|
||||
expect(
|
||||
await screen.findByText( `@${mockObservation.user.login}` )
|
||||
).toBeTruthy();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { screen } from "@testing-library/react-native";
|
||||
import ActivityItem from "components/ObsDetails/ActivityItem";
|
||||
import ActivityItem from "components/ObsDetails/ActivityTab/ActivityItem";
|
||||
import initI18next from "i18n/initI18next";
|
||||
import React from "react";
|
||||
|
||||
@@ -74,7 +74,6 @@ describe( "ActivityItem", () => {
|
||||
observationUUID=""
|
||||
item={mockIdentification}
|
||||
navToTaxonDetails={jest.fn()}
|
||||
toggleRefetch={jest.fn()}
|
||||
refetchRemoteObservation={jest.fn()}
|
||||
onAgree={jest.fn()}
|
||||
currentUserId="000"
|
||||
@@ -91,7 +90,6 @@ describe( "ActivityItem", () => {
|
||||
observationUUID=""
|
||||
item={mockIdentification}
|
||||
navToTaxonDetails={jest.fn()}
|
||||
toggleRefetch={jest.fn()}
|
||||
refetchRemoteObservation={jest.fn()}
|
||||
onAgree={jest.fn()}
|
||||
currentUserId="000"
|
||||
@@ -116,7 +114,6 @@ describe( "ActivityItem", () => {
|
||||
observationUUID=""
|
||||
item={mockId}
|
||||
navToTaxonDetails={jest.fn()}
|
||||
toggleRefetch={jest.fn()}
|
||||
refetchRemoteObservation={jest.fn()}
|
||||
onAgree={jest.fn()}
|
||||
currentUserId="000"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { screen } from "@testing-library/react-native";
|
||||
import ActivityTab from "components/ObsDetails/ActivityTab";
|
||||
import ActivityTab from "components/ObsDetails/ActivityTab/ActivityTab";
|
||||
import initI18next from "i18n/initI18next";
|
||||
import React from "react";
|
||||
|
||||
@@ -55,14 +55,10 @@ describe( "ActivityTab", () => {
|
||||
await initI18next( );
|
||||
renderComponent(
|
||||
<ActivityTab
|
||||
uuid={mockObservation.uuid}
|
||||
observation={mockObservation}
|
||||
comments={[]}
|
||||
navToTaxonDetails={jest.fn()}
|
||||
toggleRefetch={jest.fn()}
|
||||
activityItems={[]}
|
||||
refetchRemoteObservation={jest.fn()}
|
||||
openCommentBox={jest.fn()}
|
||||
showCommentBox={jest.fn()}
|
||||
onIDAgreePressed={jest.fn()}
|
||||
/>
|
||||
);
|
||||
expect( await screen.findByTestId( "ActivityTab" ) ).toBeTruthy( );
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { fireEvent, screen } from "@testing-library/react-native";
|
||||
import DataQualityAssessment from "components/ObsDetails/DataQualityAssessment";
|
||||
import DQAVoteButtons from "components/ObsDetails/DQAVoteButtons";
|
||||
import DQAVoteButtons from "components/ObsDetails/DetailsTab/DQAVoteButtons";
|
||||
import initI18next from "i18n/initI18next";
|
||||
import { t } from "i18next";
|
||||
import React from "react";
|
||||
@@ -75,7 +75,7 @@ jest.mock( "sharedHooks/useAuthenticatedMutation", () => ( {
|
||||
} ) );
|
||||
|
||||
const mockAttribution = <View testID="mock-attribution" />;
|
||||
jest.mock( "components/ObsDetails/Attribution", () => ( {
|
||||
jest.mock( "components/ObsDetails/DetailsTab/Attribution", () => ( {
|
||||
__esModule: true,
|
||||
default: () => mockAttribution
|
||||
} ) );
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { screen } from "@testing-library/react-native";
|
||||
import DetailsTab from "components/ObsDetails/DetailsTab";
|
||||
import DetailsTab from "components/ObsDetails/DetailsTab/DetailsTab";
|
||||
import initI18next from "i18n/initI18next";
|
||||
import React from "react";
|
||||
import { View } from "react-native";
|
||||
@@ -8,17 +8,6 @@ import { View } from "react-native";
|
||||
import factory from "../../../factory";
|
||||
import { renderComponent } from "../../../helpers/render";
|
||||
|
||||
// jest.mock( "react-i18next", () => ( {
|
||||
// useTranslation: () => ( {
|
||||
// t: str => {
|
||||
// if ( str === "datetime-format-short" ) {
|
||||
// return "M/d/yy h:mm a";
|
||||
// }
|
||||
// return str;
|
||||
// }
|
||||
// } )
|
||||
// } ) );
|
||||
|
||||
jest.mock( "sharedHooks/useIsConnected", ( ) => ( {
|
||||
__esModule: true,
|
||||
default: ( ) => true
|
||||
@@ -41,7 +30,7 @@ const mockObservation = factory( "LocalObservation", {
|
||||
} );
|
||||
|
||||
const mockAttribution = <View testID="mock-attribution" />;
|
||||
jest.mock( "components/ObsDetails/Attribution", () => ( {
|
||||
jest.mock( "components/ObsDetails/DetailsTab/Attribution", () => ( {
|
||||
__esModule: true,
|
||||
default: () => mockAttribution
|
||||
} ) );
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { fireEvent, screen } from "@testing-library/react-native";
|
||||
import ActivityItem from "components/ObsDetails/ActivityItem";
|
||||
import ActivityItem from "components/ObsDetails/ActivityTab/ActivityItem";
|
||||
import FlagItemModal from "components/ObsDetails/FlagItemModal";
|
||||
import initI18next from "i18n/initI18next";
|
||||
import React from "react";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { fireEvent, screen } from "@testing-library/react-native";
|
||||
import ObsDetails from "components/ObsDetails/ObsDetails";
|
||||
import ObsDetailsContainer from "components/ObsDetails/ObsDetailsContainer";
|
||||
import initI18next from "i18n/initI18next";
|
||||
import { t } from "i18next";
|
||||
import React from "react";
|
||||
@@ -118,12 +118,11 @@ jest.mock( "sharedHooks/useAuthenticatedMutation", () => ( {
|
||||
} )
|
||||
} ) );
|
||||
|
||||
jest.mock( "components/ObsDetails/AddCommentModal" );
|
||||
jest.mock( "components/ObsDetails/ActivityTab" );
|
||||
jest.mock( "components/ObsDetails/ActivityTab/ActivityTab" );
|
||||
jest.mock( "components/SharedComponents/PhotoScroll" );
|
||||
|
||||
const mockDataTab = <View testID="mock-data-tab" />;
|
||||
jest.mock( "components/ObsDetails/DetailsTab", () => ( {
|
||||
jest.mock( "components/ObsDetails/DetailsTab/DetailsTab", () => ( {
|
||||
__esModule: true,
|
||||
default: () => mockDataTab
|
||||
} ) );
|
||||
@@ -156,7 +155,7 @@ describe( "ObsDetails", () => {
|
||||
} );
|
||||
|
||||
it( "should not have accessibility errors", async () => {
|
||||
renderComponent( <ObsDetails /> );
|
||||
renderComponent( <ObsDetailsContainer /> );
|
||||
const obsDetails = await screen.findByTestId(
|
||||
`ObsDetails.${mockObservation.uuid}`
|
||||
);
|
||||
@@ -165,7 +164,7 @@ describe( "ObsDetails", () => {
|
||||
|
||||
it( "renders obs details from remote call", async () => {
|
||||
useIsConnected.mockImplementation( () => true );
|
||||
renderComponent( <ObsDetails /> );
|
||||
renderComponent( <ObsDetailsContainer /> );
|
||||
|
||||
expect(
|
||||
await screen.findByTestId( `ObsDetails.${mockObservation.uuid}` )
|
||||
@@ -174,7 +173,7 @@ describe( "ObsDetails", () => {
|
||||
} );
|
||||
|
||||
it( "renders data tab on button press", async () => {
|
||||
renderComponent( <ObsDetails /> );
|
||||
renderComponent( <ObsDetailsContainer /> );
|
||||
const button = await screen.findByTestId( "ObsDetails.DetailsTab" );
|
||||
expect( screen.queryByTestId( "mock-data-tab" ) ).not.toBeTruthy();
|
||||
|
||||
@@ -191,7 +190,7 @@ describe( "ObsDetails", () => {
|
||||
|
||||
it( "should render fallback image icon instead of photos", async () => {
|
||||
useIsConnected.mockImplementation( () => true );
|
||||
renderComponent( <ObsDetails /> );
|
||||
renderComponent( <ObsDetailsContainer /> );
|
||||
|
||||
const labelText = t( "Observation-has-no-photos-and-no-sounds" );
|
||||
const fallbackImage = await screen.findByLabelText( labelText );
|
||||
@@ -207,7 +206,7 @@ describe( "ObsDetails", () => {
|
||||
|
||||
describe( "activity tab", () => {
|
||||
it( "navigates to taxon details on button press", async () => {
|
||||
renderComponent( <ObsDetails /> );
|
||||
renderComponent( <ObsDetailsContainer /> );
|
||||
fireEvent.press(
|
||||
await screen.findByTestId(
|
||||
`ObsDetails.taxon.${mockObservation.taxon.id}`
|
||||
@@ -220,7 +219,7 @@ describe( "ObsDetails", () => {
|
||||
|
||||
it( "shows network error image instead of observation photos if user is offline", async () => {
|
||||
useIsConnected.mockImplementation( () => false );
|
||||
renderComponent( <ObsDetails /> );
|
||||
renderComponent( <ObsDetailsContainer /> );
|
||||
const labelText = t( "Observation-photos-unavailable-without-internet" );
|
||||
const noInternet = await screen.findByLabelText( labelText );
|
||||
expect( noInternet ).toBeTruthy();
|
||||
|
||||
Reference in New Issue
Block a user