diff --git a/src/components/AddObsBottomSheet/AddObsButton.js b/src/components/AddObsBottomSheet/AddObsButton.js
index 0b8dae60c..c64c9eaa0 100644
--- a/src/components/AddObsBottomSheet/AddObsButton.js
+++ b/src/components/AddObsBottomSheet/AddObsButton.js
@@ -46,10 +46,10 @@ const AddObsButton = ( ): React.Node => {
// Base trigger condition in all cases:
// Only show the tooltip if the user has the AI camera as the default button option.
// Only show the tooltip on MyObservations screen.
- const onObsList = currentRoute?.name === "MyObservationsResults";
+ const isOnMyObservationsResults = currentRoute?.name === "MyObservationsResults";
const onlyAiCamera = !isAllAddObsOptionsMode;
- let triggerCondition = onObsList && onlyAiCamera;
+ let triggerCondition = isOnMyObservationsResults && onlyAiCamera;
if ( justFinishedSignup ) {
// If a user creates a new account, they should see the tooltip right after dismissing the
diff --git a/src/components/Explore/SearchScreens/ExploreSearchHeader.tsx b/src/components/Explore/SearchScreens/ExploreSearchHeader.tsx
index 130f3ee2e..74f8df5bf 100644
--- a/src/components/Explore/SearchScreens/ExploreSearchHeader.tsx
+++ b/src/components/Explore/SearchScreens/ExploreSearchHeader.tsx
@@ -1,9 +1,10 @@
+import classnames from "classnames";
import {
BackButton,
Body3,
Heading4,
} from "components/SharedComponents";
-import { View } from "components/styledComponents";
+import { Pressable, View } from "components/styledComponents";
import React from "react";
import { useTranslation } from "sharedHooks";
@@ -11,6 +12,7 @@ interface Props {
closeModal: ( ) => void;
headerText: string;
resetFilters: ( ) => void;
+ resetDisabled?: boolean;
testID: string;
}
@@ -18,6 +20,7 @@ const ExploreSearchHeader = ( {
closeModal,
headerText,
resetFilters,
+ resetDisabled = false,
testID,
}: Props ) => {
const { t } = useTranslation( );
@@ -32,11 +35,21 @@ const ExploreSearchHeader = ( {
/>
{headerText}
-
-
+
+
{t( "Reset-verb" )}
-
+
);
};
diff --git a/src/components/MyObservations/MyObservationsSimple.tsx b/src/components/MyObservations/MyObservationsSimple.tsx
index 6305b7d96..a1d686dce 100644
--- a/src/components/MyObservations/MyObservationsSimple.tsx
+++ b/src/components/MyObservations/MyObservationsSimple.tsx
@@ -35,6 +35,8 @@ import {
} from "sharedHelpers/speciesSort";
import { accessibleTaxonName } from "sharedHelpers/taxon";
import { useGridLayout, useLayoutPrefs, useTranslation } from "sharedHooks";
+import useFeatureFlag from "sharedHooks/useFeatureFlag";
+import { FeatureFlag } from "stores/createFeatureFlagSlice";
import colors from "styles/tailwindColors";
import type { SpeciesCount } from "types/sorting";
@@ -42,6 +44,7 @@ import LoginSheet from "./LoginSheet";
import { ACTIVE_SHEET } from "./MyObservationsResults";
import MyObservationsSimpleHeader from "./MyObservationsSimpleHeader";
import PivotCardObsGridItem from "./PivotCardObsGridItem";
+import SearchedTaxonBanner from "./Search/SearchedTaxonBanner";
import SimpleHeader from "./SimpleHeader";
import SimpleTaxonGridItem from "./SimpleTaxonGridItem";
@@ -131,6 +134,9 @@ const MyObservationsSimple = ( {
}: Props ) => {
const { isDefaultMode } = useLayoutPrefs( );
const { t } = useTranslation( );
+ const searchMyObservationsEnabled = useFeatureFlag(
+ FeatureFlag.SearchMyObservationsEnabled,
+ );
const speciesSortLabels = useSpeciesSortLabels( );
const navigation = useNavigation( );
const route = useRoute( );
@@ -358,6 +364,9 @@ const MyObservationsSimple = ( {
]}
TabComponent={renderTabComponent}
/>
+ {searchMyObservationsEnabled && activeTab === OBSERVATIONS_TAB && (
+
+ )}
{ activeTab === OBSERVATIONS_TAB && (
<>
{
if ( !isConnected ) {
@@ -111,6 +114,9 @@ const MyObservationsSimpleHeader = ( {
diff --git a/src/components/MyObservations/Search/SearchEmptyState.tsx b/src/components/MyObservations/Search/SearchEmptyState.tsx
new file mode 100644
index 000000000..ab01ee652
--- /dev/null
+++ b/src/components/MyObservations/Search/SearchEmptyState.tsx
@@ -0,0 +1,38 @@
+// TODO: This component is intentionally not rendered anywhere yet.
+// This is the empty state for the Search My Observations feature.
+
+import {
+ Body1,
+ Button,
+} from "components/SharedComponents";
+import { View } from "components/styledComponents";
+import {
+ MY_OBSERVATIONS_ACTION,
+ useMyObservations,
+} from "providers/MyObservationsContext";
+import React from "react";
+import { useTranslation } from "sharedHooks";
+
+const SearchEmptyState = ( ) => {
+ const { t } = useTranslation( );
+ const { dispatch } = useMyObservations( );
+
+ return (
+
+
+ {t( "Looks-like-you-havent-observed-this-yet-time-to-keep-exploring" )}
+
+
+ );
+};
+
+export default SearchEmptyState;
diff --git a/src/components/MyObservations/Search/SearchMyObservationsTaxon.tsx b/src/components/MyObservations/Search/SearchMyObservationsTaxon.tsx
index 1a700d1c0..462e98ca4 100644
--- a/src/components/MyObservations/Search/SearchMyObservationsTaxon.tsx
+++ b/src/components/MyObservations/Search/SearchMyObservationsTaxon.tsx
@@ -14,14 +14,23 @@ import {
useMyObservations,
} from "providers/MyObservationsContext";
import React, { useCallback, useState } from "react";
-import { useTranslation } from "sharedHooks";
+import type { RealmTaxon, RealmUser } from "realmModels/types";
+import { taxonDisplayName } from "sharedHelpers/taxon";
+import { useCurrentUser, useTranslation } from "sharedHooks";
import useTaxonSearch from "sharedHooks/useTaxonSearch";
const SearchMyObservationsTaxon = ( ) => {
const { t } = useTranslation( );
const navigation = useNavigation( );
- const { dispatch } = useMyObservations( );
- const [taxonQuery, setTaxonQuery] = useState( "" );
+ const { state, dispatch } = useMyObservations( );
+ const { searchedTaxon } = state;
+ const currentUser = useCurrentUser( ) as RealmUser | null;
+
+ const [taxonQuery, setTaxonQuery] = useState( ( ) => (
+ searchedTaxon
+ ? taxonDisplayName( searchedTaxon, currentUser )
+ : ""
+ ) );
const { taxa, isLoading, isLocal } = useTaxonSearch( taxonQuery );
@@ -29,12 +38,21 @@ const SearchMyObservationsTaxon = ( ) => {
const onTaxonSelected = useCallback( ( newTaxon: ApiTaxon | null ) => {
if ( newTaxon && typeof newTaxon.id === "number" && newTaxon.name ) {
+ // useTaxonSearch can return either ApiTaxon-shaped or RealmTaxon-shaped
+ // taxa depending on the source, so we have to check for both here.
+ // TODO: normalize taxa at ingest.
+ const realmTaxon = newTaxon as RealmTaxon;
+ const iconUri = newTaxon.default_photo?.url
+ || realmTaxon.defaultPhoto?.url;
+ const preferredCommonName = newTaxon.preferred_common_name
+ || realmTaxon.preferredCommonName;
dispatch( {
type: MY_OBSERVATIONS_ACTION.SET_TAXON_SEARCH,
searchTaxon: {
id: newTaxon.id,
name: newTaxon.name,
- preferred_common_name: newTaxon.preferred_common_name,
+ preferredCommonName,
+ iconUri,
},
} );
} else {
@@ -43,7 +61,11 @@ const SearchMyObservationsTaxon = ( ) => {
closeScreen( );
}, [closeScreen, dispatch] );
- const resetSearch = useCallback( ( ) => setTaxonQuery( "" ), [] );
+ const resetSearch = useCallback( ( ) => {
+ setTaxonQuery( "" );
+ dispatch( { type: MY_OBSERVATIONS_ACTION.CLEAR_TAXON_SEARCH } );
+ closeScreen( );
+ }, [closeScreen, dispatch] );
const renderItem = useCallback(
// no-unused-prop-types failing for components defined at runtime seems to
@@ -68,6 +90,7 @@ const SearchMyObservationsTaxon = ( ) => {
closeModal={closeScreen}
headerText={t( "SEARCH" )}
resetFilters={resetSearch}
+ resetDisabled={!searchedTaxon}
testID="SearchMyObservationsTaxon.close"
/>
{
+ const { t } = useTranslation( );
+ const { state, dispatch } = useMyObservations( );
+ const { searchedTaxon } = state;
+
+ // The Realm `User` class doesn't declare its schema fields as typed
+ // instance properties (see realmModels/User.ts), so cast to the typed
+ // RealmUser interface to access the user's name-display prefs.
+ const currentUser = useCurrentUser( ) as RealmUser | null;
+
+ if ( !searchedTaxon ) return null;
+
+ const displayName = taxonDisplayName( searchedTaxon, currentUser );
+
+ return (
+
+
+
+ {searchedTaxon.iconUri && (
+
+ )}
+
+
+ {displayName}
+
+
+ dispatch( {
+ type: MY_OBSERVATIONS_ACTION.CLEAR_TAXON_SEARCH,
+ } )}
+ />
+
+ );
+};
+
+export default SearchedTaxonBanner;
diff --git a/src/components/MyObservations/SimpleHeader.tsx b/src/components/MyObservations/SimpleHeader.tsx
index 6f8a531a8..ab8ed5883 100644
--- a/src/components/MyObservations/SimpleHeader.tsx
+++ b/src/components/MyObservations/SimpleHeader.tsx
@@ -4,6 +4,7 @@ import {
INatIcon,
} from "components/SharedComponents";
import { View } from "components/styledComponents";
+import { useMyObservations } from "providers/MyObservationsContext";
import React from "react";
import { useCurrentUser, useTranslation } from "sharedHooks";
import useStore from "stores/useStore";
@@ -37,6 +38,13 @@ const SimpleHeader = ( {
const setIsDefaultMode = useStore( state => state.layout.setIsDefaultMode );
const currentUser = useCurrentUser();
+ const { state: myObsState } = useMyObservations( );
+ const searchActive = myObsState.searchedTaxon !== null;
+
+ // While a search is active, the SearchedTaxonBanner is the only banner we
+ // want to show. Suppress the announcement / advanced-mode / location-missing
+ // banners so they don't compete for attention or change the layout.
+ if ( searchActive ) return null;
const shouldShowAdvancedModeBanner = !!currentUser
&& isDefaultMode
diff --git a/src/i18n/l10n/en.ftl b/src/i18n/l10n/en.ftl
index 5b4dc1f35..4b3f430df 100644
--- a/src/i18n/l10n/en.ftl
+++ b/src/i18n/l10n/en.ftl
@@ -732,6 +732,10 @@ LOG-IN-TO-INATURALIST = LOG IN TO INATURALIST
Log-in-to-iNaturalist = Log in to iNaturalist
LOG-OUT = LOG OUT
LOG-OUT--question = LOG OUT?
+# When the user searches their own observations for something they haven't observed
+Looks-like-you-havent-observed-this-yet-time-to-keep-exploring =
+ Looks like you haven't observed this yet.
+ Time to keep exploring!
Lowest = Lowest
LOWEST-RANK = LOWEST RANK
MAP = MAP
diff --git a/src/i18n/l10n/en.ftl.json b/src/i18n/l10n/en.ftl.json
index ff02192a8..6a1c5d099 100644
--- a/src/i18n/l10n/en.ftl.json
+++ b/src/i18n/l10n/en.ftl.json
@@ -422,6 +422,7 @@
"Log-in-to-iNaturalist": "Log in to iNaturalist",
"LOG-OUT": "LOG OUT",
"LOG-OUT--question": "LOG OUT?",
+ "Looks-like-you-havent-observed-this-yet-time-to-keep-exploring": "Looks like you haven't observed this yet.\nTime to keep exploring!",
"Lowest": "Lowest",
"LOWEST-RANK": "LOWEST RANK",
"MAP": "MAP",
diff --git a/src/i18n/strings.ftl b/src/i18n/strings.ftl
index 5b4dc1f35..4b3f430df 100644
--- a/src/i18n/strings.ftl
+++ b/src/i18n/strings.ftl
@@ -732,6 +732,10 @@ LOG-IN-TO-INATURALIST = LOG IN TO INATURALIST
Log-in-to-iNaturalist = Log in to iNaturalist
LOG-OUT = LOG OUT
LOG-OUT--question = LOG OUT?
+# When the user searches their own observations for something they haven't observed
+Looks-like-you-havent-observed-this-yet-time-to-keep-exploring =
+ Looks like you haven't observed this yet.
+ Time to keep exploring!
Lowest = Lowest
LOWEST-RANK = LOWEST RANK
MAP = MAP
diff --git a/src/providers/MyObservationsContext.tsx b/src/providers/MyObservationsContext.tsx
index 8ccad9dce..0a5060723 100644
--- a/src/providers/MyObservationsContext.tsx
+++ b/src/providers/MyObservationsContext.tsx
@@ -12,7 +12,8 @@ export enum MY_OBSERVATIONS_ACTION {
export interface MyObservationsTaxon {
id: number;
name: string;
- preferred_common_name?: string;
+ preferredCommonName?: string;
+ iconUri?: string;
}
export interface MyObservationsState {
diff --git a/src/sharedHelpers/taxon.ts b/src/sharedHelpers/taxon.ts
index 823ec0601..e51e0928a 100644
--- a/src/sharedHelpers/taxon.ts
+++ b/src/sharedHelpers/taxon.ts
@@ -179,6 +179,25 @@ export function accessibleTaxonName(
return t( "accessible-comname-sciname", { scientificName, commonName } );
}
+interface TaxonName {
+ name: string;
+ preferredCommonName?: string;
+}
+
+// Returns a single taxon name to show for a taxon in a single-line context
+// like banners & search bars, honoring the user's name-display preferences.
+// For two-line rendering with italics, use instead.
+export function taxonDisplayName(
+ taxon: TaxonName,
+ user: User | null,
+): string {
+ const preferScientific = user?.prefers_scientific_name_first === true
+ || user?.prefers_common_names === false;
+ return preferScientific
+ ? taxon.name
+ : ( taxon.preferredCommonName || taxon.name );
+}
+
// Translates rank in a way that can be statically checked
export function translatedRank( rank: string, t: ( key: string ) => string ) {
switch ( rank ) {
diff --git a/src/sharedHooks/useTaxonSearch.ts b/src/sharedHooks/useTaxonSearch.ts
index 4cdde620a..616d96a52 100644
--- a/src/sharedHooks/useTaxonSearch.ts
+++ b/src/sharedHooks/useTaxonSearch.ts
@@ -1,3 +1,4 @@
+import { useNetInfo } from "@react-native-community/netinfo";
import { fetchSearchResults } from "api/search";
import type { ApiOpts } from "api/types";
import { RealmContext } from "providers/contexts";
@@ -32,6 +33,7 @@ function saveTaxaToRealm( taxa: Taxon[], realm: Realm ) {
const useTaxonSearch = ( taxonQueryArg = "" ) => {
const realm = useRealm( );
const iconicTaxa = useIconicTaxa( { reload: false } );
+ const { isConnected } = useNetInfo( );
// Remove leading and trailing whitespace, no need to perform new queries or
// potentially get different results b/c of meaningless whitespace
const taxonQuery = taxonQueryArg.trim();
@@ -39,7 +41,9 @@ const useTaxonSearch = ( taxonQueryArg = "" ) => {
const shouldFetchRemote = taxonQuery.length > 0;
- const { data: remoteTaxa, refetch, isLoading } = useAuthenticatedQuery(
+ const {
+ data: remoteTaxa, refetch, isLoading, isFetched,
+ } = useAuthenticatedQuery(
["fetchTaxonSuggestions", taxonQuery],
async ( optsWithAuth: ApiOpts ) => {
const apiTaxa = await fetchSearchResults(
@@ -96,6 +100,14 @@ const useTaxonSearch = ( taxonQueryArg = "" ) => {
if ( isLoading ) return;
+ // Don't fall back to local results until the remote query has actually
+ // run at least once. useAuthenticatedQuery starts with enabled=false
+ // while it resolves the auth state, which makes isLoading=false even
+ // though no remote fetch has happened yet — without this check we'd
+ // flash the "Showing offline search results" callout in that window.
+ // Skip the gate when we know we're offline and will need remote taxa
+ if ( shouldFetchRemote && !isFetched && isConnected !== false ) return;
+
if ( remoteTaxa && remoteTaxa.length > 0 ) {
if ( isSubscribed ) setLocalTaxa( null );
return;
@@ -116,10 +128,13 @@ const useTaxonSearch = ( taxonQueryArg = "" ) => {
isSubscribed = false;
};
}, [
+ isConnected,
+ isFetched,
isLoading,
realm,
remoteTaxa,
safeRealmSearch,
+ shouldFetchRemote,
taxonQuery,
] );
diff --git a/tests/unit/components/MyObservations/MyObservationsSimple.test.js b/tests/unit/components/MyObservations/MyObservationsSimple.test.js
index ce8220cfe..d4b97c4f5 100644
--- a/tests/unit/components/MyObservations/MyObservationsSimple.test.js
+++ b/tests/unit/components/MyObservations/MyObservationsSimple.test.js
@@ -1,6 +1,7 @@
import { screen } from "@testing-library/react-native";
import MyObservationsSimple, { OBSERVATIONS_TAB }
from "components/MyObservations/MyObservationsSimple";
+import { MyObservationsProvider } from "providers/MyObservationsContext";
import React from "react";
// import DeviceInfo from "react-native-device-info";
import useDeviceOrientation from "sharedHooks/useDeviceOrientation";
@@ -59,14 +60,16 @@ jest.mock( "sharedHooks/useDeviceOrientation", ( ) => ( {
} ) );
const renderMyObservations = layout => renderComponent(
- ,
+
+
+ ,
);
describe( "MyObservationsSimple", () => {
diff --git a/tests/unit/sharedHooks/useTaxonSearch.test.js b/tests/unit/sharedHooks/useTaxonSearch.test.js
index fd2ea9e08..93f30cd68 100644
--- a/tests/unit/sharedHooks/useTaxonSearch.test.js
+++ b/tests/unit/sharedHooks/useTaxonSearch.test.js
@@ -1,3 +1,4 @@
+import { useNetInfo } from "@react-native-community/netinfo";
import { renderHook } from "@testing-library/react-native";
import useAuthenticatedQuery from "sharedHooks/useAuthenticatedQuery";
import useTaxonSearch from "sharedHooks/useTaxonSearch";
@@ -18,6 +19,7 @@ jest.mock( "sharedHooks/useAuthenticatedQuery", ( ) => ( {
data: [],
refetch: jest.mock( ),
isLoading: false,
+ isFetched: true,
} ) ),
} ) );
@@ -74,6 +76,7 @@ describe( "useTaxonSearch", ( ) => {
data: [factory( "LocalTaxon" )],
refetch: jest.mock( ),
isLoading: false,
+ isFetched: true,
} ) );
}
@@ -88,4 +91,27 @@ describe( "useTaxonSearch", ( ) => {
renderHook( ( ) => useTaxonSearch( "foo" ) );
expect( mockRealmObjects ).not.toHaveBeenCalledWith( "Taxon" );
} );
+
+ it( "when online should not request local taxa while remote query has not run yet", ( ) => {
+ useAuthenticatedQuery.mockImplementation( ( ) => ( {
+ data: undefined,
+ refetch: jest.mock( ),
+ isLoading: false,
+ isFetched: false,
+ } ) );
+ renderHook( ( ) => useTaxonSearch( "foo" ) );
+ expect( mockRealmObjects ).not.toHaveBeenCalledWith( "Taxon" );
+ } );
+
+ it( "when offline should request local taxa offline even before the remote query runs", ( ) => {
+ useNetInfo.mockReturnValueOnce( { isConnected: false } );
+ useAuthenticatedQuery.mockImplementation( ( ) => ( {
+ data: undefined,
+ refetch: jest.mock( ),
+ isLoading: false,
+ isFetched: false,
+ } ) );
+ renderHook( ( ) => useTaxonSearch( "foo" ) );
+ expect( mockRealmObjects ).toHaveBeenCalledWith( "Taxon" );
+ } );
} );