Merge branch 'main' into mob-1327-tabs

This commit is contained in:
sepeterson committed 2026-06-22 13:13:37 -05:00
commit ea30e0bdfc
14 files changed
+330 -134

No files matched your search

+10 -14
View File
@@ -471,13 +471,11 @@ lane :internal do
end
changelog = get_changelog
# TODO: disabled because we have just migrated this app to a new Google Play account and
# have not yet created credentials to talk to the upload API
# upload_to_play_store(
# aab: aab_path,
# track: "internal",
# version_name: last_tag
# )
upload_to_play_store(
aab: aab_path,
track: "internal",
version_name: last_tag
)
upload_to_testflight(
ipa: get_ipa_path,
@@ -513,13 +511,11 @@ lane :beta do
build_number = get_build_number( xcodeproj: XCODEPROJ )
# UI.message "Play Store: promoting build #{build_number} to beta track"
# TODO: disabled because we have just migrated this app to a new Google Play account and
# have not yet created credentials to talk to the upload API
# upload_to_play_store(
# version_code: build_number,
# track: "internal",
# track_promote_to: "beta"
# )
upload_to_play_store(
version_code: build_number,
track: "internal",
track_promote_to: "beta"
)
UI.message "TestFlight: adding build #{build_number} to Open Beta group"
add_build_to_testflight_beta_group(
+3
View File
@@ -0,0 +1,3 @@
// Tailwind/NativeWind className for a standard 62x62 rounded thumbnail
// eslint-disable-next-line import/prefer-default-export
export const THUMBNAIL_CLASS = "w-[62px] h-[62px] rounded-lg";
@@ -1,4 +1,5 @@
import { useNavigation } from "@react-navigation/native";
import { THUMBNAIL_CLASS } from "appConstants/classNames";
import {
Body1,
Body3,
@@ -22,8 +23,6 @@ import React from "react";
import { useCurrentUser, useTranslation } from "sharedHooks";
import colors from "styles/tailwindColors";
const THUMBNAIL_CLASS = "w-[62px] h-[62px] rounded-lg";
function subjectLabel( subject: ExploreV2Subject | null, t: TFunction ): string {
if ( !subject ) { return t( "All-organisms" ); }
switch ( subject.type ) {
+2 -1
View File
@@ -1,4 +1,5 @@
import type { ApiPost } from "api/types";
import { THUMBNAIL_CLASS } from "appConstants/classNames";
import {
Body1,
List2,
@@ -29,7 +30,7 @@ const PostListItem = ( {
{item.parent.icon_url && (
<Image
source={{ uri: item.parent.icon_url }}
className="w-[62px] h-[62px] rounded-lg"
className={THUMBNAIL_CLASS}
accessibilityRole="image"
accessibilityIgnoresInvertColors
/>
@@ -1,4 +1,5 @@
import type { ApiObservation } from "api/types";
import { THUMBNAIL_CLASS } from "appConstants/classNames";
import classnames from "classnames";
import {
INatIcon,
@@ -20,10 +21,8 @@ const ObservationIcon = ( {
return (
<View
className={classnames(
"w-[62px]",
"h-[62px]",
THUMBNAIL_CLASS,
"bg-white",
"rounded-lg",
"border-[2px]",
"justify-center",
"items-center",
@@ -42,10 +41,8 @@ const ObservationIcon = ( {
return (
<View
className={classnames(
"w-[62px]",
"h-[62px]",
THUMBNAIL_CLASS,
"bg-white",
"rounded-lg",
"border-[2px]",
"justify-center",
"items-center",
@@ -63,7 +60,7 @@ const ObservationIcon = ( {
return (
<Image
testID="ObservationIcon.photo"
className="w-[62px] h-[62px] rounded-lg"
className={THUMBNAIL_CLASS}
source={{ uri: photoUri }}
accessibilityRole="image"
accessibilityIgnoresInvertColors
@@ -1,4 +1,5 @@
import type { ProjectRulePreference } from "api/types";
import { THUMBNAIL_CLASS } from "appConstants/classNames";
import classnames from "classnames";
import displayProjectType from "components/Projects/helpers/displayProjectType";
import {
@@ -31,7 +32,7 @@ interface Props {
const ProjectListItem = ( { item, isHeader = false }: Props ) => {
const { t, i18n } = useTranslation( );
const iconClassName = "w-[62px] h-[62px] rounded-lg bg-white mr-3";
const iconClassName = classnames( THUMBNAIL_CLASS, "bg-white mr-3" );
const displayBriefcase = ( ) => (
<INatIcon
@@ -1,11 +1,3 @@
import {
useNetInfo,
} from "@react-native-community/netinfo";
import { useFocusEffect, useNavigation } from "@react-navigation/native";
import { useQueryClient } from "@tanstack/react-query";
import {
signOut,
} from "components/LoginSignUp/AuthenticationService";
import {
ActivityIndicator,
Body2,
@@ -13,65 +5,32 @@ import {
Heading4,
} from "components/SharedComponents";
import { RealmContext } from "providers/contexts";
import React, { useCallback, useEffect, useState } from "react";
import React, { useEffect, useState } from "react";
import {
Alert,
View,
} from "react-native";
import Config from "react-native-config";
import { EventRegister } from "react-native-event-listeners";
import QueueItem from "realmModels/QueueItem";
import {
useLayoutPrefs,
useTranslation,
useUserMe,
} from "sharedHooks";
import useNavigateToAccountSettings from "sharedHooks/useNavigateToAccountSettings";
import LanguageSetting from "./LanguageSetting";
import TaxonNamesSetting from "./TaxonNamesSetting";
const { useRealm } = RealmContext;
const SETTINGS_URL = `${Config.OAUTH_API_URL}/users/edit?noh1=true`;
const FINISHED_WEB_SETTINGS = "finished-web-settings";
const LoggedInDefaultSettings = ( ) => {
const realm = useRealm( );
const { isConnected } = useNetInfo( );
const navigation = useNavigation( );
const { t } = useTranslation( );
const {
remoteUser, isLoading, refetchUserMe,
} = useUserMe( { updateRealm: false } );
const {
setIsDefaultMode,
} = useLayoutPrefs();
const [settings, setSettings] = useState( {} );
const [isSaving, setIsSaving] = useState( false );
const [showingWebViewSettings, setShowingWebViewSettings] = useState( false );
useFocusEffect(
useCallback( () => {
if ( showingWebViewSettings ) {
// When we get back from the webview of settings - in case the user updated their profile
// photo or other details
refetchUserMe();
setShowingWebViewSettings( false );
}
}, [showingWebViewSettings, refetchUserMe] ),
);
const confirmInternetConnection = useCallback( ( ) => {
if ( !isConnected ) {
Alert.alert(
t( "Internet-Connection-Required" ),
t( "Please-try-again-when-you-are-connected-to-the-internet" ),
);
}
return isConnected;
}, [t, isConnected] );
const queryClient = useQueryClient();
const navigateToAccountSettings = useNavigateToAccountSettings( { onFinish: refetchUserMe } );
useEffect( () => {
if ( remoteUser ) {
@@ -81,18 +40,6 @@ const LoggedInDefaultSettings = ( ) => {
}
}, [remoteUser, realm] );
// Listen for the webview to finish so we can fetch the updates users/me
// response
useEffect( ( ) => {
const listener = EventRegister.addEventListener(
FINISHED_WEB_SETTINGS,
refetchUserMe,
);
return ( ) => {
EventRegister?.removeEventListener( listener );
};
}, [refetchUserMe] );
return (
<View className="mt-[30px]">
{( isSaving || isLoading ) && (
@@ -143,41 +90,7 @@ const LoggedInDefaultSettings = ( ) => {
<Button
className="mt-4"
text={t( "ACCOUNT-SETTINGS" )}
onPress={() => {
confirmInternetConnection( );
if ( !isConnected ) { return; }
setShowingWebViewSettings( true );
navigation.navigate( "FullPageWebView", {
title: t( "ACCOUNT-SETTINGS" ),
loggedIn: true,
initialUrl: SETTINGS_URL,
blurEvent: FINISHED_WEB_SETTINGS,
clickablePathnames: ["/users/delete"],
skipSetSourceInShouldStartLoadWithRequest: true,
shouldLoadUrl: url => {
async function signOutGoHome() {
Alert.alert(
t( "Account-Deleted" ),
t( "It-may-take-up-to-an-hour-to-remove-content" ),
);
// sign out
await signOut( { realm, clearRealm: true, queryClient } );
// revert back to default mode
setIsDefaultMode( true );
// navigate to My Obs
navigation.navigate( "ObsList" );
}
// If the webview navigates to a URL that indicates the account
// was deleted, sign the current user out of the app
if ( url === `${Config.OAUTH_API_URL}/?account_deleted=true` ) {
signOutGoHome( );
return false;
}
return true;
},
} );
}}
onPress={navigateToAccountSettings}
accessibilityLabel={t( "INATURALIST-SETTINGS" )}
/>
</View>
@@ -1,3 +1,4 @@
import { THUMBNAIL_CLASS } from "appConstants/classNames";
import classnames from "classnames";
import {
DisplayTaxonName,
@@ -32,7 +33,7 @@ const DisplayTaxon = ( {
const { t } = useTranslation( );
const currentUser = useCurrentUser( );
const imageClassName = "w-[62px] h-[62px] rounded-lg";
const imageClassName = THUMBNAIL_CLASS;
const iconicTaxonName = taxon?.isIconic
? taxon.name
+30 -13
View File
@@ -9,6 +9,7 @@ import {
Button,
Heading1,
Heading4,
INatIconButton,
List2,
OverviewCounts,
ScrollViewWrapper,
@@ -18,7 +19,7 @@ import {
} from "components/SharedComponents";
import { View } from "components/styledComponents";
import type { TabStackScreenProps } from "navigation/types";
import React, { useState } from "react";
import React, { useCallback, useEffect, useState } from "react";
import User from "realmModels/User";
import { formatLongDate } from "sharedHelpers/dateAndTime";
import {
@@ -27,8 +28,10 @@ import {
useFeatureFlag,
useTranslation,
} from "sharedHooks";
import useNavigateToAccountSettings from "sharedHooks/useNavigateToAccountSettings";
import { FeatureFlag } from "stores/createFeatureFlagSlice";
import useStore from "stores/useStore";
import colors from "styles/tailwindColors";
import FollowButtonContainer from "./FollowButtonContainer";
import UnfollowSheet from "./UnfollowSheet";
@@ -46,10 +49,13 @@ const UserProfile = ( ) => {
const { t, i18n } = useTranslation( );
const fetchId = userId || login;
const { data: remoteUser, isError, error }: {
const {
data: remoteUser, isError, error, refetch: refetchUser,
}: {
data: ApiUser | null;
isError: boolean;
error: INatApiError | ErrorWithResponse;
refetch: ( ) => void;
} = useAuthenticatedQuery(
["fetchRemoteUser", fetchId],
optsWithAuth => fetchRemoteUser( fetchId, {}, optsWithAuth ),
@@ -78,18 +84,29 @@ const UserProfile = ( ) => {
},
);
// useEffect( ( ) => {
// const headerRight = ( ) => currentUser?.login === user?.login && (
// <INatIconButton
// icon="pencil"
// color={colors.darkGray}
// size={22}
// accessibilityLabel={t( "Edit" )}
// />
// );
const isCurrentUser = !!currentUser && currentUser?.login === user?.login;
const navigateToAccountSettings = useNavigateToAccountSettings( { onFinish: refetchUser } );
// navigation.setOptions( { headerRight } );
// }, [navigation, user, currentUser] );
const headerRight = useCallback(
( ) => ( isCurrentUser
? (
<INatIconButton
testID="UserProfile.editButton"
onPress={navigateToAccountSettings}
icon="pencil"
color={String( colors?.darkGray )}
size={22}
accessibilityLabel={t( "Edit" )}
/>
)
: null ),
[isCurrentUser, navigateToAccountSettings, t],
);
useEffect(
( ) => navigation.setOptions( { headerRight } ),
[headerRight, navigation],
);
if ( isError && error?.status === 404 ) {
return (
+1 -1
View File
@@ -767,7 +767,7 @@ Search-suggestions-with-location = Zoek suggesties met locatie
Search-suggestions-without-location = Zoek suggesties zonder locatie te gebruiken
SEARCH-TAXA = ZOEK TAXA
SEARCH-USERS = ZOEK GEBRUIKERS
See-journal-posts = Zie dagboek
See-journal-posts = Zie dagboek
See-observations-by-this-user-in-Explore = Toon waarnemingen van deze gebruiker in Verkennen
See-observations-of-this-taxon-in-explore = Toon waarnemingen van dit taxon in verkennen
See-project-members = Toon projectdeelnemers
+1 -1
View File
@@ -711,7 +711,7 @@
"Search-suggestions-without-location": "Zoek suggesties zonder locatie te gebruiken",
"SEARCH-TAXA": "ZOEK TAXA",
"SEARCH-USERS": "ZOEK GEBRUIKERS",
"See-journal-posts": "Zie dagboek",
"See-journal-posts": "Zie dagboek",
"See-observations-by-this-user-in-Explore": "Toon waarnemingen van deze gebruiker in Verkennen",
"See-observations-of-this-taxon-in-explore": "Toon waarnemingen van dit taxon in verkennen",
"See-project-members": "Toon projectdeelnemers",
@@ -0,0 +1,103 @@
import { useNetInfo } from "@react-native-community/netinfo";
import { useFocusEffect, useNavigation } from "@react-navigation/native";
import { useQueryClient } from "@tanstack/react-query";
import { signOut } from "components/LoginSignUp/AuthenticationService";
import type { TabStackScreenProps } from "navigation/types";
import { RealmContext } from "providers/contexts";
import { useCallback, useEffect, useState } from "react";
import { Alert } from "react-native";
import Config from "react-native-config";
import { EventRegister } from "react-native-event-listeners";
import useLayoutPrefs from "./useLayoutPrefs";
import useTranslation from "./useTranslation";
const { useRealm } = RealmContext;
const SETTINGS_URL = `${Config.OAUTH_API_URL}/users/edit?noh1=true`;
const FINISHED_WEB_SETTINGS = "finished-web-settings";
interface Options {
// Called when the user returns from the web settings view (e.g. to refetch
// any profile data that may have changed)
onFinish?: ( ) => void;
}
const useNavigateToAccountSettings = ( { onFinish }: Options = {} ) => {
const realm = useRealm( );
const { isConnected } = useNetInfo( );
const navigation = useNavigation<
TabStackScreenProps<"UserProfile" | "Settings">["navigation"]
>( );
const { t } = useTranslation( );
const { setIsDefaultMode } = useLayoutPrefs( );
const queryClient = useQueryClient( );
const [showingWebViewSettings, setShowingWebViewSettings] = useState( false );
useFocusEffect(
useCallback( ( ) => {
if ( showingWebViewSettings ) {
// When we get back from the webview of settings - in case the user updated their profile
// photo or other details
onFinish?.( );
setShowingWebViewSettings( false );
}
}, [showingWebViewSettings, onFinish] ),
);
// Listen for the webview to finish so callers can refresh their data
useEffect( ( ) => {
const listener = EventRegister.addEventListener(
FINISHED_WEB_SETTINGS,
( ) => onFinish?.( ),
);
return ( ) => {
EventRegister?.removeEventListener( listener as string );
};
}, [onFinish] );
const navigateToAccountSettings = useCallback( ( ) => {
if ( !isConnected ) {
Alert.alert(
t( "Internet-Connection-Required" ),
t( "Please-try-again-when-you-are-connected-to-the-internet" ),
);
return;
}
setShowingWebViewSettings( true );
navigation.navigate( "FullPageWebView", {
title: t( "ACCOUNT-SETTINGS" ),
loggedIn: true,
initialUrl: SETTINGS_URL,
blurEvent: FINISHED_WEB_SETTINGS,
clickablePathnames: ["/users/delete"],
skipSetSourceInShouldStartLoadWithRequest: true,
shouldLoadUrl: url => {
async function signOutGoHome() {
Alert.alert(
t( "Account-Deleted" ),
t( "It-may-take-up-to-an-hour-to-remove-content" ),
);
// sign out
await signOut( { realm, clearRealm: true, queryClient } );
// revert back to default mode
setIsDefaultMode( true );
// navigate to My Obs
navigation.navigate( "ObsList" );
}
// If the webview navigates to a URL that indicates the account
// was deleted, sign the current user out of the app
if ( url === `${Config.OAUTH_API_URL}/?account_deleted=true` ) {
signOutGoHome( );
return false;
}
return true;
},
} );
}, [isConnected, navigation, queryClient, realm, setIsDefaultMode, t] );
return navigateToAccountSettings;
};
export default useNavigateToAccountSettings;
@@ -1,7 +1,8 @@
import { screen } from "@testing-library/react-native";
import { fireEvent, screen } from "@testing-library/react-native";
import UserProfile from "components/UserProfile/UserProfile";
import { t } from "i18next";
import React from "react";
import * as useCurrentUser from "sharedHooks/useCurrentUser";
import factory from "tests/factory";
import { renderComponent } from "tests/helpers/render";
@@ -11,6 +12,7 @@ jest.mock( "sharedHooks/useAuthenticatedQuery", () => ( {
__esModule: true,
default: () => ( {
data: mockUser,
refetch: jest.fn(),
} ),
} ) );
@@ -22,6 +24,8 @@ jest.mock( "sharedHooks/useAuthenticatedMutation", () => ( {
} ),
} ) );
const mockNavigate = jest.fn();
const mockSetOptions = jest.fn();
jest.mock( "@react-navigation/native", () => {
const actualNav = jest.requireActual( "@react-navigation/native" );
return {
@@ -32,7 +36,8 @@ jest.mock( "@react-navigation/native", () => {
},
} ),
useNavigation: () => ( {
setOptions: () => ( { } ),
navigate: mockNavigate,
setOptions: mockSetOptions,
} ),
};
} );
@@ -47,7 +52,21 @@ jest.mock(
},
);
const renderHeaderRight = ( ) => {
const lastCall = mockSetOptions.mock.calls.at( -1 );
const HeaderRight = lastCall?.[0]?.headerRight;
if ( !HeaderRight ) return null;
renderComponent( <HeaderRight /> );
return HeaderRight;
};
describe( "UserProfile", () => {
beforeEach( () => {
jest.clearAllMocks();
// Default: viewing someone else's profile (no current user)
jest.spyOn( useCurrentUser, "default" ).mockImplementation( () => null );
} );
it( "should render inside mocked container for testing", () => {
renderComponent( <UserProfile /> );
expect( screen.getByTestId( "UserProfile" ) ).toBeTruthy();
@@ -88,4 +107,41 @@ describe( "UserProfile", () => {
const projectsButton = await screen.findByText( /VIEW PROJECTS/ );
expect( projectsButton ).toBeVisible( );
} );
describe( "edit button", () => {
test( "is shown when viewing your own profile", async () => {
jest.spyOn( useCurrentUser, "default" )
.mockImplementation( () => ( { login: mockUser.login } ) );
renderComponent( <UserProfile /> );
renderHeaderRight( );
expect( screen.getByTestId( "UserProfile.editButton" ) ).toBeTruthy( );
} );
test( "is not shown when viewing another user's profile", async () => {
jest.spyOn( useCurrentUser, "default" )
.mockImplementation( () => ( { login: "someone-else" } ) );
renderComponent( <UserProfile /> );
renderHeaderRight( );
expect( screen.queryByTestId( "UserProfile.editButton" ) ).toBeNull( );
} );
test( "navigates to account settings when pressed", async () => {
jest.spyOn( useCurrentUser, "default" )
.mockImplementation( () => ( { login: mockUser.login } ) );
renderComponent( <UserProfile /> );
renderHeaderRight( );
fireEvent.press( screen.getByTestId( "UserProfile.editButton" ) );
expect( mockNavigate ).toHaveBeenCalledWith(
"FullPageWebView",
expect.objectContaining( { loggedIn: true } ),
);
} );
} );
} );
@@ -0,0 +1,109 @@
import { useNetInfo } from "@react-native-community/netinfo";
import { act, renderHook } from "@testing-library/react-native";
import { signOut } from "components/LoginSignUp/AuthenticationService";
import { Alert } from "react-native";
import Config from "react-native-config";
import { EventRegister } from "react-native-event-listeners";
import useNavigateToAccountSettings from "sharedHooks/useNavigateToAccountSettings";
const mockNavigate = jest.fn();
jest.mock( "@react-navigation/native", () => ( {
...jest.requireActual( "@react-navigation/native" ),
useNavigation: () => ( { navigate: mockNavigate } ),
// Run the focus callback synchronously so we don't need a NavigationContainer
useFocusEffect: jest.fn(),
} ) );
const mockSetIsDefaultMode = jest.fn();
jest.mock( "sharedHooks/useLayoutPrefs", () => ( {
__esModule: true,
default: () => ( { setIsDefaultMode: mockSetIsDefaultMode } ),
} ) );
const mockRealm = {};
jest.mock( "providers/contexts", () => ( {
RealmContext: {
useRealm: () => mockRealm,
},
} ) );
const mockQueryClient = {};
jest.mock( "@tanstack/react-query", () => ( {
...jest.requireActual( "@tanstack/react-query" ),
useQueryClient: () => mockQueryClient,
} ) );
jest.mock( "components/LoginSignUp/AuthenticationService", () => ( {
__esModule: true,
signOut: jest.fn( () => Promise.resolve() ),
} ) );
const ACCOUNT_DELETED_URL = `${Config.OAUTH_API_URL}/?account_deleted=true`;
describe( "useNavigateToAccountSettings", () => {
beforeEach( () => {
jest.clearAllMocks();
useNetInfo.mockReturnValue( { isConnected: true } );
} );
it( "alerts and does not navigate when there is no internet", () => {
const alertSpy = jest.spyOn( Alert, "alert" ).mockImplementation( () => {} );
useNetInfo.mockReturnValue( { isConnected: false } );
const { result } = renderHook( () => useNavigateToAccountSettings() );
act( () => result.current() );
expect( alertSpy ).toHaveBeenCalled();
expect( mockNavigate ).not.toHaveBeenCalled();
} );
it( "navigates to the account settings web view when connected", () => {
const { result } = renderHook( () => useNavigateToAccountSettings() );
act( () => result.current() );
expect( mockNavigate ).toHaveBeenCalledWith(
"FullPageWebView",
expect.objectContaining( {
loggedIn: true,
clickablePathnames: ["/users/delete"],
} ),
);
} );
it( "signs the user out when the web view reports the account was deleted", async () => {
const alertSpy = jest.spyOn( Alert, "alert" ).mockImplementation( () => {} );
const { result } = renderHook( () => useNavigateToAccountSettings() );
act( () => result.current() );
const { shouldLoadUrl } = mockNavigate.mock.calls[0][1];
const shouldLoad = shouldLoadUrl( ACCOUNT_DELETED_URL );
// Block the web view from loading the deletion-confirmation URL
expect( shouldLoad ).toBe( false );
// Allow the async signOut flow inside shouldLoadUrl to resolve
await Promise.resolve();
expect( signOut ).toHaveBeenCalledWith(
expect.objectContaining( { realm: mockRealm, clearRealm: true } ),
);
expect( mockSetIsDefaultMode ).toHaveBeenCalledWith( true );
expect( mockNavigate ).toHaveBeenCalledWith( "ObsList" );
expect( alertSpy ).toHaveBeenCalled();
} );
it( "calls onFinish when the web settings finished event fires", () => {
const onFinish = jest.fn();
let registeredCallback;
jest.spyOn( EventRegister, "addEventListener" )
.mockImplementation( ( eventName, cb ) => {
registeredCallback = cb;
return "listener-id";
} );
renderHook( () => useNavigateToAccountSettings( { onFinish } ) );
expect( registeredCallback ).toBeDefined();
registeredCallback();
expect( onFinish ).toHaveBeenCalled();
} );
} );