Refactor ObsDetails and show ID after agree button pressed (#738)

This commit is contained in:
Amanda Bullington
2023-08-16 16:09:46 -07:00
committed by GitHub
parent a17f8e026d
commit 5bfa7940e1
27 changed files with 958 additions and 1047 deletions

View File

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

View File

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

View File

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

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

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

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import ActivityItem from "components/ObsDetails/ActivityItem";
import ActivityItem from "components/ObsDetails/ActivityTab/ActivityItem";
import {
ActivityCount,
Body1,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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