From 6aecc2505b836f1c6d0747bfa0c411c49d7347be Mon Sep 17 00:00:00 2001 From: sepeterson <10458078+sepeterson@users.noreply.github.com> Date: Thu, 30 Apr 2026 08:53:52 -0500 Subject: [PATCH 01/18] MOB-1329: first pass --- .../Explore/ExploreV2/ExploreV2Container.tsx | 83 ++++++ .../Explore/ExploreV2/buildQueryParams.ts | 69 +++++ .../ExploreV2/screens/AdvancedSearch.tsx | 18 ++ .../ExploreV2/screens/ExploreObservations.tsx | 117 ++++++++ .../ExploreV2/screens/UniversalSearch.tsx | 18 ++ .../StackNavigators/ExploreStackNavigator.tsx | 42 +++ .../StackNavigators/TabStackNavigator.tsx | 8 +- src/navigation/types.ts | 9 + src/providers/ExploreV2Context.tsx | 260 ++++++++++++++++++ 9 files changed, 623 insertions(+), 1 deletion(-) create mode 100644 src/components/Explore/ExploreV2/ExploreV2Container.tsx create mode 100644 src/components/Explore/ExploreV2/buildQueryParams.ts create mode 100644 src/components/Explore/ExploreV2/screens/AdvancedSearch.tsx create mode 100644 src/components/Explore/ExploreV2/screens/ExploreObservations.tsx create mode 100644 src/components/Explore/ExploreV2/screens/UniversalSearch.tsx create mode 100644 src/navigation/StackNavigators/ExploreStackNavigator.tsx create mode 100644 src/providers/ExploreV2Context.tsx diff --git a/src/components/Explore/ExploreV2/ExploreV2Container.tsx b/src/components/Explore/ExploreV2/ExploreV2Container.tsx new file mode 100644 index 000000000..f9ef85d7a --- /dev/null +++ b/src/components/Explore/ExploreV2/ExploreV2Container.tsx @@ -0,0 +1,83 @@ +import ExploreStackNavigator + from "navigation/StackNavigators/ExploreStackNavigator"; +import { + defaultExploreV2Location, + EXPLORE_V2_ACTION, + EXPLORE_V2_PLACE_MODE, + ExploreV2Provider, + useExploreV2, +} from "providers/ExploreV2Context"; +import React, { useEffect, useRef } from "react"; +import useLocationPermission from "sharedHooks/useLocationPermission"; + +const ExploreV2WithProvider = ( ) => { + const { state, dispatch } = useExploreV2( ); + const { + hasPermissions, + renderPermissionsGate, + } = useLocationPermission( ); + const previousHasPermissions = useRef( undefined ); + + // Resolve initial location once we know permission state. NEARBY with no + // coords means we haven't fetched the user's coarse location yet. + useEffect( ( ) => { + let cancelled = false; + async function resolveLocation( ) { + if ( + hasPermissions + && !previousHasPermissions.current + && state.placeMode === EXPLORE_V2_PLACE_MODE.NEARBY + && state.lat === undefined + ) { + const next = await defaultExploreV2Location( ); + if ( cancelled ) return; + if ( + next.placeMode === EXPLORE_V2_PLACE_MODE.NEARBY + && next.lat !== undefined + && next.lng !== undefined + && next.radius !== undefined + ) { + dispatch( { + type: EXPLORE_V2_ACTION.SET_LOCATION_NEARBY, + lat: next.lat, + lng: next.lng, + radius: next.radius, + } ); + } else { + dispatch( { type: EXPLORE_V2_ACTION.SET_LOCATION_WORLDWIDE } ); + } + } + previousHasPermissions.current = hasPermissions; + } + resolveLocation( ); + return ( ) => { + cancelled = true; + }; + }, [hasPermissions, state.placeMode, state.lat, dispatch] ); + + // Per the ticket: default to "Worldwide" when location is denied (or blocked). + // Once permission state is known to be false, fall back from NEARBY. + useEffect( ( ) => { + if ( + hasPermissions === false + && state.placeMode === EXPLORE_V2_PLACE_MODE.NEARBY + ) { + dispatch( { type: EXPLORE_V2_ACTION.SET_LOCATION_WORLDWIDE } ); + } + }, [hasPermissions, state.placeMode, dispatch] ); + + return ( + <> + + {renderPermissionsGate( undefined )} + + ); +}; + +const ExploreV2Container = ( ) => ( + + + +); + +export default ExploreV2Container; diff --git a/src/components/Explore/ExploreV2/buildQueryParams.ts b/src/components/Explore/ExploreV2/buildQueryParams.ts new file mode 100644 index 000000000..08c4f3d18 --- /dev/null +++ b/src/components/Explore/ExploreV2/buildQueryParams.ts @@ -0,0 +1,69 @@ +import type { ExploreV2State } from "providers/ExploreV2Context"; +import { + EXPLORE_V2_PLACE_MODE, + EXPLORE_V2_SORT, +} from "providers/ExploreV2Context"; + +const PER_PAGE = 20; + +export interface ExploreV2QueryParams { + per_page: number; + order_by: "created_at" | "observed_on" | "votes"; + order: "asc" | "desc"; + taxon_id?: number; + user_id?: number; + project_id?: number; + lat?: number; + lng?: number; + radius?: number; + place_id?: number; + verifiable?: boolean; +} + +const sortToOrder: Record< + EXPLORE_V2_SORT, + { order_by: "created_at" | "observed_on" | "votes"; order: "asc" | "desc" } +> = { + [EXPLORE_V2_SORT.DATE_UPLOADED_NEWEST]: { order_by: "created_at", order: "desc" }, + [EXPLORE_V2_SORT.DATE_UPLOADED_OLDEST]: { order_by: "created_at", order: "asc" }, + [EXPLORE_V2_SORT.DATE_OBSERVED_NEWEST]: { order_by: "observed_on", order: "desc" }, + [EXPLORE_V2_SORT.DATE_OBSERVED_OLDEST]: { order_by: "observed_on", order: "asc" }, + [EXPLORE_V2_SORT.MOST_FAVED]: { order_by: "votes", order: "desc" }, +}; + +const buildExploreV2QueryParams = ( + state: ExploreV2State, +): ExploreV2QueryParams => { + const params: ExploreV2QueryParams = { + per_page: PER_PAGE, + verifiable: true, + ...sortToOrder[state.sortBy], + }; + + if ( state.entityType === "taxon" && state.taxon ) { + params.taxon_id = state.taxon.id; + } else if ( state.entityType === "user" && state.user ) { + params.user_id = state.user.id; + } else if ( state.entityType === "project" && state.project ) { + params.project_id = state.project.id; + } + + if ( + state.placeMode === EXPLORE_V2_PLACE_MODE.NEARBY + && state.lat !== undefined + && state.lng !== undefined + ) { + params.lat = state.lat; + params.lng = state.lng; + params.radius = state.radius; + } else if ( + state.placeMode === EXPLORE_V2_PLACE_MODE.PLACE + && state.placeId !== null + ) { + params.place_id = state.placeId; + } + + return params; +}; + +export default buildExploreV2QueryParams; diff --git a/src/components/Explore/ExploreV2/screens/AdvancedSearch.tsx b/src/components/Explore/ExploreV2/screens/AdvancedSearch.tsx new file mode 100644 index 000000000..8bf320311 --- /dev/null +++ b/src/components/Explore/ExploreV2/screens/AdvancedSearch.tsx @@ -0,0 +1,18 @@ +import { Body2, ViewWrapper } from "components/SharedComponents"; +import { View } from "components/styledComponents"; +import { useExploreV2 } from "providers/ExploreV2Context"; +import React from "react"; + +const AdvancedSearch = ( ) => { + useExploreV2( ); + return ( + + + {/* eslint-disable-next-line i18next/no-literal-string */} + TODO: Advanced Search — MOB-1346 + + + ); +}; + +export default AdvancedSearch; diff --git a/src/components/Explore/ExploreV2/screens/ExploreObservations.tsx b/src/components/Explore/ExploreV2/screens/ExploreObservations.tsx new file mode 100644 index 000000000..d3913a9ea --- /dev/null +++ b/src/components/Explore/ExploreV2/screens/ExploreObservations.tsx @@ -0,0 +1,117 @@ +import { useNetInfo } from "@react-native-community/netinfo"; +import { useNavigation } from "@react-navigation/native"; +import classnames from "classnames"; +import buildExploreV2QueryParams + from "components/Explore/ExploreV2/buildQueryParams"; +import useInfiniteExploreScroll + from "components/Explore/hooks/useInfiniteExploreScroll"; +import ObservationsFlashList from "components/ObservationsFlashList/ObservationsFlashList"; +import { + Body2, + INatIconButton, + ViewWrapper, +} from "components/SharedComponents"; +import { Pressable, View } from "components/styledComponents"; +import { EXPLORE_V2_PLACE_MODE, useExploreV2 } from "providers/ExploreV2Context"; +import React, { useMemo } from "react"; +import { Alert } from "react-native"; +import useDebugMode from "sharedHooks/useDebugMode"; +import { getShadow } from "styles/global"; + +const DROP_SHADOW = getShadow( { + offsetHeight: 4, + elevation: 6, +} ); + +const OBS_LIST_CONTAINER_STYLE = { paddingTop: 50 }; + +const ExploreObservations = ( ) => { + const navigation = useNavigation( ); + const { state } = useExploreV2( ); + const { isConnected } = useNetInfo( ); + const { isDebug } = useDebugMode( ); + + const queryParams = useMemo( () => buildExploreV2QueryParams( state ), [state] ); + + // Don't fetch until placeMode has resolved to something usable: worldwide + // (no coords needed), nearby with coords, or a specific place. + const canFetch = state.placeMode === EXPLORE_V2_PLACE_MODE.WORLDWIDE + || ( + state.placeMode === EXPLORE_V2_PLACE_MODE.NEARBY + && queryParams.lat !== undefined + ) + || ( + state.placeMode === EXPLORE_V2_PLACE_MODE.PLACE + && queryParams.place_id !== undefined + ); + + const { + fetchNextPage, + isFetchingNextPage, + handlePullToRefresh, + observations, + totalResults, + } = useInfiniteExploreScroll( { params: queryParams, enabled: canFetch } ); + + return ( + + + navigation.navigate( "UniversalSearch" )} + > + {/* eslint-disable-next-line i18next/no-literal-string */} + TODO: Header — MOB-1327 (tap to open Universal Search) + + + {isDebug && ( + { + Alert.alert( + "ExploreV2 Info", + `state: ${JSON.stringify( state, null, 2 )}\n\nqueryParams: ${ + JSON.stringify( queryParams, null, 2 ) + }`, + ); + }} + /> + )} + + + ); +}; + +export default ExploreObservations; diff --git a/src/components/Explore/ExploreV2/screens/UniversalSearch.tsx b/src/components/Explore/ExploreV2/screens/UniversalSearch.tsx new file mode 100644 index 000000000..d982a6745 --- /dev/null +++ b/src/components/Explore/ExploreV2/screens/UniversalSearch.tsx @@ -0,0 +1,18 @@ +import { Body2, ViewWrapper } from "components/SharedComponents"; +import { View } from "components/styledComponents"; +import { useExploreV2 } from "providers/ExploreV2Context"; +import React from "react"; + +const UniversalSearch = ( ) => { + useExploreV2( ); + return ( + + + {/* eslint-disable-next-line i18next/no-literal-string */} + TODO: Universal Search — MOB-1338 + + + ); +}; + +export default UniversalSearch; diff --git a/src/navigation/StackNavigators/ExploreStackNavigator.tsx b/src/navigation/StackNavigators/ExploreStackNavigator.tsx new file mode 100644 index 000000000..16acf125a --- /dev/null +++ b/src/navigation/StackNavigators/ExploreStackNavigator.tsx @@ -0,0 +1,42 @@ +import { createNativeStackNavigator } from "@react-navigation/native-stack"; +import AdvancedSearch + from "components/Explore/ExploreV2/screens/AdvancedSearch"; +import ExploreObservations + from "components/Explore/ExploreV2/screens/ExploreObservations"; +import UniversalSearch + from "components/Explore/ExploreV2/screens/UniversalSearch"; +import { hideHeader } from "navigation/navigationOptions"; +import type { ExploreStackParamList } from "navigation/types"; +import React from "react"; +import colors from "styles/tailwindColors"; + +const BASE_SCREEN_OPTIONS = { + headerBackButtonDisplayMode: "minimal", + headerTintColor: colors.darkGray, +} as const; + +const Stack = createNativeStackNavigator( ); + +const ExploreStackNavigator = ( ) => ( + + + + + + + +); + +export default ExploreStackNavigator; diff --git a/src/navigation/StackNavigators/TabStackNavigator.tsx b/src/navigation/StackNavigators/TabStackNavigator.tsx index cd78105ca..85e773d29 100644 --- a/src/navigation/StackNavigators/TabStackNavigator.tsx +++ b/src/navigation/StackNavigators/TabStackNavigator.tsx @@ -9,6 +9,7 @@ import Donate from "components/Donate/Donate"; import ExploreContainer from "components/Explore/ExploreContainer"; import ExploreFiltersContainer from "components/Explore/ExploreFiltersContainer"; import ExploreSearchContainer from "components/Explore/ExploreSearchContainer"; +import ExploreV2Container from "components/Explore/ExploreV2/ExploreV2Container"; import RootExploreContainer from "components/Explore/RootExploreContainer"; import Help from "components/Help/Help"; import Menu from "components/Menu/Menu"; @@ -45,6 +46,8 @@ import React from "react"; import { useLayoutPrefs, } from "sharedHooks"; +import useFeatureFlag from "sharedHooks/useFeatureFlag"; +import { FeatureFlag } from "stores/createFeatureFlagSlice"; import colors from "styles/tailwindColors"; import SharedStackScreens from "./SharedStackScreens"; @@ -172,6 +175,7 @@ const TabStackNavigator = ( { route }: BottomTabProps ) => { const { isDefaultMode, } = useLayoutPrefs( ); + const exploreV2Enabled = useFeatureFlag( FeatureFlag.ExploreV2Enabled ); return ( { /> void; + +export const initialExploreV2State: ExploreV2State = { + entityType: null, + taxon: null, + user: null, + project: null, + // Initial placeMode is NEARBY; the container resolves to coords (granted) or + // WORLDWIDE (denied/blocked) once permission state is known. + placeMode: EXPLORE_V2_PLACE_MODE.NEARBY, + lat: undefined, + lng: undefined, + radius: undefined, + place: null, + placeId: null, + placeGuess: "", + sortBy: EXPLORE_V2_SORT.DATE_UPLOADED_NEWEST, + filters: {}, +}; + +export function exploreV2Reducer( + state: ExploreV2State, + action: ExploreV2Action, +): ExploreV2State { + switch ( action.type ) { + case EXPLORE_V2_ACTION.SET_ENTITY: { + // Universal search: only one entity type is active at a time. + const cleared = { + ...state, + entityType: action.entityType, + taxon: null, + user: null, + project: null, + }; + if ( action.entityType === "taxon" ) { + return { ...cleared, taxon: action.taxon }; + } + if ( action.entityType === "user" ) { + return { ...cleared, user: action.user }; + } + return { ...cleared, project: action.project }; + } + case EXPLORE_V2_ACTION.CLEAR_ENTITY: + return { + ...state, + entityType: null, + taxon: null, + user: null, + project: null, + }; + case EXPLORE_V2_ACTION.SET_LOCATION_NEARBY: + return { + ...state, + placeMode: EXPLORE_V2_PLACE_MODE.NEARBY, + lat: action.lat, + lng: action.lng, + radius: action.radius, + place: null, + placeId: null, + placeGuess: "", + }; + case EXPLORE_V2_ACTION.SET_LOCATION_WORLDWIDE: + return { + ...state, + placeMode: EXPLORE_V2_PLACE_MODE.WORLDWIDE, + lat: undefined, + lng: undefined, + radius: undefined, + place: null, + placeId: null, + placeGuess: "", + }; + case EXPLORE_V2_ACTION.SET_LOCATION_PLACE: + return { + ...state, + placeMode: EXPLORE_V2_PLACE_MODE.PLACE, + lat: undefined, + lng: undefined, + radius: undefined, + place: action.place, + placeId: action.place.id, + placeGuess: action.placeGuess ?? action.place.display_name ?? "", + }; + case EXPLORE_V2_ACTION.SET_SORT: + return { ...state, sortBy: action.sortBy }; + case EXPLORE_V2_ACTION.SET_FILTERS: + return { ...state, filters: action.filters }; + case EXPLORE_V2_ACTION.RESET: + return initialExploreV2State; + default: { + const _exhaustive: never = action; + return _exhaustive; + } + } +} + +export interface DefaultExploreV2Location { + placeMode: EXPLORE_V2_PLACE_MODE; + lat?: number; + lng?: number; + radius?: number; +} + +export async function defaultExploreV2Location( ): Promise { + const location = await fetchCoarseUserLocation( ); + if ( !location || !location.latitude ) { + return { placeMode: EXPLORE_V2_PLACE_MODE.WORLDWIDE }; + } + return { + placeMode: EXPLORE_V2_PLACE_MODE.NEARBY, + lat: location.latitude, + lng: location.longitude, + radius: 1, + }; +} + +interface ExploreV2ContextValue { + state: ExploreV2State; + dispatch: Dispatch; +} + +const ExploreV2Context = React.createContext( + undefined, +); + +interface ExploreV2ProviderProps { + children: React.ReactNode; +} + +export const ExploreV2Provider = ( { children }: ExploreV2ProviderProps ) => { + const [state, dispatch] = React.useReducer( exploreV2Reducer, initialExploreV2State ); + + const value = React.useMemo( + () => ( { state, dispatch } ), + [state], + ); + + return ( + + {children} + + ); +}; + +export function useExploreV2( ): ExploreV2ContextValue { + const context = React.useContext( ExploreV2Context ); + if ( context === undefined ) { + throw new Error( "useExploreV2 must be used within an ExploreV2Provider" ); + } + return context; +} From c431af454f36eaaa8df8881aea437bba1ddbc859 Mon Sep 17 00:00:00 2001 From: sepeterson <10458078+sepeterson@users.noreply.github.com> Date: Thu, 30 Apr 2026 09:20:40 -0500 Subject: [PATCH 02/18] MOB-1329: clean up permission granted useEffect --- .../Explore/ExploreV2/ExploreV2Container.tsx | 63 ++++++++----------- src/providers/ExploreV2Context.tsx | 14 +++-- 2 files changed, 35 insertions(+), 42 deletions(-) diff --git a/src/components/Explore/ExploreV2/ExploreV2Container.tsx b/src/components/Explore/ExploreV2/ExploreV2Container.tsx index f9ef85d7a..cbce84119 100644 --- a/src/components/Explore/ExploreV2/ExploreV2Container.tsx +++ b/src/components/Explore/ExploreV2/ExploreV2Container.tsx @@ -7,7 +7,7 @@ import { ExploreV2Provider, useExploreV2, } from "providers/ExploreV2Context"; -import React, { useEffect, useRef } from "react"; +import React, { useEffect, useEffectEvent, useRef } from "react"; import useLocationPermission from "sharedHooks/useLocationPermission"; const ExploreV2WithProvider = ( ) => { @@ -18,42 +18,33 @@ const ExploreV2WithProvider = ( ) => { } = useLocationPermission( ); const previousHasPermissions = useRef( undefined ); - // Resolve initial location once we know permission state. NEARBY with no - // coords means we haven't fetched the user's coarse location yet. - useEffect( ( ) => { - let cancelled = false; - async function resolveLocation( ) { - if ( - hasPermissions - && !previousHasPermissions.current - && state.placeMode === EXPLORE_V2_PLACE_MODE.NEARBY - && state.lat === undefined - ) { - const next = await defaultExploreV2Location( ); - if ( cancelled ) return; - if ( - next.placeMode === EXPLORE_V2_PLACE_MODE.NEARBY - && next.lat !== undefined - && next.lng !== undefined - && next.radius !== undefined - ) { - dispatch( { - type: EXPLORE_V2_ACTION.SET_LOCATION_NEARBY, - lat: next.lat, - lng: next.lng, - radius: next.radius, - } ); - } else { - dispatch( { type: EXPLORE_V2_ACTION.SET_LOCATION_WORLDWIDE } ); - } - } - previousHasPermissions.current = hasPermissions; + const onPermissionsGained = useEffectEvent( async ( ) => { + // State not empty. No op + if ( + state.placeMode !== EXPLORE_V2_PLACE_MODE.NEARBY + || state.lat !== undefined + ) return; + + const next = await defaultExploreV2Location( ); + if ( next.placeMode === EXPLORE_V2_PLACE_MODE.NEARBY ) { + dispatch( { + type: EXPLORE_V2_ACTION.SET_LOCATION_NEARBY, + lat: next.lat, + lng: next.lng, + radius: next.radius, + } ); + } else { + dispatch( { type: EXPLORE_V2_ACTION.SET_LOCATION_WORLDWIDE } ); } - resolveLocation( ); - return ( ) => { - cancelled = true; - }; - }, [hasPermissions, state.placeMode, state.lat, dispatch] ); + } ); + + // handle granting location permissions on Explore + useEffect( ( ) => { + if ( hasPermissions && !previousHasPermissions.current ) { + onPermissionsGained( ); + } + previousHasPermissions.current = hasPermissions; + }, [hasPermissions] ); // Per the ticket: default to "Worldwide" when location is denied (or blocked). // Once permission state is known to be false, fall back from NEARBY. diff --git a/src/providers/ExploreV2Context.tsx b/src/providers/ExploreV2Context.tsx index 59872c3ef..bb614ac06 100644 --- a/src/providers/ExploreV2Context.tsx +++ b/src/providers/ExploreV2Context.tsx @@ -203,12 +203,14 @@ export function exploreV2Reducer( } } -export interface DefaultExploreV2Location { - placeMode: EXPLORE_V2_PLACE_MODE; - lat?: number; - lng?: number; - radius?: number; -} +export type DefaultExploreV2Location = + | { placeMode: EXPLORE_V2_PLACE_MODE.WORLDWIDE } + | { + placeMode: EXPLORE_V2_PLACE_MODE.NEARBY; + lat: number; + lng: number; + radius: number; + }; export async function defaultExploreV2Location( ): Promise { const location = await fetchCoarseUserLocation( ); From d977e731b1b4165c7ad390be9f430f63818c050d Mon Sep 17 00:00:00 2001 From: sepeterson <10458078+sepeterson@users.noreply.github.com> Date: Thu, 30 Apr 2026 09:25:39 -0500 Subject: [PATCH 03/18] MOB-1329: clarify comments --- src/components/Explore/ExploreV2/ExploreV2Container.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/Explore/ExploreV2/ExploreV2Container.tsx b/src/components/Explore/ExploreV2/ExploreV2Container.tsx index cbce84119..b2b260e21 100644 --- a/src/components/Explore/ExploreV2/ExploreV2Container.tsx +++ b/src/components/Explore/ExploreV2/ExploreV2Container.tsx @@ -19,7 +19,7 @@ const ExploreV2WithProvider = ( ) => { const previousHasPermissions = useRef( undefined ); const onPermissionsGained = useEffectEvent( async ( ) => { - // State not empty. No op + // State not empty. Do nothing if ( state.placeMode !== EXPLORE_V2_PLACE_MODE.NEARBY || state.lat !== undefined @@ -46,8 +46,8 @@ const ExploreV2WithProvider = ( ) => { previousHasPermissions.current = hasPermissions; }, [hasPermissions] ); - // Per the ticket: default to "Worldwide" when location is denied (or blocked). - // Once permission state is known to be false, fall back from NEARBY. + // default to "Worldwide" when location is denied + // hasPermissions === false always means permission has been denied or blocked useEffect( ( ) => { if ( hasPermissions === false From 9acf85f7438c0a0b4c67c1058f988328a81ac40c Mon Sep 17 00:00:00 2001 From: sepeterson <10458078+sepeterson@users.noreply.github.com> Date: Thu, 30 Apr 2026 09:33:27 -0500 Subject: [PATCH 04/18] MOB-1329: rename entity -> subject --- .../Explore/ExploreV2/buildQueryParams.ts | 6 +-- src/providers/ExploreV2Context.tsx | 38 +++++++++---------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/components/Explore/ExploreV2/buildQueryParams.ts b/src/components/Explore/ExploreV2/buildQueryParams.ts index 08c4f3d18..47151dbe6 100644 --- a/src/components/Explore/ExploreV2/buildQueryParams.ts +++ b/src/components/Explore/ExploreV2/buildQueryParams.ts @@ -40,11 +40,11 @@ const buildExploreV2QueryParams = ( ...sortToOrder[state.sortBy], }; - if ( state.entityType === "taxon" && state.taxon ) { + if ( state.subjectType === "taxon" && state.taxon ) { params.taxon_id = state.taxon.id; - } else if ( state.entityType === "user" && state.user ) { + } else if ( state.subjectType === "user" && state.user ) { params.user_id = state.user.id; - } else if ( state.entityType === "project" && state.project ) { + } else if ( state.subjectType === "project" && state.project ) { params.project_id = state.project.id; } diff --git a/src/providers/ExploreV2Context.tsx b/src/providers/ExploreV2Context.tsx index bb614ac06..af75080ef 100644 --- a/src/providers/ExploreV2Context.tsx +++ b/src/providers/ExploreV2Context.tsx @@ -3,8 +3,8 @@ import * as React from "react"; import fetchCoarseUserLocation from "../sharedHelpers/fetchCoarseUserLocation"; export enum EXPLORE_V2_ACTION { - SET_ENTITY = "SET_ENTITY", - CLEAR_ENTITY = "CLEAR_ENTITY", + SET_SUBJECT = "SET_SUBJECT", + CLEAR_SUBJECT = "CLEAR_SUBJECT", SET_LOCATION_NEARBY = "SET_LOCATION_NEARBY", SET_LOCATION_WORLDWIDE = "SET_LOCATION_WORLDWIDE", SET_LOCATION_PLACE = "SET_LOCATION_PLACE", @@ -30,7 +30,7 @@ export enum EXPLORE_V2_SORT { MOST_FAVED = "MOST_FAVED" } -export type ExploreV2EntityType = "taxon" | "user" | "project"; +export type ExploreV2SubjectType = "taxon" | "user" | "project"; interface Place { id: number; @@ -59,7 +59,7 @@ export interface ExploreV2Filters { } export interface ExploreV2State { - entityType: ExploreV2EntityType | null; + subjectType: ExploreV2SubjectType | null; taxon: Taxon | null; user: User | null; project: Project | null; @@ -76,21 +76,21 @@ export interface ExploreV2State { export type ExploreV2Action = | { - type: EXPLORE_V2_ACTION.SET_ENTITY; - entityType: "taxon"; + type: EXPLORE_V2_ACTION.SET_SUBJECT; + subjectType: "taxon"; taxon: Taxon; } | { - type: EXPLORE_V2_ACTION.SET_ENTITY; - entityType: "user"; + type: EXPLORE_V2_ACTION.SET_SUBJECT; + subjectType: "user"; user: User; } | { - type: EXPLORE_V2_ACTION.SET_ENTITY; - entityType: "project"; + type: EXPLORE_V2_ACTION.SET_SUBJECT; + subjectType: "project"; project: Project; } - | { type: EXPLORE_V2_ACTION.CLEAR_ENTITY } + | { type: EXPLORE_V2_ACTION.CLEAR_SUBJECT } | { type: EXPLORE_V2_ACTION.SET_LOCATION_NEARBY; lat: number; @@ -110,7 +110,7 @@ export type ExploreV2Action = type Dispatch = ( action: ExploreV2Action ) => void; export const initialExploreV2State: ExploreV2State = { - entityType: null, + subjectType: null, taxon: null, user: null, project: null, @@ -132,27 +132,27 @@ export function exploreV2Reducer( action: ExploreV2Action, ): ExploreV2State { switch ( action.type ) { - case EXPLORE_V2_ACTION.SET_ENTITY: { - // Universal search: only one entity type is active at a time. + case EXPLORE_V2_ACTION.SET_SUBJECT: { + // Universal search: only one subject type is active at a time. const cleared = { ...state, - entityType: action.entityType, + subjectType: action.subjectType, taxon: null, user: null, project: null, }; - if ( action.entityType === "taxon" ) { + if ( action.subjectType === "taxon" ) { return { ...cleared, taxon: action.taxon }; } - if ( action.entityType === "user" ) { + if ( action.subjectType === "user" ) { return { ...cleared, user: action.user }; } return { ...cleared, project: action.project }; } - case EXPLORE_V2_ACTION.CLEAR_ENTITY: + case EXPLORE_V2_ACTION.CLEAR_SUBJECT: return { ...state, - entityType: null, + subjectType: null, taxon: null, user: null, project: null, From 23efd85ddcf789a0d15d9f5b484b980696221271 Mon Sep 17 00:00:00 2001 From: sepeterson <10458078+sepeterson@users.noreply.github.com> Date: Thu, 30 Apr 2026 09:56:29 -0500 Subject: [PATCH 05/18] MOB-1329: context cleanup --- .../Explore/ExploreV2/buildQueryParams.ts | 4 +-- src/providers/ExploreV2Context.tsx | 33 +++++-------------- 2 files changed, 11 insertions(+), 26 deletions(-) diff --git a/src/components/Explore/ExploreV2/buildQueryParams.ts b/src/components/Explore/ExploreV2/buildQueryParams.ts index 47151dbe6..84fc3613f 100644 --- a/src/components/Explore/ExploreV2/buildQueryParams.ts +++ b/src/components/Explore/ExploreV2/buildQueryParams.ts @@ -58,9 +58,9 @@ const buildExploreV2QueryParams = ( params.radius = state.radius; } else if ( state.placeMode === EXPLORE_V2_PLACE_MODE.PLACE - && state.placeId !== null + && state.place ) { - params.place_id = state.placeId; + params.place_id = state.place.id; } return params; diff --git a/src/providers/ExploreV2Context.tsx b/src/providers/ExploreV2Context.tsx index af75080ef..bfe4cfad5 100644 --- a/src/providers/ExploreV2Context.tsx +++ b/src/providers/ExploreV2Context.tsx @@ -19,9 +19,7 @@ export enum EXPLORE_V2_PLACE_MODE { PLACE = "PLACE" } -// Sort options. The full Observations sort enum (Taxonomy, Alphabetical, etc.) -// is owned by MOB-1333; for now we cover the four date-sort cases plus most-faved -// which is what searchObservations supports today. +// more options to be potentially added in MOB-1333 export enum EXPLORE_V2_SORT { DATE_UPLOADED_NEWEST = "DATE_UPLOADED_NEWEST", DATE_UPLOADED_OLDEST = "DATE_UPLOADED_OLDEST", @@ -39,27 +37,28 @@ interface Place { interface Taxon { id: number; - name?: string; + name: string; } interface User { id: number; - login?: string; + login: string; } interface Project { id: number; - title?: string; + title: string; } -// Filter scaffold. MOB-1346 (Advanced Search) populates this. Kept open-ended -// so sibling tickets can extend without a context migration. +// To be added to in MOB-1346 export interface ExploreV2Filters { [key: string]: unknown; } export interface ExploreV2State { subjectType: ExploreV2SubjectType | null; + // In theory I prefer separate properties for taxon, user, project so it's very clear + // which we're dealing with in later logic but I think this could change to a single property taxon: Taxon | null; user: User | null; project: Project | null; @@ -68,8 +67,6 @@ export interface ExploreV2State { lng?: number; radius?: number; place: Place | null; - placeId: number | null; - placeGuess: string; sortBy: EXPLORE_V2_SORT; filters: ExploreV2Filters; } @@ -101,28 +98,21 @@ export type ExploreV2Action = | { type: EXPLORE_V2_ACTION.SET_LOCATION_PLACE; place: Place; - placeGuess?: string; } | { type: EXPLORE_V2_ACTION.SET_SORT; sortBy: EXPLORE_V2_SORT } | { type: EXPLORE_V2_ACTION.SET_FILTERS; filters: ExploreV2Filters } | { type: EXPLORE_V2_ACTION.RESET }; -type Dispatch = ( action: ExploreV2Action ) => void; - export const initialExploreV2State: ExploreV2State = { subjectType: null, taxon: null, user: null, project: null, - // Initial placeMode is NEARBY; the container resolves to coords (granted) or - // WORLDWIDE (denied/blocked) once permission state is known. placeMode: EXPLORE_V2_PLACE_MODE.NEARBY, lat: undefined, lng: undefined, radius: undefined, place: null, - placeId: null, - placeGuess: "", sortBy: EXPLORE_V2_SORT.DATE_UPLOADED_NEWEST, filters: {}, }; @@ -165,8 +155,6 @@ export function exploreV2Reducer( lng: action.lng, radius: action.radius, place: null, - placeId: null, - placeGuess: "", }; case EXPLORE_V2_ACTION.SET_LOCATION_WORLDWIDE: return { @@ -176,8 +164,6 @@ export function exploreV2Reducer( lng: undefined, radius: undefined, place: null, - placeId: null, - placeGuess: "", }; case EXPLORE_V2_ACTION.SET_LOCATION_PLACE: return { @@ -187,8 +173,6 @@ export function exploreV2Reducer( lng: undefined, radius: undefined, place: action.place, - placeId: action.place.id, - placeGuess: action.placeGuess ?? action.place.display_name ?? "", }; case EXPLORE_V2_ACTION.SET_SORT: return { ...state, sortBy: action.sortBy }; @@ -227,7 +211,7 @@ export async function defaultExploreV2Location( ): Promise void; } const ExploreV2Context = React.createContext( @@ -255,6 +239,7 @@ export const ExploreV2Provider = ( { children }: ExploreV2ProviderProps ) => { export function useExploreV2( ): ExploreV2ContextValue { const context = React.useContext( ExploreV2Context ); + // Pattern from https://kentcdodds.com/blog/how-to-use-react-context-effectively if ( context === undefined ) { throw new Error( "useExploreV2 must be used within an ExploreV2Provider" ); } From 96a5db6eed8ee014c833297bfaf9a40db58cda67 Mon Sep 17 00:00:00 2001 From: sepeterson <10458078+sepeterson@users.noreply.github.com> Date: Thu, 30 Apr 2026 10:01:30 -0500 Subject: [PATCH 06/18] MOB-1329: single explore subject property --- .../Explore/ExploreV2/buildQueryParams.ts | 18 ++++-- src/providers/ExploreV2Context.tsx | 62 ++++--------------- 2 files changed, 23 insertions(+), 57 deletions(-) diff --git a/src/components/Explore/ExploreV2/buildQueryParams.ts b/src/components/Explore/ExploreV2/buildQueryParams.ts index 84fc3613f..edf27078c 100644 --- a/src/components/Explore/ExploreV2/buildQueryParams.ts +++ b/src/components/Explore/ExploreV2/buildQueryParams.ts @@ -40,12 +40,18 @@ const buildExploreV2QueryParams = ( ...sortToOrder[state.sortBy], }; - if ( state.subjectType === "taxon" && state.taxon ) { - params.taxon_id = state.taxon.id; - } else if ( state.subjectType === "user" && state.user ) { - params.user_id = state.user.id; - } else if ( state.subjectType === "project" && state.project ) { - params.project_id = state.project.id; + switch ( state.subject?.type ) { + case "taxon": + params.taxon_id = state.subject.taxon.id; + break; + case "user": + params.user_id = state.subject.user.id; + break; + case "project": + params.project_id = state.subject.project.id; + break; + default: + break; } if ( diff --git a/src/providers/ExploreV2Context.tsx b/src/providers/ExploreV2Context.tsx index bfe4cfad5..71e6e2bc0 100644 --- a/src/providers/ExploreV2Context.tsx +++ b/src/providers/ExploreV2Context.tsx @@ -28,8 +28,6 @@ export enum EXPLORE_V2_SORT { MOST_FAVED = "MOST_FAVED" } -export type ExploreV2SubjectType = "taxon" | "user" | "project"; - interface Place { id: number; display_name?: string; @@ -50,18 +48,18 @@ interface Project { title: string; } +export type ExploreV2Subject = + | { type: "taxon"; taxon: Taxon } + | { type: "user"; user: User } + | { type: "project"; project: Project }; + // To be added to in MOB-1346 export interface ExploreV2Filters { [key: string]: unknown; } export interface ExploreV2State { - subjectType: ExploreV2SubjectType | null; - // In theory I prefer separate properties for taxon, user, project so it's very clear - // which we're dealing with in later logic but I think this could change to a single property - taxon: Taxon | null; - user: User | null; - project: Project | null; + subject: ExploreV2Subject | null; placeMode: EXPLORE_V2_PLACE_MODE; lat?: number; lng?: number; @@ -72,21 +70,7 @@ export interface ExploreV2State { } export type ExploreV2Action = - | { - type: EXPLORE_V2_ACTION.SET_SUBJECT; - subjectType: "taxon"; - taxon: Taxon; - } - | { - type: EXPLORE_V2_ACTION.SET_SUBJECT; - subjectType: "user"; - user: User; - } - | { - type: EXPLORE_V2_ACTION.SET_SUBJECT; - subjectType: "project"; - project: Project; - } + | { type: EXPLORE_V2_ACTION.SET_SUBJECT; subject: ExploreV2Subject } | { type: EXPLORE_V2_ACTION.CLEAR_SUBJECT } | { type: EXPLORE_V2_ACTION.SET_LOCATION_NEARBY; @@ -104,10 +88,7 @@ export type ExploreV2Action = | { type: EXPLORE_V2_ACTION.RESET }; export const initialExploreV2State: ExploreV2State = { - subjectType: null, - taxon: null, - user: null, - project: null, + subject: null, placeMode: EXPLORE_V2_PLACE_MODE.NEARBY, lat: undefined, lng: undefined, @@ -122,31 +103,10 @@ export function exploreV2Reducer( action: ExploreV2Action, ): ExploreV2State { switch ( action.type ) { - case EXPLORE_V2_ACTION.SET_SUBJECT: { - // Universal search: only one subject type is active at a time. - const cleared = { - ...state, - subjectType: action.subjectType, - taxon: null, - user: null, - project: null, - }; - if ( action.subjectType === "taxon" ) { - return { ...cleared, taxon: action.taxon }; - } - if ( action.subjectType === "user" ) { - return { ...cleared, user: action.user }; - } - return { ...cleared, project: action.project }; - } + case EXPLORE_V2_ACTION.SET_SUBJECT: + return { ...state, subject: action.subject }; case EXPLORE_V2_ACTION.CLEAR_SUBJECT: - return { - ...state, - subjectType: null, - taxon: null, - user: null, - project: null, - }; + return { ...state, subject: null }; case EXPLORE_V2_ACTION.SET_LOCATION_NEARBY: return { ...state, From 951a3e5ce0ddfbb19069830b951de9ff0e2578b2 Mon Sep 17 00:00:00 2001 From: sepeterson <10458078+sepeterson@users.noreply.github.com> Date: Thu, 30 Apr 2026 10:07:33 -0500 Subject: [PATCH 07/18] MOB-1329: query params comment --- src/components/Explore/ExploreV2/buildQueryParams.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/Explore/ExploreV2/buildQueryParams.ts b/src/components/Explore/ExploreV2/buildQueryParams.ts index edf27078c..6e5440297 100644 --- a/src/components/Explore/ExploreV2/buildQueryParams.ts +++ b/src/components/Explore/ExploreV2/buildQueryParams.ts @@ -40,6 +40,7 @@ const buildExploreV2QueryParams = ( ...sortToOrder[state.sortBy], }; + // this might warrant moving into a selector function at some point switch ( state.subject?.type ) { case "taxon": params.taxon_id = state.subject.taxon.id; From 8eeb2566f2c23582dafb550e5312fbdd619c010c Mon Sep 17 00:00:00 2001 From: sepeterson <10458078+sepeterson@users.noreply.github.com> Date: Thu, 30 Apr 2026 10:33:22 -0500 Subject: [PATCH 08/18] MOB-1329: explicitly uninitialized place mode --- .../Explore/ExploreV2/ExploreV2Container.tsx | 8 +- .../Explore/ExploreV2/buildQueryParams.ts | 29 +++---- .../ExploreV2/screens/ExploreObservations.tsx | 76 +++++++------------ src/providers/ExploreV2Context.tsx | 49 ++++++------ 4 files changed, 71 insertions(+), 91 deletions(-) diff --git a/src/components/Explore/ExploreV2/ExploreV2Container.tsx b/src/components/Explore/ExploreV2/ExploreV2Container.tsx index b2b260e21..9cae5b38b 100644 --- a/src/components/Explore/ExploreV2/ExploreV2Container.tsx +++ b/src/components/Explore/ExploreV2/ExploreV2Container.tsx @@ -19,11 +19,7 @@ const ExploreV2WithProvider = ( ) => { const previousHasPermissions = useRef( undefined ); const onPermissionsGained = useEffectEvent( async ( ) => { - // State not empty. Do nothing - if ( - state.placeMode !== EXPLORE_V2_PLACE_MODE.NEARBY - || state.lat !== undefined - ) return; + if ( state.placeMode !== EXPLORE_V2_PLACE_MODE.UNINITIALIZED ) return; const next = await defaultExploreV2Location( ); if ( next.placeMode === EXPLORE_V2_PLACE_MODE.NEARBY ) { @@ -51,7 +47,7 @@ const ExploreV2WithProvider = ( ) => { useEffect( ( ) => { if ( hasPermissions === false - && state.placeMode === EXPLORE_V2_PLACE_MODE.NEARBY + && state.placeMode === EXPLORE_V2_PLACE_MODE.UNINITIALIZED ) { dispatch( { type: EXPLORE_V2_ACTION.SET_LOCATION_WORLDWIDE } ); } diff --git a/src/components/Explore/ExploreV2/buildQueryParams.ts b/src/components/Explore/ExploreV2/buildQueryParams.ts index 6e5440297..3e52b3d97 100644 --- a/src/components/Explore/ExploreV2/buildQueryParams.ts +++ b/src/components/Explore/ExploreV2/buildQueryParams.ts @@ -55,19 +55,22 @@ const buildExploreV2QueryParams = ( break; } - if ( - state.placeMode === EXPLORE_V2_PLACE_MODE.NEARBY - && state.lat !== undefined - && state.lng !== undefined - ) { - params.lat = state.lat; - params.lng = state.lng; - params.radius = state.radius; - } else if ( - state.placeMode === EXPLORE_V2_PLACE_MODE.PLACE - && state.place - ) { - params.place_id = state.place.id; + switch ( state.placeMode ) { + case EXPLORE_V2_PLACE_MODE.NEARBY: + params.lat = state.lat; + params.lng = state.lng; + params.radius = state.radius; + break; + case EXPLORE_V2_PLACE_MODE.PLACE: + params.place_id = state.place.id; + break; + case EXPLORE_V2_PLACE_MODE.WORLDWIDE: + case EXPLORE_V2_PLACE_MODE.UNINITIALIZED: + break; + default: { + const _exhaustive: never = state; + return _exhaustive; + } } return params; diff --git a/src/components/Explore/ExploreV2/screens/ExploreObservations.tsx b/src/components/Explore/ExploreV2/screens/ExploreObservations.tsx index d3913a9ea..d8bfd1db7 100644 --- a/src/components/Explore/ExploreV2/screens/ExploreObservations.tsx +++ b/src/components/Explore/ExploreV2/screens/ExploreObservations.tsx @@ -15,13 +15,6 @@ import { Pressable, View } from "components/styledComponents"; import { EXPLORE_V2_PLACE_MODE, useExploreV2 } from "providers/ExploreV2Context"; import React, { useMemo } from "react"; import { Alert } from "react-native"; -import useDebugMode from "sharedHooks/useDebugMode"; -import { getShadow } from "styles/global"; - -const DROP_SHADOW = getShadow( { - offsetHeight: 4, - elevation: 6, -} ); const OBS_LIST_CONTAINER_STYLE = { paddingTop: 50 }; @@ -29,21 +22,10 @@ const ExploreObservations = ( ) => { const navigation = useNavigation( ); const { state } = useExploreV2( ); const { isConnected } = useNetInfo( ); - const { isDebug } = useDebugMode( ); const queryParams = useMemo( () => buildExploreV2QueryParams( state ), [state] ); - // Don't fetch until placeMode has resolved to something usable: worldwide - // (no coords needed), nearby with coords, or a specific place. - const canFetch = state.placeMode === EXPLORE_V2_PLACE_MODE.WORLDWIDE - || ( - state.placeMode === EXPLORE_V2_PLACE_MODE.NEARBY - && queryParams.lat !== undefined - ) - || ( - state.placeMode === EXPLORE_V2_PLACE_MODE.PLACE - && queryParams.place_id !== undefined - ); + const canFetch = state.placeMode !== EXPLORE_V2_PLACE_MODE.UNINITIALIZED; const { fetchNextPage, @@ -78,37 +60,31 @@ const ExploreObservations = ( ) => { showNoResults={!canFetch || totalResults === 0} testID="ExploreV2ObservationsList" /> - {isDebug && ( - { - Alert.alert( - "ExploreV2 Info", - `state: ${JSON.stringify( state, null, 2 )}\n\nqueryParams: ${ - JSON.stringify( queryParams, null, 2 ) - }`, - ); - }} - /> - )} + { + Alert.alert( + "ExploreV2 Info", + `state: ${JSON.stringify( state, null, 2 )}\n\nqueryParams: ${ + JSON.stringify( queryParams, null, 2 ) + }`, + ); + }} + /> + ); diff --git a/src/providers/ExploreV2Context.tsx b/src/providers/ExploreV2Context.tsx index 71e6e2bc0..8083923f0 100644 --- a/src/providers/ExploreV2Context.tsx +++ b/src/providers/ExploreV2Context.tsx @@ -14,6 +14,7 @@ export enum EXPLORE_V2_ACTION { } export enum EXPLORE_V2_PLACE_MODE { + UNINITIALIZED = "UNINITIALIZED", NEARBY = "NEARBY", WORLDWIDE = "WORLDWIDE", PLACE = "PLACE" @@ -58,17 +59,25 @@ export interface ExploreV2Filters { [key: string]: unknown; } -export interface ExploreV2State { +interface ExploreV2BaseState { subject: ExploreV2Subject | null; - placeMode: EXPLORE_V2_PLACE_MODE; - lat?: number; - lng?: number; - radius?: number; - place: Place | null; sortBy: EXPLORE_V2_SORT; filters: ExploreV2Filters; } +export type ExploreV2LocationState = + | { placeMode: EXPLORE_V2_PLACE_MODE.UNINITIALIZED } + | { placeMode: EXPLORE_V2_PLACE_MODE.WORLDWIDE } + | { + placeMode: EXPLORE_V2_PLACE_MODE.NEARBY; + lat: number; + lng: number; + radius: number; + } + | { placeMode: EXPLORE_V2_PLACE_MODE.PLACE; place: Place }; + +export type ExploreV2State = ExploreV2BaseState & ExploreV2LocationState; + export type ExploreV2Action = | { type: EXPLORE_V2_ACTION.SET_SUBJECT; subject: ExploreV2Subject } | { type: EXPLORE_V2_ACTION.CLEAR_SUBJECT } @@ -89,15 +98,19 @@ export type ExploreV2Action = export const initialExploreV2State: ExploreV2State = { subject: null, - placeMode: EXPLORE_V2_PLACE_MODE.NEARBY, - lat: undefined, - lng: undefined, - radius: undefined, - place: null, + placeMode: EXPLORE_V2_PLACE_MODE.UNINITIALIZED, sortBy: EXPLORE_V2_SORT.DATE_UPLOADED_NEWEST, filters: {}, }; +function baseFields( state: ExploreV2State ): ExploreV2BaseState { + return { + subject: state.subject, + sortBy: state.sortBy, + filters: state.filters, + }; +} + export function exploreV2Reducer( state: ExploreV2State, action: ExploreV2Action, @@ -109,29 +122,21 @@ export function exploreV2Reducer( return { ...state, subject: null }; case EXPLORE_V2_ACTION.SET_LOCATION_NEARBY: return { - ...state, + ...baseFields( state ), placeMode: EXPLORE_V2_PLACE_MODE.NEARBY, lat: action.lat, lng: action.lng, radius: action.radius, - place: null, }; case EXPLORE_V2_ACTION.SET_LOCATION_WORLDWIDE: return { - ...state, + ...baseFields( state ), placeMode: EXPLORE_V2_PLACE_MODE.WORLDWIDE, - lat: undefined, - lng: undefined, - radius: undefined, - place: null, }; case EXPLORE_V2_ACTION.SET_LOCATION_PLACE: return { - ...state, + ...baseFields( state ), placeMode: EXPLORE_V2_PLACE_MODE.PLACE, - lat: undefined, - lng: undefined, - radius: undefined, place: action.place, }; case EXPLORE_V2_ACTION.SET_SORT: From 530c45d2adef1805b422a41329765034d04cd4d1 Mon Sep 17 00:00:00 2001 From: sepeterson <10458078+sepeterson@users.noreply.github.com> Date: Thu, 30 Apr 2026 10:37:03 -0500 Subject: [PATCH 09/18] MOB-1329: combine container useEffects --- .../Explore/ExploreV2/ExploreV2Container.tsx | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/src/components/Explore/ExploreV2/ExploreV2Container.tsx b/src/components/Explore/ExploreV2/ExploreV2Container.tsx index 9cae5b38b..2cc1e12a5 100644 --- a/src/components/Explore/ExploreV2/ExploreV2Container.tsx +++ b/src/components/Explore/ExploreV2/ExploreV2Container.tsx @@ -34,25 +34,24 @@ const ExploreV2WithProvider = ( ) => { } } ); - // handle granting location permissions on Explore + // default to "Worldwide" when location is denied + // hasPermissions === false always means permission has been denied or blocked + const onPermissionsDenied = useEffectEvent( ( ) => { + if ( state.placeMode === EXPLORE_V2_PLACE_MODE.UNINITIALIZED ) { + dispatch( { type: EXPLORE_V2_ACTION.SET_LOCATION_WORLDWIDE } ); + } + } ); + + // handle location permission changes on Explore useEffect( ( ) => { - if ( hasPermissions && !previousHasPermissions.current ) { + if ( hasPermissions === true && previousHasPermissions.current !== true ) { onPermissionsGained( ); + } else if ( hasPermissions === false && previousHasPermissions.current !== false ) { + onPermissionsDenied( ); } previousHasPermissions.current = hasPermissions; }, [hasPermissions] ); - // default to "Worldwide" when location is denied - // hasPermissions === false always means permission has been denied or blocked - useEffect( ( ) => { - if ( - hasPermissions === false - && state.placeMode === EXPLORE_V2_PLACE_MODE.UNINITIALIZED - ) { - dispatch( { type: EXPLORE_V2_ACTION.SET_LOCATION_WORLDWIDE } ); - } - }, [hasPermissions, state.placeMode, dispatch] ); - return ( <> From fde15efdcce1cac067ab7917eb5c10862b447403 Mon Sep 17 00:00:00 2001 From: sepeterson <10458078+sepeterson@users.noreply.github.com> Date: Thu, 30 Apr 2026 10:47:33 -0500 Subject: [PATCH 10/18] MOB-1329 dedicated location property in explore v2 context --- .../Explore/ExploreV2/ExploreV2Container.tsx | 4 +- .../Explore/ExploreV2/buildQueryParams.ts | 14 +++--- .../ExploreV2/screens/ExploreObservations.tsx | 2 +- src/providers/ExploreV2Context.tsx | 47 +++++++++---------- 4 files changed, 32 insertions(+), 35 deletions(-) diff --git a/src/components/Explore/ExploreV2/ExploreV2Container.tsx b/src/components/Explore/ExploreV2/ExploreV2Container.tsx index 2cc1e12a5..5195ee64a 100644 --- a/src/components/Explore/ExploreV2/ExploreV2Container.tsx +++ b/src/components/Explore/ExploreV2/ExploreV2Container.tsx @@ -19,7 +19,7 @@ const ExploreV2WithProvider = ( ) => { const previousHasPermissions = useRef( undefined ); const onPermissionsGained = useEffectEvent( async ( ) => { - if ( state.placeMode !== EXPLORE_V2_PLACE_MODE.UNINITIALIZED ) return; + if ( state.location.placeMode !== EXPLORE_V2_PLACE_MODE.UNINITIALIZED ) return; const next = await defaultExploreV2Location( ); if ( next.placeMode === EXPLORE_V2_PLACE_MODE.NEARBY ) { @@ -37,7 +37,7 @@ const ExploreV2WithProvider = ( ) => { // default to "Worldwide" when location is denied // hasPermissions === false always means permission has been denied or blocked const onPermissionsDenied = useEffectEvent( ( ) => { - if ( state.placeMode === EXPLORE_V2_PLACE_MODE.UNINITIALIZED ) { + if ( state.location.placeMode === EXPLORE_V2_PLACE_MODE.UNINITIALIZED ) { dispatch( { type: EXPLORE_V2_ACTION.SET_LOCATION_WORLDWIDE } ); } } ); diff --git a/src/components/Explore/ExploreV2/buildQueryParams.ts b/src/components/Explore/ExploreV2/buildQueryParams.ts index 3e52b3d97..9ad59b504 100644 --- a/src/components/Explore/ExploreV2/buildQueryParams.ts +++ b/src/components/Explore/ExploreV2/buildQueryParams.ts @@ -55,20 +55,22 @@ const buildExploreV2QueryParams = ( break; } - switch ( state.placeMode ) { + const { location } = state; + switch ( location.placeMode ) { case EXPLORE_V2_PLACE_MODE.NEARBY: - params.lat = state.lat; - params.lng = state.lng; - params.radius = state.radius; + params.lat = location.lat; + params.lng = location.lng; + params.radius = location.radius; break; case EXPLORE_V2_PLACE_MODE.PLACE: - params.place_id = state.place.id; + params.place_id = location.place.id; break; case EXPLORE_V2_PLACE_MODE.WORLDWIDE: case EXPLORE_V2_PLACE_MODE.UNINITIALIZED: break; default: { - const _exhaustive: never = state; + // Exhaustiveness check: ts fails if a new placeMode is added without a case. + const _exhaustive: never = location; return _exhaustive; } } diff --git a/src/components/Explore/ExploreV2/screens/ExploreObservations.tsx b/src/components/Explore/ExploreV2/screens/ExploreObservations.tsx index d8bfd1db7..4828ef7e7 100644 --- a/src/components/Explore/ExploreV2/screens/ExploreObservations.tsx +++ b/src/components/Explore/ExploreV2/screens/ExploreObservations.tsx @@ -25,7 +25,7 @@ const ExploreObservations = ( ) => { const queryParams = useMemo( () => buildExploreV2QueryParams( state ), [state] ); - const canFetch = state.placeMode !== EXPLORE_V2_PLACE_MODE.UNINITIALIZED; + const canFetch = state.location.placeMode !== EXPLORE_V2_PLACE_MODE.UNINITIALIZED; const { fetchNextPage, diff --git a/src/providers/ExploreV2Context.tsx b/src/providers/ExploreV2Context.tsx index 8083923f0..12eef7707 100644 --- a/src/providers/ExploreV2Context.tsx +++ b/src/providers/ExploreV2Context.tsx @@ -59,12 +59,6 @@ export interface ExploreV2Filters { [key: string]: unknown; } -interface ExploreV2BaseState { - subject: ExploreV2Subject | null; - sortBy: EXPLORE_V2_SORT; - filters: ExploreV2Filters; -} - export type ExploreV2LocationState = | { placeMode: EXPLORE_V2_PLACE_MODE.UNINITIALIZED } | { placeMode: EXPLORE_V2_PLACE_MODE.WORLDWIDE } @@ -76,7 +70,12 @@ export type ExploreV2LocationState = } | { placeMode: EXPLORE_V2_PLACE_MODE.PLACE; place: Place }; -export type ExploreV2State = ExploreV2BaseState & ExploreV2LocationState; +export interface ExploreV2State { + subject: ExploreV2Subject | null; + location: ExploreV2LocationState; + sortBy: EXPLORE_V2_SORT; + filters: ExploreV2Filters; +} export type ExploreV2Action = | { type: EXPLORE_V2_ACTION.SET_SUBJECT; subject: ExploreV2Subject } @@ -98,19 +97,11 @@ export type ExploreV2Action = export const initialExploreV2State: ExploreV2State = { subject: null, - placeMode: EXPLORE_V2_PLACE_MODE.UNINITIALIZED, + location: { placeMode: EXPLORE_V2_PLACE_MODE.UNINITIALIZED }, sortBy: EXPLORE_V2_SORT.DATE_UPLOADED_NEWEST, filters: {}, }; -function baseFields( state: ExploreV2State ): ExploreV2BaseState { - return { - subject: state.subject, - sortBy: state.sortBy, - filters: state.filters, - }; -} - export function exploreV2Reducer( state: ExploreV2State, action: ExploreV2Action, @@ -122,22 +113,26 @@ export function exploreV2Reducer( return { ...state, subject: null }; case EXPLORE_V2_ACTION.SET_LOCATION_NEARBY: return { - ...baseFields( state ), - placeMode: EXPLORE_V2_PLACE_MODE.NEARBY, - lat: action.lat, - lng: action.lng, - radius: action.radius, + ...state, + location: { + placeMode: EXPLORE_V2_PLACE_MODE.NEARBY, + lat: action.lat, + lng: action.lng, + radius: action.radius, + }, }; case EXPLORE_V2_ACTION.SET_LOCATION_WORLDWIDE: return { - ...baseFields( state ), - placeMode: EXPLORE_V2_PLACE_MODE.WORLDWIDE, + ...state, + location: { placeMode: EXPLORE_V2_PLACE_MODE.WORLDWIDE }, }; case EXPLORE_V2_ACTION.SET_LOCATION_PLACE: return { - ...baseFields( state ), - placeMode: EXPLORE_V2_PLACE_MODE.PLACE, - place: action.place, + ...state, + location: { + placeMode: EXPLORE_V2_PLACE_MODE.PLACE, + place: action.place, + }, }; case EXPLORE_V2_ACTION.SET_SORT: return { ...state, sortBy: action.sortBy }; From 9b839352dbffa5d58d31b1cdecb4cbc63e453d56 Mon Sep 17 00:00:00 2001 From: sepeterson <10458078+sepeterson@users.noreply.github.com> Date: Fri, 1 May 2026 08:28:20 -0500 Subject: [PATCH 11/18] MOB-1329: explore obs cleanup --- .../ExploreV2/screens/ExploreObservations.tsx | 41 ++----------------- 1 file changed, 3 insertions(+), 38 deletions(-) diff --git a/src/components/Explore/ExploreV2/screens/ExploreObservations.tsx b/src/components/Explore/ExploreV2/screens/ExploreObservations.tsx index 4828ef7e7..732b2f79d 100644 --- a/src/components/Explore/ExploreV2/screens/ExploreObservations.tsx +++ b/src/components/Explore/ExploreV2/screens/ExploreObservations.tsx @@ -1,6 +1,4 @@ import { useNetInfo } from "@react-native-community/netinfo"; -import { useNavigation } from "@react-navigation/native"; -import classnames from "classnames"; import buildExploreV2QueryParams from "components/Explore/ExploreV2/buildQueryParams"; import useInfiniteExploreScroll @@ -8,18 +6,15 @@ import useInfiniteExploreScroll import ObservationsFlashList from "components/ObservationsFlashList/ObservationsFlashList"; import { Body2, - INatIconButton, ViewWrapper, } from "components/SharedComponents"; -import { Pressable, View } from "components/styledComponents"; +import { View } from "components/styledComponents"; import { EXPLORE_V2_PLACE_MODE, useExploreV2 } from "providers/ExploreV2Context"; import React, { useMemo } from "react"; -import { Alert } from "react-native"; const OBS_LIST_CONTAINER_STYLE = { paddingTop: 50 }; const ExploreObservations = ( ) => { - const navigation = useNavigation( ); const { state } = useExploreV2( ); const { isConnected } = useNetInfo( ); @@ -38,13 +33,8 @@ const ExploreObservations = ( ) => { return ( - navigation.navigate( "UniversalSearch" )} - > - {/* eslint-disable-next-line i18next/no-literal-string */} - TODO: Header — MOB-1327 (tap to open Universal Search) - + {/* eslint-disable-next-line i18next/no-literal-string */} + TODO: Header — MOB-1327 (tap to open Universal Search) { showNoResults={!canFetch || totalResults === 0} testID="ExploreV2ObservationsList" /> - { - Alert.alert( - "ExploreV2 Info", - `state: ${JSON.stringify( state, null, 2 )}\n\nqueryParams: ${ - JSON.stringify( queryParams, null, 2 ) - }`, - ); - }} - /> - ); From 289bc228c0637b82aa3f2e7e51d51c0964cbc381 Mon Sep 17 00:00:00 2001 From: sepeterson <10458078+sepeterson@users.noreply.github.com> Date: Fri, 1 May 2026 10:16:34 -0500 Subject: [PATCH 12/18] MOB-1329: tests --- .../ExploreV2/buildQueryParams.test.js | 167 +++++++++++++ tests/unit/providers/ExploreV2Context.test.js | 226 ++++++++++++++++++ 2 files changed, 393 insertions(+) create mode 100644 tests/unit/components/Explore/ExploreV2/buildQueryParams.test.js create mode 100644 tests/unit/providers/ExploreV2Context.test.js diff --git a/tests/unit/components/Explore/ExploreV2/buildQueryParams.test.js b/tests/unit/components/Explore/ExploreV2/buildQueryParams.test.js new file mode 100644 index 000000000..41093797b --- /dev/null +++ b/tests/unit/components/Explore/ExploreV2/buildQueryParams.test.js @@ -0,0 +1,167 @@ +import buildExploreV2QueryParams + from "components/Explore/ExploreV2/buildQueryParams"; +import { + EXPLORE_V2_PLACE_MODE, + EXPLORE_V2_SORT, + initialExploreV2State, +} from "providers/ExploreV2Context"; + +describe( "buildExploreV2QueryParams", ( ) => { + describe( "subject filter", ( ) => { + it( "maps a selected taxon to taxon_id", ( ) => { + const state = { + ...initialExploreV2State, + subject: { type: "taxon", taxon: { id: 42 } }, + }; + const params = buildExploreV2QueryParams( state ); + expect( params.taxon_id ).toBe( 42 ); + expect( params.user_id ).toBeUndefined( ); + expect( params.project_id ).toBeUndefined( ); + } ); + + it( "maps a selected user to user_id", ( ) => { + const state = { + ...initialExploreV2State, + subject: { type: "user", user: { id: 7 } }, + }; + const params = buildExploreV2QueryParams( state ); + expect( params.user_id ).toBe( 7 ); + expect( params.taxon_id ).toBeUndefined( ); + } ); + + it( "maps a selected project to project_id", ( ) => { + const state = { + ...initialExploreV2State, + subject: { type: "project", project: { id: 12 } }, + }; + const params = buildExploreV2QueryParams( state ); + expect( params.project_id ).toBe( 12 ); + } ); + } ); + + describe( "location", ( ) => { + it( "includes lat/lng/radius in NEARBY mode with coords", ( ) => { + const state = { + ...initialExploreV2State, + location: { + placeMode: EXPLORE_V2_PLACE_MODE.NEARBY, + lat: 37.5, + lng: -122.1, + radius: 1, + }, + }; + const params = buildExploreV2QueryParams( state ); + expect( params.lat ).toBe( 37.5 ); + expect( params.lng ).toBe( -122.1 ); + expect( params.radius ).toBe( 1 ); + expect( params.place_id ).toBeUndefined( ); + } ); + + it( "omits coords and place in WORLDWIDE mode", ( ) => { + const state = { + ...initialExploreV2State, + location: { placeMode: EXPLORE_V2_PLACE_MODE.WORLDWIDE }, + }; + const params = buildExploreV2QueryParams( state ); + expect( params.lat ).toBeUndefined( ); + expect( params.place_id ).toBeUndefined( ); + } ); + + it( "uses place_id in PLACE mode", ( ) => { + const state = { + ...initialExploreV2State, + location: { + placeMode: EXPLORE_V2_PLACE_MODE.PLACE, + place: { id: 5 }, + }, + }; + const params = buildExploreV2QueryParams( state ); + expect( params.place_id ).toBe( 5 ); + expect( params.lat ).toBeUndefined( ); + } ); + } ); + + describe( "sort", ( ) => { + it( "DATE_UPLOADED_NEWEST → created_at desc", ( ) => { + const params = buildExploreV2QueryParams( { + ...initialExploreV2State, + sortBy: EXPLORE_V2_SORT.DATE_UPLOADED_NEWEST, + } ); + expect( params.order_by ).toBe( "created_at" ); + expect( params.order ).toBe( "desc" ); + } ); + + it( "DATE_UPLOADED_OLDEST → created_at asc", ( ) => { + const params = buildExploreV2QueryParams( { + ...initialExploreV2State, + sortBy: EXPLORE_V2_SORT.DATE_UPLOADED_OLDEST, + } ); + expect( params.order_by ).toBe( "created_at" ); + expect( params.order ).toBe( "asc" ); + } ); + + it( "DATE_OBSERVED_NEWEST → observed_on desc", ( ) => { + const params = buildExploreV2QueryParams( { + ...initialExploreV2State, + sortBy: EXPLORE_V2_SORT.DATE_OBSERVED_NEWEST, + } ); + expect( params.order_by ).toBe( "observed_on" ); + expect( params.order ).toBe( "desc" ); + } ); + + it( "DATE_OBSERVED_OLDEST → observed_on asc", ( ) => { + const params = buildExploreV2QueryParams( { + ...initialExploreV2State, + sortBy: EXPLORE_V2_SORT.DATE_OBSERVED_OLDEST, + } ); + expect( params.order_by ).toBe( "observed_on" ); + expect( params.order ).toBe( "asc" ); + } ); + + it( "MOST_FAVED → votes desc", ( ) => { + const params = buildExploreV2QueryParams( { + ...initialExploreV2State, + sortBy: EXPLORE_V2_SORT.MOST_FAVED, + } ); + expect( params.order_by ).toBe( "votes" ); + expect( params.order ).toBe( "desc" ); + } ); + } ); + + it( "always sets per_page to 20 and verifiable to true", ( ) => { + const params = buildExploreV2QueryParams( initialExploreV2State ); + expect( params.per_page ).toBe( 20 ); + expect( params.verifiable ).toBe( true ); + } ); + + it( "applies sort order to the default state", ( ) => { + const params = buildExploreV2QueryParams( initialExploreV2State ); + expect( params.order_by ).toBe( "created_at" ); + expect( params.order ).toBe( "desc" ); + } ); + + it( "combines subject, location, and sort into a single query", ( ) => { + const state = { + subject: { type: "taxon", taxon: { id: 42 } }, + location: { + placeMode: EXPLORE_V2_PLACE_MODE.NEARBY, + lat: 37.5, + lng: -122.1, + radius: 1, + }, + sortBy: EXPLORE_V2_SORT.MOST_FAVED, + filters: {}, + }; + const params = buildExploreV2QueryParams( state ); + expect( params ).toEqual( { + per_page: 20, + verifiable: true, + order_by: "votes", + order: "desc", + taxon_id: 42, + lat: 37.5, + lng: -122.1, + radius: 1, + } ); + } ); +} ); diff --git a/tests/unit/providers/ExploreV2Context.test.js b/tests/unit/providers/ExploreV2Context.test.js new file mode 100644 index 000000000..f6f8e1df4 --- /dev/null +++ b/tests/unit/providers/ExploreV2Context.test.js @@ -0,0 +1,226 @@ +import { + defaultExploreV2Location, + EXPLORE_V2_ACTION, + EXPLORE_V2_PLACE_MODE, + EXPLORE_V2_SORT, + exploreV2Reducer, + initialExploreV2State, +} from "providers/ExploreV2Context"; +import fetchCoarseUserLocation from "sharedHelpers/fetchCoarseUserLocation"; + +jest.mock( "sharedHelpers/fetchCoarseUserLocation", ( ) => ( { + __esModule: true, + default: jest.fn( ), +} ) ); + +describe( "initialExploreV2State", ( ) => { + it( "starts with no subject, UNINITIALIZED placeMode, newest-upload sort, empty filters", ( ) => { + expect( initialExploreV2State.subject ).toBeNull( ); + expect( initialExploreV2State.location.placeMode ).toBe( EXPLORE_V2_PLACE_MODE.UNINITIALIZED ); + expect( initialExploreV2State.sortBy ).toBe( EXPLORE_V2_SORT.DATE_UPLOADED_NEWEST ); + expect( initialExploreV2State.filters ).toEqual( {} ); + } ); +} ); + +describe( "exploreV2Reducer", ( ) => { + describe( EXPLORE_V2_ACTION.SET_SUBJECT, ( ) => { + it( "sets a taxon subject, replacing any prior subject", ( ) => { + const taxon = { id: 42, name: "Foo" }; + const state = { + ...initialExploreV2State, + subject: { type: "user", user: { id: 1 } }, + }; + const next = exploreV2Reducer( state, { + type: EXPLORE_V2_ACTION.SET_SUBJECT, + subject: { type: "taxon", taxon }, + } ); + expect( next.subject ).toEqual( { type: "taxon", taxon } ); + } ); + + it( "preserves location, sortBy, and filters when changing subject", ( ) => { + const state = { + subject: null, + location: { + placeMode: EXPLORE_V2_PLACE_MODE.NEARBY, + lat: 1, + lng: 2, + radius: 3, + }, + sortBy: EXPLORE_V2_SORT.MOST_FAVED, + filters: { quality_grade: "research" }, + }; + const next = exploreV2Reducer( state, { + type: EXPLORE_V2_ACTION.SET_SUBJECT, + subject: { type: "taxon", taxon: { id: 1 } }, + } ); + expect( next.location ).toEqual( state.location ); + expect( next.sortBy ).toBe( state.sortBy ); + expect( next.filters ).toEqual( state.filters ); + } ); + } ); + + describe( EXPLORE_V2_ACTION.CLEAR_SUBJECT, ( ) => { + it( "clears the selected subject", ( ) => { + const state = { + ...initialExploreV2State, + subject: { type: "taxon", taxon: { id: 99 } }, + }; + const next = exploreV2Reducer( state, { type: EXPLORE_V2_ACTION.CLEAR_SUBJECT } ); + expect( next.subject ).toBeNull( ); + } ); + } ); + + describe( "location actions", ( ) => { + it( "SET_LOCATION_NEARBY transitions from PLACE and drops place", ( ) => { + const state = { + ...initialExploreV2State, + location: { + placeMode: EXPLORE_V2_PLACE_MODE.PLACE, + place: { id: 1 }, + }, + }; + const next = exploreV2Reducer( state, { + type: EXPLORE_V2_ACTION.SET_LOCATION_NEARBY, + lat: 37.5, + lng: -122.1, + radius: 1, + } ); + expect( next.location.placeMode ).toBe( EXPLORE_V2_PLACE_MODE.NEARBY ); + expect( next.location.lat ).toBe( 37.5 ); + expect( next.location.lng ).toBe( -122.1 ); + expect( next.location.radius ).toBe( 1 ); + expect( next.location.place ).toBeUndefined( ); + } ); + + it( "SET_LOCATION_WORLDWIDE transitions from NEARBY and drops coords", ( ) => { + const state = { + ...initialExploreV2State, + location: { + placeMode: EXPLORE_V2_PLACE_MODE.NEARBY, + lat: 1, + lng: 1, + radius: 1, + }, + }; + const next = exploreV2Reducer( state, { + type: EXPLORE_V2_ACTION.SET_LOCATION_WORLDWIDE, + } ); + expect( next.location.placeMode ).toBe( EXPLORE_V2_PLACE_MODE.WORLDWIDE ); + expect( next.location.lat ).toBeUndefined( ); + expect( next.location.lng ).toBeUndefined( ); + expect( next.location.radius ).toBeUndefined( ); + } ); + + it( "SET_LOCATION_PLACE transitions from NEARBY and drops coords", ( ) => { + const state = { + ...initialExploreV2State, + location: { + placeMode: EXPLORE_V2_PLACE_MODE.NEARBY, + lat: 1, + lng: 1, + radius: 1, + }, + }; + const place = { id: 5, display_name: "Oakland" }; + const next = exploreV2Reducer( state, { + type: EXPLORE_V2_ACTION.SET_LOCATION_PLACE, + place, + } ); + expect( next.location.placeMode ).toBe( EXPLORE_V2_PLACE_MODE.PLACE ); + expect( next.location.place ).toEqual( place ); + expect( next.location.lat ).toBeUndefined( ); + } ); + + it( "SET_LOCATION_PLACE replaces an existing place", ( ) => { + const state = { + ...initialExploreV2State, + location: { + placeMode: EXPLORE_V2_PLACE_MODE.PLACE, + place: { id: 1, display_name: "Oakland" }, + }, + }; + const place = { id: 2, display_name: "Berkeley" }; + const next = exploreV2Reducer( state, { + type: EXPLORE_V2_ACTION.SET_LOCATION_PLACE, + place, + } ); + expect( next.location.placeMode ).toBe( EXPLORE_V2_PLACE_MODE.PLACE ); + expect( next.location.place ).toEqual( place ); + } ); + + it( "preserves subject, sortBy, and filters when changing location", ( ) => { + const state = { + subject: { type: "taxon", taxon: { id: 42 } }, + location: { placeMode: EXPLORE_V2_PLACE_MODE.UNINITIALIZED }, + sortBy: EXPLORE_V2_SORT.MOST_FAVED, + filters: { quality_grade: "research" }, + }; + const next = exploreV2Reducer( state, { + type: EXPLORE_V2_ACTION.SET_LOCATION_WORLDWIDE, + } ); + expect( next.subject ).toEqual( state.subject ); + expect( next.sortBy ).toBe( state.sortBy ); + expect( next.filters ).toEqual( state.filters ); + } ); + } ); + + describe( EXPLORE_V2_ACTION.SET_SORT, ( ) => { + it( "updates sortBy", ( ) => { + const next = exploreV2Reducer( initialExploreV2State, { + type: EXPLORE_V2_ACTION.SET_SORT, + sortBy: EXPLORE_V2_SORT.DATE_OBSERVED_NEWEST, + } ); + expect( next.sortBy ).toBe( EXPLORE_V2_SORT.DATE_OBSERVED_NEWEST ); + } ); + } ); + + describe( EXPLORE_V2_ACTION.SET_FILTERS, ( ) => { + it( "replaces filters with the provided object", ( ) => { + const state = { + ...initialExploreV2State, + filters: { quality_grade: "research" }, + }; + const filters = { quality_grade: "needs_id", iconic_taxa: ["Aves"] }; + const next = exploreV2Reducer( state, { + type: EXPLORE_V2_ACTION.SET_FILTERS, + filters, + } ); + expect( next.filters ).toEqual( filters ); + } ); + } ); + + describe( EXPLORE_V2_ACTION.RESET, ( ) => { + it( "returns initial state", ( ) => { + const state = { + ...initialExploreV2State, + subject: { type: "taxon", taxon: { id: 1 } }, + sortBy: EXPLORE_V2_SORT.MOST_FAVED, + }; + const next = exploreV2Reducer( state, { type: EXPLORE_V2_ACTION.RESET } ); + expect( next ).toEqual( initialExploreV2State ); + } ); + } ); +} ); + +describe( "defaultExploreV2Location", ( ) => { + beforeEach( ( ) => { + fetchCoarseUserLocation.mockReset( ); + } ); + + it( "returns NEARBY with radius 1 when a location is available", async ( ) => { + fetchCoarseUserLocation.mockResolvedValueOnce( { latitude: 37.5, longitude: -122.1 } ); + const result = await defaultExploreV2Location( ); + expect( result ).toEqual( { + placeMode: EXPLORE_V2_PLACE_MODE.NEARBY, + lat: 37.5, + lng: -122.1, + radius: 1, + } ); + } ); + + it( "returns WORLDWIDE when fetchCoarseUserLocation returns null", async ( ) => { + fetchCoarseUserLocation.mockResolvedValueOnce( null ); + const result = await defaultExploreV2Location( ); + expect( result ).toEqual( { placeMode: EXPLORE_V2_PLACE_MODE.WORLDWIDE } ); + } ); +} ); From d22fbd459d75eb14df7435e9a6f27daad620f8c3 Mon Sep 17 00:00:00 2001 From: sepeterson <10458078+sepeterson@users.noreply.github.com> Date: Fri, 1 May 2026 10:26:08 -0500 Subject: [PATCH 13/18] MOB-1329: nav typing --- .../StackNavigators/ExploreStackNavigator.tsx | 42 ++++++++----------- src/navigation/types.ts | 8 ++++ src/providers/ExploreV2Context.tsx | 2 + 3 files changed, 28 insertions(+), 24 deletions(-) diff --git a/src/navigation/StackNavigators/ExploreStackNavigator.tsx b/src/navigation/StackNavigators/ExploreStackNavigator.tsx index 16acf125a..6ae9ce95e 100644 --- a/src/navigation/StackNavigators/ExploreStackNavigator.tsx +++ b/src/navigation/StackNavigators/ExploreStackNavigator.tsx @@ -8,34 +8,28 @@ import UniversalSearch import { hideHeader } from "navigation/navigationOptions"; import type { ExploreStackParamList } from "navigation/types"; import React from "react"; -import colors from "styles/tailwindColors"; - -const BASE_SCREEN_OPTIONS = { - headerBackButtonDisplayMode: "minimal", - headerTintColor: colors.darkGray, -} as const; +// When navigating out of this stack, useNavigation should be typed like: +// useNavigation["navigation"]>( ); const Stack = createNativeStackNavigator( ); const ExploreStackNavigator = ( ) => ( - - - - - - + + + + ); diff --git a/src/navigation/types.ts b/src/navigation/types.ts index 72b77300f..8717579d0 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -391,6 +391,14 @@ export type TabStackScreenProps = BottomTabProps >; +// ExploreStackNavigator is nested inside RootExplore. This composite type +// acknowledges ExploreV2 screens access to outer-stack routes +export type ExploreStackScreenProps = + CompositeScreenProps< + NativeStackScreenProps, + TabStackScreenProps<"RootExplore"> + >; + export type NoBottomTabStackScreenProps = CompositeScreenProps< NativeStackScreenProps, diff --git a/src/providers/ExploreV2Context.tsx b/src/providers/ExploreV2Context.tsx index 12eef7707..bc5afe9cd 100644 --- a/src/providers/ExploreV2Context.tsx +++ b/src/providers/ExploreV2Context.tsx @@ -1,5 +1,7 @@ import * as React from "react"; +// Please don't change this to an aliased path or the e2e mock will not get +// used in our e2e tests on Github Actions import fetchCoarseUserLocation from "../sharedHelpers/fetchCoarseUserLocation"; export enum EXPLORE_V2_ACTION { From 0ba8150abce04e0c874fa4ed06b2d6a3b030552b Mon Sep 17 00:00:00 2001 From: sepeterson <10458078+sepeterson@users.noreply.github.com> Date: Fri, 1 May 2026 10:46:44 -0500 Subject: [PATCH 14/18] MOB-1329: cleanup for review --- .../Explore/ExploreV2/ExploreV2Container.tsx | 6 +----- .../ExploreV2/screens/AdvancedSearch.tsx | 20 ++++++++----------- .../ExploreV2/screens/ExploreObservations.tsx | 5 +---- .../ExploreV2/screens/UniversalSearch.tsx | 20 ++++++++----------- 4 files changed, 18 insertions(+), 33 deletions(-) diff --git a/src/components/Explore/ExploreV2/ExploreV2Container.tsx b/src/components/Explore/ExploreV2/ExploreV2Container.tsx index 5195ee64a..3eba26d18 100644 --- a/src/components/Explore/ExploreV2/ExploreV2Container.tsx +++ b/src/components/Explore/ExploreV2/ExploreV2Container.tsx @@ -14,7 +14,6 @@ const ExploreV2WithProvider = ( ) => { const { state, dispatch } = useExploreV2( ); const { hasPermissions, - renderPermissionsGate, } = useLocationPermission( ); const previousHasPermissions = useRef( undefined ); @@ -53,10 +52,7 @@ const ExploreV2WithProvider = ( ) => { }, [hasPermissions] ); return ( - <> - - {renderPermissionsGate( undefined )} - + ); }; diff --git a/src/components/Explore/ExploreV2/screens/AdvancedSearch.tsx b/src/components/Explore/ExploreV2/screens/AdvancedSearch.tsx index 8bf320311..637c92ae4 100644 --- a/src/components/Explore/ExploreV2/screens/AdvancedSearch.tsx +++ b/src/components/Explore/ExploreV2/screens/AdvancedSearch.tsx @@ -1,18 +1,14 @@ import { Body2, ViewWrapper } from "components/SharedComponents"; import { View } from "components/styledComponents"; -import { useExploreV2 } from "providers/ExploreV2Context"; import React from "react"; -const AdvancedSearch = ( ) => { - useExploreV2( ); - return ( - - - {/* eslint-disable-next-line i18next/no-literal-string */} - TODO: Advanced Search — MOB-1346 - - - ); -}; +const AdvancedSearch = ( ) => ( + + + {/* eslint-disable-next-line i18next/no-literal-string */} + TODO: Advanced Search — MOB-1346 + + +); export default AdvancedSearch; diff --git a/src/components/Explore/ExploreV2/screens/ExploreObservations.tsx b/src/components/Explore/ExploreV2/screens/ExploreObservations.tsx index 732b2f79d..2a46a61fb 100644 --- a/src/components/Explore/ExploreV2/screens/ExploreObservations.tsx +++ b/src/components/Explore/ExploreV2/screens/ExploreObservations.tsx @@ -12,8 +12,6 @@ import { View } from "components/styledComponents"; import { EXPLORE_V2_PLACE_MODE, useExploreV2 } from "providers/ExploreV2Context"; import React, { useMemo } from "react"; -const OBS_LIST_CONTAINER_STYLE = { paddingTop: 50 }; - const ExploreObservations = ( ) => { const { state } = useExploreV2( ); const { isConnected } = useNetInfo( ); @@ -34,9 +32,8 @@ const ExploreObservations = ( ) => { {/* eslint-disable-next-line i18next/no-literal-string */} - TODO: Header — MOB-1327 (tap to open Universal Search) + TODO: Header — MOB-1327 { - useExploreV2( ); - return ( - - - {/* eslint-disable-next-line i18next/no-literal-string */} - TODO: Universal Search — MOB-1338 - - - ); -}; +const UniversalSearch = ( ) => ( + + + {/* eslint-disable-next-line i18next/no-literal-string */} + TODO: Universal Search — MOB-1338 + + +); export default UniversalSearch; From f5564a32d534069e0046d65bb33277b65639ec06 Mon Sep 17 00:00:00 2001 From: sepeterson <10458078+sepeterson@users.noreply.github.com> Date: Fri, 1 May 2026 11:00:11 -0500 Subject: [PATCH 15/18] MOB-1329: link to exhaustiveness check doc --- src/providers/ExploreV2Context.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/providers/ExploreV2Context.tsx b/src/providers/ExploreV2Context.tsx index bc5afe9cd..ce123fb9a 100644 --- a/src/providers/ExploreV2Context.tsx +++ b/src/providers/ExploreV2Context.tsx @@ -143,6 +143,7 @@ export function exploreV2Reducer( case EXPLORE_V2_ACTION.RESET: return initialExploreV2State; default: { + // https://www.typescriptlang.org/docs/handbook/2/narrowing.html#exhaustiveness-checking const _exhaustive: never = action; return _exhaustive; } From 9756506fac6554f0b3fe947f00b7c393d8b23abe Mon Sep 17 00:00:00 2001 From: sepeterson <10458078+sepeterson@users.noreply.github.com> Date: Wed, 13 May 2026 07:32:19 -0500 Subject: [PATCH 16/18] MOB-1329: rm redundant ref and add comment for useEffectEvent --- .../Explore/ExploreV2/ExploreV2Container.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/components/Explore/ExploreV2/ExploreV2Container.tsx b/src/components/Explore/ExploreV2/ExploreV2Container.tsx index 3eba26d18..ec50886f9 100644 --- a/src/components/Explore/ExploreV2/ExploreV2Container.tsx +++ b/src/components/Explore/ExploreV2/ExploreV2Container.tsx @@ -7,7 +7,7 @@ import { ExploreV2Provider, useExploreV2, } from "providers/ExploreV2Context"; -import React, { useEffect, useEffectEvent, useRef } from "react"; +import React, { useEffect, useEffectEvent } from "react"; import useLocationPermission from "sharedHooks/useLocationPermission"; const ExploreV2WithProvider = ( ) => { @@ -15,8 +15,8 @@ const ExploreV2WithProvider = ( ) => { const { hasPermissions, } = useLocationPermission( ); - const previousHasPermissions = useRef( undefined ); - + // useEffectEvent is a new pattern for us, which we are adding only to new code for the moment + // https://github.com/inaturalist/iNaturalistReactNative/pull/3585#discussion_r3220223241 const onPermissionsGained = useEffectEvent( async ( ) => { if ( state.location.placeMode !== EXPLORE_V2_PLACE_MODE.UNINITIALIZED ) return; @@ -43,12 +43,11 @@ const ExploreV2WithProvider = ( ) => { // handle location permission changes on Explore useEffect( ( ) => { - if ( hasPermissions === true && previousHasPermissions.current !== true ) { + if ( hasPermissions === true ) { onPermissionsGained( ); - } else if ( hasPermissions === false && previousHasPermissions.current !== false ) { + } else if ( hasPermissions === false ) { onPermissionsDenied( ); } - previousHasPermissions.current = hasPermissions; }, [hasPermissions] ); return ( From 625b33c1be5172bd5e0be95dd9cf22f8c377f77e Mon Sep 17 00:00:00 2001 From: sepeterson <10458078+sepeterson@users.noreply.github.com> Date: Wed, 13 May 2026 08:04:43 -0500 Subject: [PATCH 17/18] MOB-1329: rename ExploreObservations -> ExploreResults --- .../{ExploreObservations.tsx => ExploreResults.tsx} | 6 +++--- .../StackNavigators/ExploreStackNavigator.tsx | 12 ++++++------ src/navigation/types.ts | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) rename src/components/Explore/ExploreV2/screens/{ExploreObservations.tsx => ExploreResults.tsx} (91%) diff --git a/src/components/Explore/ExploreV2/screens/ExploreObservations.tsx b/src/components/Explore/ExploreV2/screens/ExploreResults.tsx similarity index 91% rename from src/components/Explore/ExploreV2/screens/ExploreObservations.tsx rename to src/components/Explore/ExploreV2/screens/ExploreResults.tsx index 2a46a61fb..4c0c6f07a 100644 --- a/src/components/Explore/ExploreV2/screens/ExploreObservations.tsx +++ b/src/components/Explore/ExploreV2/screens/ExploreResults.tsx @@ -12,7 +12,7 @@ import { View } from "components/styledComponents"; import { EXPLORE_V2_PLACE_MODE, useExploreV2 } from "providers/ExploreV2Context"; import React, { useMemo } from "react"; -const ExploreObservations = ( ) => { +const ExploreResults = ( ) => { const { state } = useExploreV2( ); const { isConnected } = useNetInfo( ); @@ -29,7 +29,7 @@ const ExploreObservations = ( ) => { } = useInfiniteExploreScroll( { params: queryParams, enabled: canFetch } ); return ( - + {/* eslint-disable-next-line i18next/no-literal-string */} TODO: Header — MOB-1327 @@ -52,4 +52,4 @@ const ExploreObservations = ( ) => { ); }; -export default ExploreObservations; +export default ExploreResults; diff --git a/src/navigation/StackNavigators/ExploreStackNavigator.tsx b/src/navigation/StackNavigators/ExploreStackNavigator.tsx index 6ae9ce95e..e17a453a4 100644 --- a/src/navigation/StackNavigators/ExploreStackNavigator.tsx +++ b/src/navigation/StackNavigators/ExploreStackNavigator.tsx @@ -1,8 +1,8 @@ import { createNativeStackNavigator } from "@react-navigation/native-stack"; import AdvancedSearch from "components/Explore/ExploreV2/screens/AdvancedSearch"; -import ExploreObservations - from "components/Explore/ExploreV2/screens/ExploreObservations"; +import ExploreResults + from "components/Explore/ExploreV2/screens/ExploreResults"; import UniversalSearch from "components/Explore/ExploreV2/screens/UniversalSearch"; import { hideHeader } from "navigation/navigationOptions"; @@ -10,14 +10,14 @@ import type { ExploreStackParamList } from "navigation/types"; import React from "react"; // When navigating out of this stack, useNavigation should be typed like: -// useNavigation["navigation"]>( ); +// useNavigation["navigation"]>( ); const Stack = createNativeStackNavigator( ); const ExploreStackNavigator = ( ) => ( - + Date: Wed, 13 May 2026 09:47:55 -0500 Subject: [PATCH 18/18] MOB-1329: rm unnecessary memo and .Provider --- src/components/Explore/ExploreV2/screens/ExploreResults.tsx | 4 ++-- src/providers/ExploreV2Context.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/Explore/ExploreV2/screens/ExploreResults.tsx b/src/components/Explore/ExploreV2/screens/ExploreResults.tsx index 4c0c6f07a..199a1644b 100644 --- a/src/components/Explore/ExploreV2/screens/ExploreResults.tsx +++ b/src/components/Explore/ExploreV2/screens/ExploreResults.tsx @@ -10,13 +10,13 @@ import { } from "components/SharedComponents"; import { View } from "components/styledComponents"; import { EXPLORE_V2_PLACE_MODE, useExploreV2 } from "providers/ExploreV2Context"; -import React, { useMemo } from "react"; +import React from "react"; const ExploreResults = ( ) => { const { state } = useExploreV2( ); const { isConnected } = useNetInfo( ); - const queryParams = useMemo( () => buildExploreV2QueryParams( state ), [state] ); + const queryParams = buildExploreV2QueryParams( state ); const canFetch = state.location.placeMode !== EXPLORE_V2_PLACE_MODE.UNINITIALIZED; diff --git a/src/providers/ExploreV2Context.tsx b/src/providers/ExploreV2Context.tsx index ce123fb9a..6a35466b6 100644 --- a/src/providers/ExploreV2Context.tsx +++ b/src/providers/ExploreV2Context.tsx @@ -194,9 +194,9 @@ export const ExploreV2Provider = ( { children }: ExploreV2ProviderProps ) => { ); return ( - + {children} - + ); };