diff --git a/.storybook/views/story-screen.tsx b/.storybook/views/story-screen.tsx index 631f86398c..8871a7798a 100644 --- a/.storybook/views/story-screen.tsx +++ b/.storybook/views/story-screen.tsx @@ -5,11 +5,12 @@ const PersistentStateWrapper: React.FC = ({ children }) {}, resetState: () => {}, diff --git a/__tests__/persistent-storage.spec.ts b/__tests__/persistent-storage.spec.ts index 6f8888797b..d8f02e3732 100644 --- a/__tests__/persistent-storage.spec.ts +++ b/__tests__/persistent-storage.spec.ts @@ -25,20 +25,21 @@ it("returns default when schema is not present", async () => { expect(state).toEqual(defaultPersistentState) }) -it("migration from 5 to 6", async () => { - const state5 = { - schemaVersion: 5, +it("migration from 6 to 7", async () => { + const state6 = { + schemaVersion: 6, galoyInstance: { id: "Main" }, galoyAuthToken: "myToken", } - const state6 = { - schemaVersion: 6, + const state7 = { + schemaVersion: 7, galoyInstance: { id: "Main" }, galoyAuthToken: "myToken", + galoyAllAuthTokens: ["myToken"], } - const res = await migrateAndGetPersistentState(state5) + const res = await migrateAndGetPersistentState(state6) - expect(res).toStrictEqual(state6) + expect(res).toStrictEqual(state7) }) diff --git a/app/assets/icons/switch.svg b/app/assets/icons/switch.svg index 02e63f98c6..fb03478a4b 100644 --- a/app/assets/icons/switch.svg +++ b/app/assets/icons/switch.svg @@ -1,10 +1,3 @@ - - - - - - - - - - + + + \ No newline at end of file diff --git a/app/components/atomic/galoy-icon/galoy-icon.tsx b/app/components/atomic/galoy-icon/galoy-icon.tsx index 679baec772..61295c8efc 100644 --- a/app/components/atomic/galoy-icon/galoy-icon.tsx +++ b/app/components/atomic/galoy-icon/galoy-icon.tsx @@ -52,6 +52,7 @@ import Note from "@app/assets/icons/note.svg" import People from "@app/assets/icons/people.svg" import Rank from "@app/assets/icons/rank.svg" import Refresh from "@app/assets/icons/refresh.svg" +import Switch from "@app/assets/icons/switch.svg" import { makeStyles, useTheme } from "@rneui/themed" export const icons = { @@ -106,6 +107,7 @@ export const icons = { "payment-error": PaymentError, "bell": Bell, "refresh": Refresh, + "switch": Switch, } as const export type IconNamesType = keyof typeof icons diff --git a/app/graphql/client.tsx b/app/graphql/client.tsx index e5c740b987..c152c9d981 100644 --- a/app/graphql/client.tsx +++ b/app/graphql/client.tsx @@ -199,15 +199,15 @@ const GaloyClient: React.FC = ({ children }) => { if (token) { authLink = setContext((request, { headers }) => ({ headers: { - ...headers, authorization: getAuthorizationHeader(token), + ...headers, }, })) } else { authLink = setContext((request, { headers }) => ({ headers: { - ...headers, authorization: "", + ...headers, }, })) } diff --git a/app/graphql/generated.gql b/app/graphql/generated.gql index d4fe7b3887..2a7648aca0 100644 --- a/app/graphql/generated.gql +++ b/app/graphql/generated.gql @@ -1644,6 +1644,13 @@ query transactionListForDefaultAccount($first: Int, $after: String, $last: Int, } } +query username { + me { + username + __typename + } +} + query walletOverviewScreen { me { id diff --git a/app/graphql/generated.ts b/app/graphql/generated.ts index 98df38bb12..25c05cbfe6 100644 --- a/app/graphql/generated.ts +++ b/app/graphql/generated.ts @@ -2990,6 +2990,11 @@ export type OnChainUsdPaymentSendAsBtcDenominatedMutationVariables = Exact<{ export type OnChainUsdPaymentSendAsBtcDenominatedMutation = { readonly __typename: 'Mutation', readonly onChainUsdPaymentSendAsBtcDenominated: { readonly __typename: 'PaymentSendPayload', readonly status?: PaymentSendResult | null, readonly errors: ReadonlyArray<{ readonly __typename: 'GraphQLApplicationError', readonly message: string }> } }; +export type UsernameQueryVariables = Exact<{ [key: string]: never; }>; + + +export type UsernameQuery = { readonly __typename: 'Query', readonly me?: { readonly __typename: 'User', readonly username?: string | null } | null }; + export type AccountDeleteMutationVariables = Exact<{ [key: string]: never; }>; @@ -6748,6 +6753,45 @@ export function useOnChainUsdPaymentSendAsBtcDenominatedMutation(baseOptions?: A export type OnChainUsdPaymentSendAsBtcDenominatedMutationHookResult = ReturnType; export type OnChainUsdPaymentSendAsBtcDenominatedMutationResult = Apollo.MutationResult; export type OnChainUsdPaymentSendAsBtcDenominatedMutationOptions = Apollo.BaseMutationOptions; +export const UsernameDocument = gql` + query username { + me { + username + } +} + `; + +/** + * __useUsernameQuery__ + * + * To run a query within a React component, call `useUsernameQuery` and pass it any options that fit your needs. + * When your component renders, `useUsernameQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useUsernameQuery({ + * variables: { + * }, + * }); + */ +export function useUsernameQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(UsernameDocument, options); + } +export function useUsernameLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(UsernameDocument, options); + } +export function useUsernameSuspenseQuery(baseOptions?: Apollo.SuspenseQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useSuspenseQuery(UsernameDocument, options); + } +export type UsernameQueryHookResult = ReturnType; +export type UsernameLazyQueryHookResult = ReturnType; +export type UsernameSuspenseQueryHookResult = ReturnType; +export type UsernameQueryResult = Apollo.QueryResult; export const AccountDeleteDocument = gql` mutation accountDelete { accountDelete { diff --git a/app/hooks/use-app-config.ts b/app/hooks/use-app-config.ts index 88d4765a40..ccc363f603 100644 --- a/app/hooks/use-app-config.ts +++ b/app/hooks/use-app-config.ts @@ -10,8 +10,13 @@ export const useAppConfig = () => { () => ({ token: persistentState.galoyAuthToken, galoyInstance: resolveGaloyInstanceOrDefault(persistentState.galoyInstance), + allTokens: persistentState.galoyAllAuthTokens, }), - [persistentState.galoyAuthToken, persistentState.galoyInstance], + [ + persistentState.galoyAuthToken, + persistentState.galoyInstance, + persistentState.galoyAllAuthTokens, + ], ) const setGaloyInstance = useCallback( @@ -35,6 +40,7 @@ export const useAppConfig = () => { return { ...state, galoyAuthToken: token, + galoyAllAuthTokens: [...state.galoyAllAuthTokens, token], } return undefined }) @@ -50,6 +56,7 @@ export const useAppConfig = () => { ...state, galoyInstance: instance, galoyAuthToken: token, + galoyAllAuthTokens: [...state.galoyAllAuthTokens, token], } return undefined }) diff --git a/app/i18n/en/index.ts b/app/i18n/en/index.ts index c77bc51afc..d6a4d9f5c4 100644 --- a/app/i18n/en/index.ts +++ b/app/i18n/en/index.ts @@ -2375,6 +2375,7 @@ const en: BaseTranslation = { tapToAddPhoneNumber: "Tap to add phone number", loginMethods: "Login Methods", level: "Level {level: string}", + switch: "switch", accountLevel: "Account Level", upgrade: "Upgrade your account", logOutAndDeleteLocalData: "Log out and clear all local data", @@ -2423,6 +2424,11 @@ const en: BaseTranslation = { accountId: "Account ID", copy: "Copy" }, + ProfileScreen: { + addNew : "Add new", + logout: "Logout", + error: "Unable to fetch profiles at this time", + }, TotpRegistrationInitiateScreen: { title: "Two-factor authentication", content: @@ -2681,6 +2687,7 @@ const en: BaseTranslation = { phone: "Phone", phoneNumber: "Phone Number", preimageProofOfPayment: "Preimage / Proof of Payment", + profile: "Profiles", rate: "Rate", reauth: "Your session has expired. Please log in again.", restart: "Restart", diff --git a/app/i18n/i18n-types.ts b/app/i18n/i18n-types.ts index 22619658e9..82c52b5d58 100644 --- a/app/i18n/i18n-types.ts +++ b/app/i18n/i18n-types.ts @@ -7407,6 +7407,10 @@ type RootTranslation = { * @param {string} level */ level: RequiredParams<'level'> + /** + * s​w​i​t​c​h + */ + 'switch': string /** * A​c​c​o​u​n​t​ ​L​e​v​e​l */ @@ -7577,6 +7581,20 @@ type RootTranslation = { */ copy: string } + ProfileScreen: { + /** + * A​d​d​ ​n​e​w + */ + addNew: string + /** + * L​o​g​o​u​t + */ + logout: string + /** + * U​n​a​b​l​e​ ​t​o​ ​f​e​t​c​h​ ​p​r​o​f​i​l​e​s​ ​a​t​ ​t​h​i​s​ ​t​i​m​e + */ + error: string + } TotpRegistrationInitiateScreen: { /** * T​w​o​-​f​a​c​t​o​r​ ​a​u​t​h​e​n​t​i​c​a​t​i​o​n @@ -8395,6 +8413,10 @@ type RootTranslation = { * P​r​e​i​m​a​g​e​ ​/​ ​P​r​o​o​f​ ​o​f​ ​P​a​y​m​e​n​t */ preimageProofOfPayment: string + /** + * P​r​o​f​i​l​e​s + */ + profile: string /** * R​a​t​e */ @@ -16418,6 +16440,10 @@ export type TranslationFunctions = { * Level {level} */ level: (arg: { level: string }) => LocalizedString + /** + * switch + */ + 'switch': () => LocalizedString /** * Account Level */ @@ -16582,6 +16608,20 @@ export type TranslationFunctions = { */ copy: () => LocalizedString } + ProfileScreen: { + /** + * Add new + */ + addNew: () => LocalizedString + /** + * Logout + */ + logout: () => LocalizedString + /** + * Unable to fetch profiles at this time + */ + error: () => LocalizedString + } TotpRegistrationInitiateScreen: { /** * Two-factor authentication @@ -17385,6 +17425,10 @@ export type TranslationFunctions = { * Preimage / Proof of Payment */ preimageProofOfPayment: () => LocalizedString + /** + * Profiles + */ + profile: () => LocalizedString /** * Rate */ diff --git a/app/i18n/raw-i18n/source/en.json b/app/i18n/raw-i18n/source/en.json index 27b3f25704..cd5caa98b6 100644 --- a/app/i18n/raw-i18n/source/en.json +++ b/app/i18n/raw-i18n/source/en.json @@ -2301,6 +2301,7 @@ "tapToAddPhoneNumber": "Tap to add phone number", "loginMethods": "Login Methods", "level": "Level {level: string}", + "switch": "switch", "accountLevel": "Account Level", "upgrade": "Upgrade your account", "logOutAndDeleteLocalData": "Log out and clear all local data", @@ -2342,6 +2343,11 @@ "accountId": "Account ID", "copy": "Copy" }, + "ProfileScreen": { + "addNew": "Add new", + "logout": "Logout", + "error": "Unable to fetch profiles at this time" + }, "TotpRegistrationInitiateScreen": { "title": "Two-factor authentication", "content": "Scan this QR code with your authenticator app. Alternatively, you can manually copy/paste the secret into your authenticator app." @@ -2578,6 +2584,7 @@ "phone": "Phone", "phoneNumber": "Phone Number", "preimageProofOfPayment": "Preimage / Proof of Payment", + "profile": "Profiles", "rate": "Rate", "reauth": "Your session has expired. Please log in again.", "restart": "Restart", @@ -2728,7 +2735,7 @@ "drivingAdoption": "I'm driving Bitcoin adoption using Blink.", "connectOnSocial": "Connect on social: ", "fullDetails": "Full details at ", - "learnMore": "Learn more about Adopting Bitcoin at ", + "learnMore": "Learn more about Adopting Bitcoin at", "mayChallenge": { "title": "May Challenge!", "description": "Grow your inner circle by 6 for a chance to win a set of Pizzas on your next Bitcoin Meetup.", diff --git a/app/navigation/root-navigator.tsx b/app/navigation/root-navigator.tsx index 7b111c951c..936bff3832 100644 --- a/app/navigation/root-navigator.tsx +++ b/app/navigation/root-navigator.tsx @@ -38,7 +38,7 @@ import SendBitcoinCompletedScreen from "@app/screens/send-bitcoin-screen/send-bi import SendBitcoinConfirmationScreen from "@app/screens/send-bitcoin-screen/send-bitcoin-confirmation-screen" import SendBitcoinDestinationScreen from "@app/screens/send-bitcoin-screen/send-bitcoin-destination-screen" import SendBitcoinDetailsScreen from "@app/screens/send-bitcoin-screen/send-bitcoin-details-screen" -import { AccountScreen } from "@app/screens/settings-screen/account" +import { AccountScreen, ProfileScreen } from "@app/screens/settings-screen/account" import { DefaultWalletScreen } from "@app/screens/settings-screen/default-wallet" import { DisplayCurrencyScreen } from "@app/screens/settings-screen/display-currency-screen" import { NotificationSettingsScreen } from "@app/screens/settings-screen/notifications-screen" @@ -335,6 +335,13 @@ export const RootStack = () => { title: LL.common.account(), }} /> + { const styles = useStyles() const { LL } = useI18nContext() + const { + theme: { colors }, + } = useTheme() const navigation = useNavigation>() @@ -31,6 +34,10 @@ export const AccountBanner = () => { if (loading) return + const handleSwitchPress = () => { + navigation.navigate("profileScreen") + } + return ( @@ -42,10 +49,20 @@ export const AccountBanner = () => { } > - - - {isUserLoggedIn ? usernameTitle : LL.SettingsScreen.logInOrCreateAccount()} - + + + + {isUserLoggedIn ? usernameTitle : LL.SettingsScreen.logInOrCreateAccount()} + + + {isUserLoggedIn && ( + + + + {LL.AccountScreen.switch()} + + + )} ) @@ -59,12 +76,25 @@ export const AccountIcon: React.FC<{ size: number }> = ({ size }) => { } const useStyles = makeStyles(() => ({ + inner: { + display: "flex", + flexDirection: "row", + alignItems: "center", + columnGap: 12, + }, outer: { height: 70, padding: 4, display: "flex", flexDirection: "row", alignItems: "center", - columnGap: 12, + justifyContent: "space-between", + }, + switch: { + display: "flex", + flexDirection: "row", + alignItems: "center", + columnGap: 4, + marginLeft: "auto", }, })) diff --git a/app/screens/settings-screen/account/index.ts b/app/screens/settings-screen/account/index.ts index e0a5266ce9..33e1230ff5 100644 --- a/app/screens/settings-screen/account/index.ts +++ b/app/screens/settings-screen/account/index.ts @@ -1 +1,2 @@ export * from "./account-screen" +export * from "./profile" diff --git a/app/screens/settings-screen/account/profile.tsx b/app/screens/settings-screen/account/profile.tsx new file mode 100644 index 0000000000..f9cd5dea5b --- /dev/null +++ b/app/screens/settings-screen/account/profile.tsx @@ -0,0 +1,292 @@ +import { ScrollView } from "react-native-gesture-handler" +import { Screen } from "@app/components/screen" +import { GaloyPrimaryButton } from "@app/components/atomic/galoy-primary-button" +import { useI18nContext } from "@app/i18n/i18n-react" +import { ActivityIndicator, Button, TouchableOpacity, View } from "react-native" +import { GaloyIcon } from "@app/components/atomic/galoy-icon" +import { makeStyles, Text, useTheme } from "@rneui/themed" +import { usePersistentStateContext } from "@app/store/persistent-state" +import { useNavigation } from "@react-navigation/native" +import { StackNavigationProp } from "@react-navigation/stack" +import { RootStackParamList } from "@app/navigation/stack-param-lists" +import { useApolloClient, gql } from "@apollo/client" +import { useUserLogoutMutation, useUsernameLazyQuery } from "@app/graphql/generated" +import { useCallback, useEffect, useRef, useState } from "react" +import messaging from "@react-native-firebase/messaging" +import crashlytics from "@react-native-firebase/crashlytics" +import { logLogout } from "@app/utils/analytics" + +gql` + query username { + me { + username + } + } +` + +type ProfileProps = { + username: string + token: string + selected?: boolean +} + +export const ProfileScreen: React.FC = () => { + const styles = useStyles() + const { + theme: { colors }, + } = useTheme() + const { LL } = useI18nContext() + const { persistentState } = usePersistentStateContext() + const navigation = useNavigation>() + + const { galoyAuthToken: curToken, galoyAllAuthTokens: allTokens } = persistentState + + const [profiles, setProfiles] = useState([]) + const [fetchUsername, { error, refetch }] = useUsernameLazyQuery({ + fetchPolicy: "no-cache", + }) + const [loading, setLoading] = useState(true) + const prevTokenRef = useRef(persistentState.galoyAuthToken) // Previous token state + + useEffect(() => { + const fetchUsernames = async () => { + setLoading(true) + const profiles: ProfileProps[] = [] + let counter = 1 + for (const token of allTokens) { + try { + const { data } = await fetchUsername({ + context: { + headers: { + authorization: `Bearer ${token}`, + }, + }, + }) + if (data && data.me) { + profiles.push({ + username: data.me.username ? data.me.username : `Account ${counter}`, + token, + selected: token === curToken, + }) + if (!data.me.username) { + counter += 1 + } + } + } catch (err) { + console.error(`Failed to fetch username for token ${token}`, err) + } + } + setProfiles(profiles) + setLoading(false) + } + fetchUsernames() + }, [allTokens, fetchUsername, curToken]) + + useEffect(() => { + const unsubscribe = navigation.addListener("beforeRemove", (e) => { + if (loading) { + e.preventDefault() + } + }) + + return unsubscribe + }, [navigation, loading]) + + useEffect(() => { + if (prevTokenRef.current !== persistentState.galoyAuthToken) { + // Navigate back when token is updated and different from the previous token + navigation.goBack() + } + prevTokenRef.current = persistentState.galoyAuthToken // Update previous token + }, [persistentState.galoyAuthToken, navigation]) + + if (error) { + return ( + + + + {LL.ProfileScreen.error()} + +