diff --git a/fastlane/Fastfile b/fastlane/Fastfile
index 521957219..7e3b83c6e 100644
--- a/fastlane/Fastfile
+++ b/fastlane/Fastfile
@@ -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(
diff --git a/src/appConstants/classNames.ts b/src/appConstants/classNames.ts
new file mode 100644
index 000000000..8cd578840
--- /dev/null
+++ b/src/appConstants/classNames.ts
@@ -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";
diff --git a/src/components/Explore/ExploreV2/components/ExploreV2Header.tsx b/src/components/Explore/ExploreV2/components/ExploreV2Header.tsx
index c67663fff..4e095d591 100644
--- a/src/components/Explore/ExploreV2/components/ExploreV2Header.tsx
+++ b/src/components/Explore/ExploreV2/components/ExploreV2Header.tsx
@@ -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 ) {
diff --git a/src/components/Journal/PostListItem.tsx b/src/components/Journal/PostListItem.tsx
index 291dd9501..ac9433b7d 100644
--- a/src/components/Journal/PostListItem.tsx
+++ b/src/components/Journal/PostListItem.tsx
@@ -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 && (
diff --git a/src/components/Notifications/ObservationIcon.tsx b/src/components/Notifications/ObservationIcon.tsx
index d164d97b4..0a9785574 100644
--- a/src/components/Notifications/ObservationIcon.tsx
+++ b/src/components/Notifications/ObservationIcon.tsx
@@ -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 (
{
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 = ( ) => (
{
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 (
{( isSaving || isLoading ) && (
@@ -143,41 +90,7 @@ const LoggedInDefaultSettings = ( ) => {
diff --git a/src/components/SharedComponents/DisplayTaxon.tsx b/src/components/SharedComponents/DisplayTaxon.tsx
index 4e9766373..6e5dc6c17 100644
--- a/src/components/SharedComponents/DisplayTaxon.tsx
+++ b/src/components/SharedComponents/DisplayTaxon.tsx
@@ -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
diff --git a/src/components/UserProfile/UserProfile.tsx b/src/components/UserProfile/UserProfile.tsx
index a456c1654..bfb4e4c7e 100644
--- a/src/components/UserProfile/UserProfile.tsx
+++ b/src/components/UserProfile/UserProfile.tsx
@@ -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 && (
- //
- // );
+ const isCurrentUser = !!currentUser && currentUser?.login === user?.login;
+ const navigateToAccountSettings = useNavigateToAccountSettings( { onFinish: refetchUser } );
- // navigation.setOptions( { headerRight } );
- // }, [navigation, user, currentUser] );
+ const headerRight = useCallback(
+ ( ) => ( isCurrentUser
+ ? (
+
+ )
+ : null ),
+ [isCurrentUser, navigateToAccountSettings, t],
+ );
+
+ useEffect(
+ ( ) => navigation.setOptions( { headerRight } ),
+ [headerRight, navigation],
+ );
if ( isError && error?.status === 404 ) {
return (
diff --git a/src/i18n/l10n/nl.ftl b/src/i18n/l10n/nl.ftl
index 270369553..cafb18754 100644
--- a/src/i18n/l10n/nl.ftl
+++ b/src/i18n/l10n/nl.ftl
@@ -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
diff --git a/src/i18n/l10n/nl.ftl.json b/src/i18n/l10n/nl.ftl.json
index f5ff7d8ae..8e0d3b516 100644
--- a/src/i18n/l10n/nl.ftl.json
+++ b/src/i18n/l10n/nl.ftl.json
@@ -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",
diff --git a/src/sharedHooks/useNavigateToAccountSettings.ts b/src/sharedHooks/useNavigateToAccountSettings.ts
new file mode 100644
index 000000000..63284a5e2
--- /dev/null
+++ b/src/sharedHooks/useNavigateToAccountSettings.ts
@@ -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;
diff --git a/tests/unit/components/UserProfile/UserProfile.test.js b/tests/unit/components/UserProfile/UserProfile.test.js
index 6a8a26fb5..2d0d71e20 100644
--- a/tests/unit/components/UserProfile/UserProfile.test.js
+++ b/tests/unit/components/UserProfile/UserProfile.test.js
@@ -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( );
+ 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( );
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( );
+ 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( );
+ renderHeaderRight( );
+
+ expect( screen.queryByTestId( "UserProfile.editButton" ) ).toBeNull( );
+ } );
+
+ test( "navigates to account settings when pressed", async () => {
+ jest.spyOn( useCurrentUser, "default" )
+ .mockImplementation( () => ( { login: mockUser.login } ) );
+
+ renderComponent( );
+ renderHeaderRight( );
+
+ fireEvent.press( screen.getByTestId( "UserProfile.editButton" ) );
+
+ expect( mockNavigate ).toHaveBeenCalledWith(
+ "FullPageWebView",
+ expect.objectContaining( { loggedIn: true } ),
+ );
+ } );
+ } );
} );
diff --git a/tests/unit/sharedHooks/useNavigateToAccountSettings.test.js b/tests/unit/sharedHooks/useNavigateToAccountSettings.test.js
new file mode 100644
index 000000000..fec9353fc
--- /dev/null
+++ b/tests/unit/sharedHooks/useNavigateToAccountSettings.test.js
@@ -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();
+ } );
+} );