diff --git a/packages/common/src/context/appContext.ts b/packages/common/src/context/appContext.ts index e62f58237af..7c7dc017507 100644 --- a/packages/common/src/context/appContext.ts +++ b/packages/common/src/context/appContext.ts @@ -28,6 +28,8 @@ type AppContextType = { audiusSdk?: AudiusSdkWithServices remoteConfig: RemoteConfigInstance localStorage: LocalStorage + /** Restarts the app after an account switch. Web falls back to a page reload. */ + reloadApp?: () => void } export const AppContext = createContext(null) diff --git a/packages/common/src/hooks/useAccountSwitcher.ts b/packages/common/src/hooks/useAccountSwitcher.ts index 8753ce3ef2e..e7d959b3615 100644 --- a/packages/common/src/hooks/useAccountSwitcher.ts +++ b/packages/common/src/hooks/useAccountSwitcher.ts @@ -5,7 +5,7 @@ import { useAppContext } from '~/context' import { UserMetadata } from '~/models/User' export const useAccountSwitcher = () => { - const { localStorage } = useAppContext() + const { localStorage, reloadApp } = useAppContext() const { data: currentWeb3User } = useCurrentWeb3Account() const switchAccount = useCallback( @@ -24,9 +24,13 @@ export const useAccountSwitcher = () => { await localStorage.clearAudiusAccount() await localStorage.clearAudiusAccountUser() - window.location.reload() + if (reloadApp) { + reloadApp() + } else { + window.location.reload() + } }, - [currentWeb3User, localStorage] + [currentWeb3User, localStorage, reloadApp] ) /** Convenience method to switch out of Manager Mode and back to the current web3 user */ diff --git a/packages/common/src/services/remote-config/feature-flags.ts b/packages/common/src/services/remote-config/feature-flags.ts index c015c11a9f3..561557d77a8 100644 --- a/packages/common/src/services/remote-config/feature-flags.ts +++ b/packages/common/src/services/remote-config/feature-flags.ts @@ -20,7 +20,8 @@ export enum FeatureFlags { TRACK_ROWS = 'track_rows', NOW_PLAYING_COMMENTS = 'now_playing_comments', TRACK_TILE_WAVEFORM = 'track_tile_waveform', - NOW_PLAYING_WAVEFORM = 'now_playing_waveform' + NOW_PLAYING_WAVEFORM = 'now_playing_waveform', + MOBILE_MANAGER_MODE = 'mobile_manager_mode' } type FlagDefaults = Record @@ -32,7 +33,8 @@ export const environmentFlagDefaults: Record< development: { [FeatureFlags.FAN_CLUB_TEXT_POST_POSTING]: true, [FeatureFlags.TRACK_TILE_WAVEFORM]: true, - [FeatureFlags.NOW_PLAYING_WAVEFORM]: true + [FeatureFlags.NOW_PLAYING_WAVEFORM]: true, + [FeatureFlags.MOBILE_MANAGER_MODE]: true }, production: {} } @@ -67,5 +69,8 @@ export const flagDefaults: FlagDefaults = { // Waveform with timestamped comment avatars on desktop web track tiles. [FeatureFlags.TRACK_TILE_WAVEFORM]: false, // Waveform scrubber with comment avatars in the mobile Now Playing drawer. - [FeatureFlags.NOW_PLAYING_WAVEFORM]: true + [FeatureFlags.NOW_PLAYING_WAVEFORM]: true, + // Account switcher and manager settings on mobile. Switching back out and + // the manager-mode restrictions stay on regardless of this flag. + [FeatureFlags.MOBILE_MANAGER_MODE]: false } diff --git a/packages/mobile/src/app/AppContextProvider.tsx b/packages/mobile/src/app/AppContextProvider.tsx index c8b5f52920e..cd56e26a338 100644 --- a/packages/mobile/src/app/AppContextProvider.tsx +++ b/packages/mobile/src/app/AppContextProvider.tsx @@ -1,6 +1,7 @@ import { useMemo, type ReactNode } from 'react' import { AppContext } from '@audius/common/context' +import RNRestart from 'react-native-restart' import { useAsync } from 'react-use' import * as analytics from 'app/services/analytics' @@ -31,7 +32,8 @@ export const AppContextProvider = (props: AppContextProviderProps) => { audiusSdk: sdk, audiusBackend: audiusBackendInstance, trackDownload, - remoteConfig: remoteConfigInstance + remoteConfig: remoteConfigInstance, + reloadApp: () => RNRestart.Restart() }), [sdk] ) diff --git a/packages/mobile/src/app/Drawers.tsx b/packages/mobile/src/app/Drawers.tsx index e8ef3445563..c3a8c60ff37 100644 --- a/packages/mobile/src/app/Drawers.tsx +++ b/packages/mobile/src/app/Drawers.tsx @@ -31,6 +31,7 @@ import { InboxUnavailableDrawer } from 'app/components/inbox-unavailable-drawer/ import { LeavingAudiusDrawer } from 'app/components/leaving-audius-drawer' import { LockedContentDrawer } from 'app/components/locked-content-drawer' import { LockedTextPostDrawer } from 'app/components/locked-text-post-drawer' +import { AccountSwitcherDrawer } from 'app/components/manager-mode/AccountSwitcherDrawer' import { ManagerModeDrawer } from 'app/components/manager-mode-drawer/ManagerModeDrawer' import { OverflowMenuDrawer } from 'app/components/overflow-menu-drawer' import { PlaybackRateDrawer } from 'app/components/playback-rate-drawer' @@ -163,6 +164,7 @@ const nativeDrawersMap: { [DrawerName in Drawer]?: LazyComponent } = { DeleteTrackConfirmation: () => DeleteTrackConfirmationDrawer, ShareToStoryProgress: () => ShareToStoryProgressDrawer, ManagerMode: () => ManagerModeDrawer, + AccountSwitcher: () => AccountSwitcherDrawer, RateCallToAction: () => RateCtaDrawer, RemoveAllDownloads: () => RemoveAllDownloadsDrawer, RemoveDownloadedCollection: () => RemoveDownloadedCollectionDrawer, diff --git a/packages/mobile/src/components/manager-mode-drawer/useShowManagerModeNotAvailable.tsx b/packages/mobile/src/components/manager-mode-drawer/useShowManagerModeNotAvailable.tsx index d1a0135e13e..6ae40693ce8 100644 --- a/packages/mobile/src/components/manager-mode-drawer/useShowManagerModeNotAvailable.tsx +++ b/packages/mobile/src/components/manager-mode-drawer/useShowManagerModeNotAvailable.tsx @@ -1,16 +1,34 @@ import { useEffect } from 'react' +import { useFeatureFlag } from '@audius/common/hooks' +import { FeatureFlags } from '@audius/common/services' + import { useDrawer } from 'app/hooks/useDrawer' +import { useNavigation } from 'app/hooks/useNavigation' import { useRoute } from 'app/hooks/useRoute' +import type { ProfileTabScreenParamList } from 'app/screens/app-screen/ProfileTabScreen' + +const PATH_TO_SCREEN = { + 'accounts-you-manage': 'AccountsYouManageScreen', + 'managing-you': 'AccountsManagingYouScreen' +} as const +/** Handles /settings/accounts-you-manage and /settings/managing-you links */ export const useShowManagerModeNotAvailable = () => { const { params } = useRoute() const path = (params as { path: string })?.path + const navigation = useNavigation() const { onOpen: openManagerModeDrawer } = useDrawer('ManagerMode') + const { isEnabled: isManagerModeEnabled, isLoaded } = useFeatureFlag( + FeatureFlags.MOBILE_MANAGER_MODE + ) useEffect(() => { - if (path === 'accounts-you-manage') { + if (!isLoaded || !(path in PATH_TO_SCREEN)) return + if (isManagerModeEnabled) { + navigation.push(PATH_TO_SCREEN[path as keyof typeof PATH_TO_SCREEN]) + } else if (path === 'accounts-you-manage') { openManagerModeDrawer() } - }, [path, openManagerModeDrawer]) + }, [path, isLoaded, isManagerModeEnabled, navigation, openManagerModeDrawer]) } diff --git a/packages/mobile/src/components/manager-mode/AccountRow.tsx b/packages/mobile/src/components/manager-mode/AccountRow.tsx new file mode 100644 index 00000000000..e8478567971 --- /dev/null +++ b/packages/mobile/src/components/manager-mode/AccountRow.tsx @@ -0,0 +1,42 @@ +import type { ReactNode } from 'react' + +import type { UserMetadata } from '@audius/common/models' + +import { Flex, Text } from '@audius/harmony-native' +import { ProfilePicture } from 'app/components/core' +import { UserBadges } from 'app/components/user-badges' + +type AccountRowProps = { + user: Pick + isSelected?: boolean + /** Rendered on the right side of the row */ + children?: ReactNode +} + +export const AccountRow = ({ user, isSelected, children }: AccountRowProps) => { + const textColor = isSelected ? 'accent' : 'default' + return ( + + + + + + {user.name} + + + + + @{user.handle} + + + {children} + + ) +} diff --git a/packages/mobile/src/components/manager-mode/AccountSwitcherDrawer.tsx b/packages/mobile/src/components/manager-mode/AccountSwitcherDrawer.tsx new file mode 100644 index 00000000000..d894ed39768 --- /dev/null +++ b/packages/mobile/src/components/manager-mode/AccountSwitcherDrawer.tsx @@ -0,0 +1,118 @@ +import { useCallback, useMemo, useState } from 'react' + +import { useAccountSwitcher } from '@audius/common/hooks' +import type { UserMetadata } from '@audius/common/models' +import { TouchableOpacity } from 'react-native' + +import { + Flex, + IconCheck, + IconUserArrowRotate, + Text +} from '@audius/harmony-native' +import { NativeDrawer } from 'app/components/drawer' +import { useDrawer } from 'app/hooks/useDrawer' + +import { DrawerHeader } from '../core/DrawerHeader' + +import { AccountRow } from './AccountRow' +import { useManagerModeAccounts } from './useManagerModeAccounts' + +const messages = { + switchAccount: 'Switch Account', + managedAccounts: 'Managed Accounts', + switching: 'Switching...' +} + +type SwitcherRowProps = { + user: UserMetadata + isSelected: boolean + isSwitching: boolean + onPress: (user: UserMetadata) => void +} + +const SwitcherRow = ({ + user, + isSelected, + isSwitching, + onPress +}: SwitcherRowProps) => ( + onPress(user)} + accessibilityRole='button' + accessibilityState={{ selected: isSelected }} + > + + + {isSelected ? : null} + + + +) + +export const AccountSwitcherDrawer = () => { + const { onClose } = useDrawer('AccountSwitcher') + const { web3User, currentUserId, accounts } = useManagerModeAccounts() + const { switchAccount } = useAccountSwitcher() + const [isSwitching, setIsSwitching] = useState(false) + + // Keep the account in use at the top of the managed list + const sortedAccounts = useMemo(() => { + const selected = accounts.filter( + ({ user }) => user.user_id === currentUserId + ) + const rest = accounts.filter(({ user }) => user.user_id !== currentUserId) + return [...selected, ...rest] + }, [accounts, currentUserId]) + + const handlePress = useCallback( + (user: UserMetadata) => { + if (user.user_id === currentUserId) return + setIsSwitching(true) + switchAccount(user) + }, + [currentUserId, switchAccount] + ) + + if (!web3User) return null + + return ( + + + + + + + + + {messages.managedAccounts} + + + {sortedAccounts.map(({ user }) => ( + + ))} + + + ) +} diff --git a/packages/mobile/src/components/manager-mode/ManagedAccountUnavailableScreen.tsx b/packages/mobile/src/components/manager-mode/ManagedAccountUnavailableScreen.tsx new file mode 100644 index 00000000000..24b223e3c32 --- /dev/null +++ b/packages/mobile/src/components/manager-mode/ManagedAccountUnavailableScreen.tsx @@ -0,0 +1,58 @@ +import type { ComponentType } from 'react' + +import { useCurrentAccountUser } from '@audius/common/api' +import { useAccountSwitcher, useIsManagedAccount } from '@audius/common/hooks' + +import { Button, Flex, IconUserArrowRotate, Text } from '@audius/harmony-native' +import { Screen, ScreenContent } from 'app/components/core' + +const messages = { + title: 'Not Available', + description: (handle?: string) => + `Messages aren't available while you're managing ${ + handle ? `@${handle}` : 'another account' + }.`, + switchBack: 'Switch to My Account' +} + +export const ManagedAccountUnavailableScreen = () => { + const { data: handle } = useCurrentAccountUser({ + select: (user) => user?.handle + }) + const { switchToWeb3User } = useAccountSwitcher() + + return ( + + + + + + {messages.description(handle)} + + + + + + ) +} + +/** + * Renders the unavailable screen instead of `Component` while managing + * another account. Used for screens that act as the signed-in wallet + * (e.g. DMs), which a manager can't do on the managed account's behalf. + */ +export const withManagedAccountRestriction =

( + Component: ComponentType

+) => { + const Restricted = (props: P) => { + const isManagedAccount = useIsManagedAccount() + if (isManagedAccount) return + return + } + Restricted.displayName = `withManagedAccountRestriction(${ + Component.displayName ?? Component.name + })` + return Restricted +} diff --git a/packages/mobile/src/components/manager-mode/useManagedAccountAccessCheck.ts b/packages/mobile/src/components/manager-mode/useManagedAccountAccessCheck.ts new file mode 100644 index 00000000000..95cc8f5048c --- /dev/null +++ b/packages/mobile/src/components/manager-mode/useManagedAccountAccessCheck.ts @@ -0,0 +1,33 @@ +import { useEffect, useRef } from 'react' + +import { useAccountSwitcher } from '@audius/common/hooks' + +import { useManagerModeAccounts } from './useManagerModeAccounts' + +/** + * Switches back to the signed-in user if their access to the managed account + * they're using was revoked. Runs once per app launch. + */ +export const useManagedAccountAccessCheck = () => { + const { currentUserId, isManagedAccount, isAccountsLoaded, accounts } = + useManagerModeAccounts() + const { switchToWeb3User } = useAccountSwitcher() + const hasChecked = useRef(false) + + useEffect(() => { + if (hasChecked.current || !currentUserId || !isAccountsLoaded) return + hasChecked.current = true + if ( + isManagedAccount && + !accounts.some(({ user }) => user.user_id === currentUserId) + ) { + switchToWeb3User() + } + }, [ + accounts, + currentUserId, + isAccountsLoaded, + isManagedAccount, + switchToWeb3User + ]) +} diff --git a/packages/mobile/src/components/manager-mode/useManagedAccountNotAllowed.ts b/packages/mobile/src/components/manager-mode/useManagedAccountNotAllowed.ts new file mode 100644 index 00000000000..c674c81a6d5 --- /dev/null +++ b/packages/mobile/src/components/manager-mode/useManagedAccountNotAllowed.ts @@ -0,0 +1,51 @@ +import { useCallback, useEffect } from 'react' + +import { useIsManagedAccount } from '@audius/common/hooks' + +import { useToast } from 'app/hooks/useToast' + +export const managedAccountNotAllowedMessage = + "You can't do that as a managed user" + +/** + * While managing another account, runs `callback` (e.g. closing a drawer) + * and shows a toast whenever `trigger` becomes true. + */ +export const useManagedAccountNotAllowedCallback = ({ + trigger, + callback +}: { + trigger: boolean + callback: () => void +}) => { + const isManagedAccount = useIsManagedAccount() + const { toast } = useToast() + + useEffect(() => { + if (isManagedAccount && trigger) { + callback() + toast({ content: managedAccountNotAllowedMessage }) + } + }, [isManagedAccount, callback, toast, trigger]) +} + +/** + * Wraps an action so it shows a toast instead of running while managing + * another account. + */ +export const useManagedAccountGuard = () => { + const isManagedAccount = useIsManagedAccount() + const { toast } = useToast() + + return useCallback( + (action: (...args: Args) => void) => + (...args: Args) => { + if (isManagedAccount) { + toast({ content: managedAccountNotAllowedMessage }) + return + } + action(...args) + }, + [isManagedAccount, toast] + ) +} diff --git a/packages/mobile/src/components/manager-mode/useManagerModeAccounts.ts b/packages/mobile/src/components/manager-mode/useManagerModeAccounts.ts new file mode 100644 index 00000000000..052f0e95dc4 --- /dev/null +++ b/packages/mobile/src/components/manager-mode/useManagerModeAccounts.ts @@ -0,0 +1,47 @@ +import { useMemo } from 'react' + +import { + useCurrentUserId, + useCurrentWeb3Account, + useManagedAccounts +} from '@audius/common/api' +import { useFeatureFlag, useIsManagedAccount } from '@audius/common/hooks' +import { FeatureFlags } from '@audius/common/services' + +/** + * Accounts the signed-in user can switch between. The switcher stays + * available while managing an account even if the flag is off, so nobody + * gets stuck in manager mode. + */ +export const useManagerModeAccounts = () => { + const { isEnabled: isManagerModeEnabled } = useFeatureFlag( + FeatureFlags.MOBILE_MANAGER_MODE + ) + const { data: web3User } = useCurrentWeb3Account() + const { data: currentUserId } = useCurrentUserId() + const isManagedAccount = useIsManagedAccount() + + const { data: managedAccounts = [], isSuccess } = useManagedAccounts( + web3User?.user_id + ) + + const accounts = useMemo( + () => managedAccounts.filter(({ grant }) => grant.is_approved), + [managedAccounts] + ) + + const canSwitch = + !!web3User && + !!currentUserId && + (isManagedAccount || (isManagerModeEnabled && accounts.length > 0)) + + return { + web3User, + currentUserId, + accounts, + isManagedAccount, + isManagerModeEnabled, + isAccountsLoaded: isSuccess, + canSwitch + } +} diff --git a/packages/mobile/src/components/premium-content-purchase-drawer/PremiumContentPurchaseDrawer.tsx b/packages/mobile/src/components/premium-content-purchase-drawer/PremiumContentPurchaseDrawer.tsx index 0b468e34052..66ee8f88283 100644 --- a/packages/mobile/src/components/premium-content-purchase-drawer/PremiumContentPurchaseDrawer.tsx +++ b/packages/mobile/src/components/premium-content-purchase-drawer/PremiumContentPurchaseDrawer.tsx @@ -51,6 +51,7 @@ import { } from '@audius/harmony-native' import { Text } from 'app/components/core' import Drawer from 'app/components/drawer' +import { useManagedAccountNotAllowedCallback } from 'app/components/manager-mode/useManagedAccountNotAllowed' import { useIsUSDCEnabled } from 'app/hooks/useIsUSDCEnabled' import { useNavigation } from 'app/hooks/useNavigation' import { make, track as trackEvent } from 'app/services/analytics' @@ -488,6 +489,8 @@ export const PremiumContentPurchaseDrawer = () => { dispatch(purchaseContentActions.cleanup()) }, [onClosed, dispatch]) + useManagedAccountNotAllowedCallback({ trigger: isOpen, callback: onClose }) + if ( !metadata || !purchaseConditions || diff --git a/packages/mobile/src/components/share-drawer/ShareDrawer.tsx b/packages/mobile/src/components/share-drawer/ShareDrawer.tsx index a9a6be017d9..22ad98bd6dd 100644 --- a/packages/mobile/src/components/share-drawer/ShareDrawer.tsx +++ b/packages/mobile/src/components/share-drawer/ShareDrawer.tsx @@ -1,7 +1,11 @@ import React, { useCallback, useEffect, useRef } from 'react' import { useCurrentUserId } from '@audius/common/api' -import { useShareAction, useShareContent } from '@audius/common/hooks' +import { + useIsManagedAccount, + useShareAction, + useShareContent +} from '@audius/common/hooks' import { exploreMessages } from '@audius/common/messages' import { Name, ShareSource } from '@audius/common/models' import { registerNiceModalId } from '@audius/common/services' @@ -77,6 +81,7 @@ const useStyles = makeStyles(({ spacing }) => ({ export const ShareDrawer = NiceModal.create(() => { const styles = useStyles() + const isManagedAccount = useIsManagedAccount() const viewShotRef = useRef(null) as React.RefObject const navigation = useNavigation() const sendShareAction = useShareAction() @@ -284,7 +289,8 @@ export const ShareDrawer = NiceModal.create(() => { icon: React.ReactElement style?: Record callback: (() => void) | (() => Promise) - }[] = [shareToChatAction] + // DMs aren't available while managing another account + }[] = isManagedAccount ? [] : [shareToChatAction] if (isShareableTrack) { result.push(shareToXAction) @@ -313,7 +319,8 @@ export const ShareDrawer = NiceModal.create(() => { handleShareToSnapchat, handleShareToInstagramStory, isShareableTrack, - content?.type + content?.type, + isManagedAccount ]) // Trigger share action on mount with new content diff --git a/packages/mobile/src/harmony-native/icons.tsx b/packages/mobile/src/harmony-native/icons.tsx index 847a3635d5d..a286a3bc6cb 100644 --- a/packages/mobile/src/harmony-native/icons.tsx +++ b/packages/mobile/src/harmony-native/icons.tsx @@ -83,6 +83,7 @@ export { default as IconCloudUpload } from '@audius/harmony/src/assets/icons/Clo export { default as IconPencil } from '@audius/harmony/src/assets/icons/Pencil.svg' export { default as IconUser } from '@audius/harmony/src/assets/icons/User.svg' export { default as IconUserArrowRotate } from '@audius/harmony/src/assets/icons/UserArrowRotate.svg' +export { default as IconShieldUser } from '@audius/harmony/src/assets/icons/ShieldUser.svg' export { default as IconArrowRotate } from '@audius/harmony/src/assets/icons/ArrowRotate.svg' export { default as IconPlay } from '@audius/harmony/src/assets/icons/Play.svg' export { default as IconUserFollow } from '@audius/harmony/src/assets/icons/UserFollow.svg' diff --git a/packages/mobile/src/hooks/useNotificationNavigation.ts b/packages/mobile/src/hooks/useNotificationNavigation.ts index daec6a5edfc..b19ac32480d 100644 --- a/packages/mobile/src/hooks/useNotificationNavigation.ts +++ b/packages/mobile/src/hooks/useNotificationNavigation.ts @@ -1,8 +1,10 @@ import { useCallback, useMemo } from 'react' import { getEventQueryKey } from '@audius/common/api' +import { useFeatureFlag } from '@audius/common/hooks' import { ShareSource } from '@audius/common/models' import type { ID } from '@audius/common/models' +import { FeatureFlags } from '@audius/common/services' import type { AnnouncementNotification, UserSubscriptionNotification, @@ -82,6 +84,9 @@ export const useNotificationNavigation = () => { const linkTo = useLinkTo() const queryClient = useQueryClient() const dispatch = useDispatch() + const { isEnabled: isManagerModeEnabled } = useFeatureFlag( + FeatureFlags.MOBILE_MANAGER_MODE + ) // Pushes flagged with shareCard open the stat card over the track const openShareCard = useCallback( @@ -134,9 +139,15 @@ export const useNotificationNavigation = () => { | ApproveManagerRequestNotification | RequestManagerNotification ) => { - navigation.navigate('Profile', { id: notification.userId }) + if (!isManagerModeEnabled) { + navigation.navigate('Profile', { id: notification.userId }) + } else if (notification.type === NotificationType.RequestManager) { + navigation.navigate('AccountsYouManageScreen') + } else { + navigation.navigate('AccountsManagingYouScreen') + } }, - [navigation] + [navigation, isManagerModeEnabled] ) const entityHandler = useCallback( diff --git a/packages/mobile/src/screens/app-drawer-screen/left-nav-drawer/AccountDetails.tsx b/packages/mobile/src/screens/app-drawer-screen/left-nav-drawer/AccountDetails.tsx index c5e77211abf..f05b2443fe5 100644 --- a/packages/mobile/src/screens/app-drawer-screen/left-nav-drawer/AccountDetails.tsx +++ b/packages/mobile/src/screens/app-drawer-screen/left-nav-drawer/AccountDetails.tsx @@ -3,13 +3,25 @@ import { useCallback, useContext } from 'react' import { useCurrentAccountUser } from '@audius/common/api' import { TouchableOpacity } from 'react-native' -import { Flex, Text } from '@audius/harmony-native' +import { + Flex, + IconButton, + IconUserArrowRotate, + Text +} from '@audius/harmony-native' import { ProfilePicture } from 'app/components/core' +import { useManagerModeAccounts } from 'app/components/manager-mode/useManagerModeAccounts' import { UserLink } from 'app/components/user-link' +import { useDrawer } from 'app/hooks/useDrawer' import { useAppTabNavigation } from 'app/screens/app-screen' import { AppDrawerContext } from '../AppDrawerContext' +const messages = { + managedAccount: 'Managed Account', + switchAccount: 'Switch Account' +} + export const AccountDetails = () => { const { drawerHelpers } = useContext(AppDrawerContext) const { data: accountUser } = useCurrentAccountUser({ @@ -19,6 +31,8 @@ export const AccountDetails = () => { }) }) const { handle, user_id } = accountUser ?? {} + const { canSwitch, isManagedAccount } = useManagerModeAccounts() + const { onOpen: openAccountSwitcher } = useDrawer('AccountSwitcher') const navigation = useAppTabNavigation() @@ -29,24 +43,49 @@ export const AccountDetails = () => { } }, [handle, navigation, drawerHelpers]) + const handlePressSwitch = useCallback(() => { + drawerHelpers.closeDrawer() + openAccountSwitcher() + }, [drawerHelpers, openAccountSwitcher]) + return ( - - - - - {user_id ? ( - - ) : null} - - @{handle} + + {isManagedAccount ? ( + + + {messages.managedAccount} + ) : null} + + + + + + {user_id ? ( + + ) : null} + + @{handle} + + + + + {canSwitch ? ( + + ) : null} - + ) } diff --git a/packages/mobile/src/screens/app-drawer-screen/left-nav-drawer/MessagesNavItem.tsx b/packages/mobile/src/screens/app-drawer-screen/left-nav-drawer/MessagesNavItem.tsx index 086d218c966..bb8bedcc327 100644 --- a/packages/mobile/src/screens/app-drawer-screen/left-nav-drawer/MessagesNavItem.tsx +++ b/packages/mobile/src/screens/app-drawer-screen/left-nav-drawer/MessagesNavItem.tsx @@ -1,5 +1,6 @@ import React from 'react' +import { useIsManagedAccount } from '@audius/common/hooks' import { chatSelectors } from '@audius/common/store' import { useSelector } from 'react-redux' @@ -12,6 +13,10 @@ const { getHasUnreadMessages, getUnreadMessagesCount } = chatSelectors export const MessagesNavItem = () => { const hasUnreadMessages = useSelector(getHasUnreadMessages) const unreadMessagesCount = useSelector(getUnreadMessagesCount) + const isManagedAccount = useIsManagedAccount() + + // DMs aren't available while managing another account + if (isManagedAccount) return null return ( ({ root: { height: spacing(8) + 2, width: spacing(8) + 2 + }, + managedRing: { + borderWidth: 2, + borderRadius: spacing(8), + padding: 1 } })) @@ -28,6 +35,8 @@ export const AccountPictureHeader = (props: AccountPictureHeaderProps) => { const drawerProgress = useDrawerProgress() as SharedValue const styles = useStyles() const { data: accountId } = useCurrentUserId() + const isManagedAccount = useIsManagedAccount() + const { color } = useTheme() const animatedStyle = useAnimatedStyle(() => ({ opacity: interpolate(drawerProgress.value, [0, 1], [1, 0]) @@ -35,7 +44,14 @@ export const AccountPictureHeader = (props: AccountPictureHeaderProps) => { return ( - + { name='NotificationSettingsScreen' getComponent={() => NotificationSettingsScreen} /> + AccountsManagingYouScreen} + /> + AccountsYouManageScreen} + /> + FindAccountManagerScreen} + /> { ) } -export const ChatListScreen = () => { +const ChatListScreenContent = () => { const styles = useStyles() const palette = useThemePalette() const dispatch = useDispatch() @@ -276,3 +277,8 @@ export const ChatListScreen = () => { ) } + +// DMs act as the signed-in wallet, so they're off while managing an account +export const ChatListScreen = withManagedAccountRestriction( + ChatListScreenContent +) diff --git a/packages/mobile/src/screens/chat-screen/ChatScreen.tsx b/packages/mobile/src/screens/chat-screen/ChatScreen.tsx index 88f6a7f825e..0d5ab35b34b 100644 --- a/packages/mobile/src/screens/chat-screen/ChatScreen.tsx +++ b/packages/mobile/src/screens/chat-screen/ChatScreen.tsx @@ -39,6 +39,7 @@ import { ScreenContent } from 'app/components/core' import LoadingSpinner from 'app/components/loading-spinner' +import { withManagedAccountRestriction } from 'app/components/manager-mode/ManagedAccountUnavailableScreen' import { PLAY_BAR_HEIGHT } from 'app/components/now-playing-drawer' import { light } from 'app/haptics' import { useRoute } from 'app/hooks/useRoute' @@ -221,7 +222,7 @@ const useGetLatestMessages = ( }) } -export const ChatScreen = () => { +const ChatScreenContent = () => { const styles = useStyles() const palette = useThemePalette() const dispatch = useDispatch() @@ -664,3 +665,6 @@ export const ChatScreen = () => { ) } + +// DMs act as the signed-in wallet, so they're off while managing an account +export const ChatScreen = withManagedAccountRestriction(ChatScreenContent) diff --git a/packages/mobile/src/screens/chat-screen/ChatUserListScreen.tsx b/packages/mobile/src/screens/chat-screen/ChatUserListScreen.tsx index 65c433ccc19..7f39c0ac5e0 100644 --- a/packages/mobile/src/screens/chat-screen/ChatUserListScreen.tsx +++ b/packages/mobile/src/screens/chat-screen/ChatUserListScreen.tsx @@ -25,6 +25,7 @@ import { HeaderShadow } from 'app/components/core' import LoadingSpinner from 'app/components/loading-spinner' +import { withManagedAccountRestriction } from 'app/components/manager-mode/ManagedAccountUnavailableScreen' import { useRoute } from 'app/hooks/useRoute' import { makeStyles } from 'app/styles' @@ -202,7 +203,7 @@ const useQueryUserList = (query: string) => { return { hasMore: !!hasNextPage, loadMore, status, userIds: userIds ?? [] } } -export const ChatUserListScreen = () => { +const ChatUserListScreenContent = () => { const styles = useStyles() const [query, setQuery] = useState('') const [inputValue, setInputValue] = useState('') @@ -319,3 +320,8 @@ export const ChatUserListScreen = () => { ) } + +// DMs act as the signed-in wallet, so they're off while managing an account +export const ChatUserListScreen = withManagedAccountRestriction( + ChatUserListScreenContent +) diff --git a/packages/mobile/src/screens/profile-screen/ProfileInfo.tsx b/packages/mobile/src/screens/profile-screen/ProfileInfo.tsx index 58514f0cffe..bf701d30889 100644 --- a/packages/mobile/src/screens/profile-screen/ProfileInfo.tsx +++ b/packages/mobile/src/screens/profile-screen/ProfileInfo.tsx @@ -1,6 +1,7 @@ import { useEffect } from 'react' import { useCurrentAccountUser, useUserByParams } from '@audius/common/api' +import { useIsManagedAccount } from '@audius/common/hooks' import { FollowSource, statusIsNotFinalized } from '@audius/common/models' import { chatActions, @@ -54,6 +55,8 @@ export const ProfileInfo = (props: ProfileInfoProps) => { const profileUserId = user_id const { canCreateChat } = useCanCreateChat(profileUserId) const chatPermissionStatus = useSelector(getChatPermissionsStatus) + // DMs and token transfers aren't available while managing another account + const isManagedAccount = useIsManagedAccount() useEffect(() => { dispatch(fetchBlockees()) @@ -80,7 +83,7 @@ export const ProfileInfo = (props: ProfileInfoProps) => { ) : ( <> - {!isOwner ? ( + {!isOwner && !isManagedAccount ? ( statusIsNotFinalized(chatPermissionStatus) ? ( ) : canCreateChat ? ( @@ -89,7 +92,7 @@ export const ProfileInfo = (props: ProfileInfoProps) => { ) ) : null} - {!isOwner ? : null} + {!isOwner && !isManagedAccount ? : null} {does_current_user_follow ? : null} { useResetNotificationBadgeCount() useActivityPing() + useManagedAccountAccessCheck() // Reset the player on first mount so a crash doesn't leak previous playback // state into the next session. PAY-1412. diff --git a/packages/mobile/src/screens/settings-screen/AccountSettingsScreen.tsx b/packages/mobile/src/screens/settings-screen/AccountSettingsScreen.tsx index 2001cf55ae6..96f18cea6f7 100644 --- a/packages/mobile/src/screens/settings-screen/AccountSettingsScreen.tsx +++ b/packages/mobile/src/screens/settings-screen/AccountSettingsScreen.tsx @@ -6,6 +6,7 @@ import { useQueryContext, useResendRecoveryEmail } from '@audius/common/api' +import { useIsManagedAccount } from '@audius/common/hooks' import { modalsActions, useTierAndVerifiedForUser } from '@audius/common/store' import { css } from '@emotion/native' import { pick } from 'lodash' @@ -105,6 +106,9 @@ export const AccountSettingsScreen = () => { } = accountData ?? {} const navigation = useNavigation() + // Email, password and verification belong to the signed-in user, so hide + // them while managing another account + const isManagedAccount = useIsManagedAccount() const { isVerified } = useTierAndVerifiedForUser(accountUserId) // Calculate badge size based on h2 font size (h2 is fontSize.medium = 16, so badge is 14) @@ -182,47 +186,51 @@ export const AccountSettingsScreen = () => { @{accountHandle} - - - - - + {!isManagedAccount ? ( + <> + + + + + + + ) : null} { buttonTitle={messages.signOutButtonTitle} onPress={openSignOutDrawer} /> - + {!isManagedAccount ? ( + + ) : null} diff --git a/packages/mobile/src/screens/settings-screen/SettingsScreen.tsx b/packages/mobile/src/screens/settings-screen/SettingsScreen.tsx index 9879908545e..7d3b3af3cc9 100644 --- a/packages/mobile/src/screens/settings-screen/SettingsScreen.tsx +++ b/packages/mobile/src/screens/settings-screen/SettingsScreen.tsx @@ -1,5 +1,7 @@ import { useCallback } from 'react' +import { useFeatureFlag, useIsManagedAccount } from '@audius/common/hooks' +import { FeatureFlags } from '@audius/common/services' import { Image } from 'react-native' import { @@ -9,7 +11,9 @@ import { IconNotificationOn, IconSettings, IconUserUnfollow, - IconListeningHistory + IconListeningHistory, + IconShieldUser, + IconUserArrowRotate } from '@audius/harmony-native' import audiusLogoHorizontal from 'app/assets/images/Horizontal-Logo-Full-Color.png' import { Screen, ScreenContent, ScrollView } from 'app/components/core' @@ -26,6 +30,7 @@ import { SettingsRowLabel } from './SettingRowLabel' import { SettingsDivider } from './SettingsDivider' import { SettingsRow } from './SettingsRow' import { SettingsRowDescription } from './SettingsRowDescription' +import { managerModeMessages } from './manager-mode/messages' const messages = { title: 'Settings', @@ -58,6 +63,10 @@ export const SettingsScreen = () => { const navigation = useNavigation() useShowManagerModeNotAvailable() + const isManagedAccount = useIsManagedAccount() + const { isEnabled: isManagerModeEnabled } = useFeatureFlag( + FeatureFlags.MOBILE_MANAGER_MODE + ) const handlePressInbox = useCallback(() => { navigation.push('InboxSettingsScreen') @@ -83,6 +92,14 @@ export const SettingsScreen = () => { navigation.push('ListeningHistoryScreen') }, [navigation]) + const handlePressAccountsManagingYou = useCallback(() => { + navigation.push('AccountsManagingYouScreen') + }, [navigation]) + + const handlePressAccountsYouManage = useCallback(() => { + navigation.push('AccountsYouManageScreen') + }, [navigation]) + return ( { - - - - {messages.inboxDescription} - - + {/* DMs are disabled while managing another account */} + {!isManagedAccount ? ( + + + + {messages.inboxDescription} + + + ) : null} { icon={IconListeningHistory} /> + {isManagerModeEnabled ? ( + <> + + + + {managerModeMessages.accountsManagingYouDescription} + + + + + + {managerModeMessages.accountsYouManageDescription} + + + + ) : null} diff --git a/packages/mobile/src/screens/settings-screen/index.ts b/packages/mobile/src/screens/settings-screen/index.ts index 844b40c3181..34e2903f0c3 100644 --- a/packages/mobile/src/screens/settings-screen/index.ts +++ b/packages/mobile/src/screens/settings-screen/index.ts @@ -6,3 +6,6 @@ export * from './InboxSettingsScreen' export * from './CommentSettingsScreen' export * from './NotificationSettingsScreen' export * from './ListeningHistoryScreen' +export * from './manager-mode/AccountsManagingYouScreen' +export * from './manager-mode/AccountsYouManageScreen' +export * from './manager-mode/FindAccountManagerScreen' diff --git a/packages/mobile/src/screens/settings-screen/manager-mode/AccountsManagingYouScreen.tsx b/packages/mobile/src/screens/settings-screen/manager-mode/AccountsManagingYouScreen.tsx new file mode 100644 index 00000000000..b832dc20428 --- /dev/null +++ b/packages/mobile/src/screens/settings-screen/manager-mode/AccountsManagingYouScreen.tsx @@ -0,0 +1,179 @@ +import { useCallback, useMemo, useState } from 'react' + +import { + useCurrentUserId, + useManagers, + useRemoveManager +} from '@audius/common/api' +import type { UserManagerMetadata } from '@audius/common/models' +import { Portal } from '@gorhom/portal' +import { TouchableOpacity } from 'react-native' + +import { + Button, + Divider, + Flex, + IconPlus, + IconShieldUser, + IconTrash, + IconUser, + Text +} from '@audius/harmony-native' +import type { ActionDrawerRow } from 'app/components/action-drawer' +import { ActionDrawerWithoutRedux } from 'app/components/action-drawer' +import { Screen, ScreenContent, ScrollView } from 'app/components/core' +import { ConfirmationDrawerWithoutRedux } from 'app/components/drawers' +import LoadingSpinner from 'app/components/loading-spinner' +import { AccountRow } from 'app/components/manager-mode/AccountRow' +import { useNavigation } from 'app/hooks/useNavigation' + +import type { ProfileTabScreenParamList } from '../../app-screen/ProfileTabScreen' + +import { managerModeMessages } from './messages' +import { useRemoveManagerWithSwitchBack } from './useRemoveManagerWithSwitchBack' + +const messages = { + accountManagers: 'Account Managers', + noManagers: 'You haven’t added any managers to your account.', + invite: 'Invite', + removeManager: 'Remove Manager', + cancelInvite: 'Cancel Invite', + removeManagerTitle: 'Remove Manager?', + removeManagerConfirmation: + 'Are you sure you want to remove this manager from your account?' +} + +export const AccountsManagingYouScreen = () => { + const navigation = useNavigation() + const { data: userId } = useCurrentUserId() + const { + data: managers, + isPending, + isSuccess + } = useManagers(userId, { + // Grants can change from web or another device + staleTime: 0 + }) + const { mutate: cancelInvite } = useRemoveManager() + const { remove, isPending: isRemoving } = useRemoveManagerWithSwitchBack() + + const [selected, setSelected] = useState(null) + const [isActionsOpen, setIsActionsOpen] = useState(false) + const [isConfirmOpen, setIsConfirmOpen] = useState(false) + + const handlePressManager = useCallback((data: UserManagerMetadata) => { + setSelected(data) + setIsActionsOpen(true) + }, []) + + const actionRows = useMemo((): ActionDrawerRow[] => { + if (!selected || !userId) return [] + const isInvitePending = selected.grant.is_approved == null + return [ + { + text: managerModeMessages.visitProfile, + icon: , + callback: () => + navigation.push('Profile', { id: selected.manager.user_id }) + }, + { + text: isInvitePending ? messages.cancelInvite : messages.removeManager, + icon: , + isDestructive: true, + callback: () => { + if (isInvitePending) { + cancelInvite({ userId, managerUserId: selected.manager.user_id }) + } else { + setIsConfirmOpen(true) + } + } + } + ] + }, [selected, userId, navigation, cancelInvite]) + + const handleConfirmRemove = useCallback(() => { + if (!selected || !userId) return + remove({ userId, managerUserId: selected.manager.user_id }) + }, [remove, selected, userId]) + + return ( + + + + + + {managerModeMessages.accountManagersExplanation} + + + + + {messages.accountManagers} + + + + {isPending || isRemoving ? ( + + + + ) : null} + {isSuccess && !managers?.length ? ( + + {messages.noManagers} + + ) : null} + + {managers?.map((data) => ( + handlePressManager(data)} + > + + + {data.grant.is_approved == null ? ( + + {managerModeMessages.invitePending} + + ) : null} + + + + ))} + + + + + {/* Above the tab bar */} + + setIsActionsOpen(false)} + /> + setIsConfirmOpen(false)} + variant='destructive' + messages={{ + header: messages.removeManagerTitle, + description: messages.removeManagerConfirmation, + confirm: managerModeMessages.remove, + cancel: managerModeMessages.cancel + }} + onConfirm={handleConfirmRemove} + /> + + + ) +} diff --git a/packages/mobile/src/screens/settings-screen/manager-mode/AccountsYouManageScreen.tsx b/packages/mobile/src/screens/settings-screen/manager-mode/AccountsYouManageScreen.tsx new file mode 100644 index 00000000000..80407c48f39 --- /dev/null +++ b/packages/mobile/src/screens/settings-screen/manager-mode/AccountsYouManageScreen.tsx @@ -0,0 +1,254 @@ +import { useCallback, useMemo, useState } from 'react' + +import { + useApproveManagedAccount, + useCurrentUserId, + useManagedAccounts, + useRejectManagedAccount +} from '@audius/common/api' +import { useAccountSwitcher, useIsManagedAccount } from '@audius/common/hooks' +import type { ManagedUserMetadata } from '@audius/common/models' +import { Portal } from '@gorhom/portal' +import { TouchableOpacity } from 'react-native' + +import { + Button, + Divider, + Flex, + IconCheck, + IconCloseAlt, + IconTrash, + IconUser, + IconUserArrowRotate, + Text +} from '@audius/harmony-native' +import type { ActionDrawerRow } from 'app/components/action-drawer' +import { ActionDrawerWithoutRedux } from 'app/components/action-drawer' +import { Screen, ScreenContent, ScrollView } from 'app/components/core' +import { ConfirmationDrawerWithoutRedux } from 'app/components/drawers' +import LoadingSpinner from 'app/components/loading-spinner' +import { AccountRow } from 'app/components/manager-mode/AccountRow' +import { useNavigation } from 'app/hooks/useNavigation' +import { useToast } from 'app/hooks/useToast' + +import type { ProfileTabScreenParamList } from '../../app-screen/ProfileTabScreen' + +import { managerModeMessages } from './messages' +import { useRemoveManagerWithSwitchBack } from './useRemoveManagerWithSwitchBack' + +const messages = { + takeControl: + 'Take control of your managed accounts by making changes to their profiles, preferences, and content.', + noAccounts: 'You don’t manage any accounts.', + stopManaging: 'Stop Managing', + stopManagingTitle: 'Stop Managing?', + stopManagingConfirmation: + 'Are you sure you want to stop managing this account?', + switchToUser: 'Switch to User', + accept: 'Accept', + decline: 'Decline', + invitationAccepted: 'Invitation Accepted!', + invitationRejected: 'Invitation Declined' +} + +type PendingInviteActionsProps = { + user: ManagedUserMetadata['user'] +} + +const PendingInviteActions = ({ user }: PendingInviteActionsProps) => { + const { data: currentUserId } = useCurrentUserId() + const { toast } = useToast() + const { mutate: approve, isPending: isApproving } = useApproveManagedAccount() + const { mutate: reject, isPending: isRejecting } = useRejectManagedAccount() + + const onError = useCallback(() => { + toast({ content: managerModeMessages.somethingWentWrong, type: 'error' }) + }, [toast]) + + const handleApprove = useCallback(() => { + if (!currentUserId) return + approve( + { userId: currentUserId, grantorUser: user }, + { + onSuccess: () => toast({ content: messages.invitationAccepted }), + onError + } + ) + }, [approve, currentUserId, onError, toast, user]) + + const handleReject = useCallback(() => { + if (!currentUserId) return + reject( + { userId: currentUserId, grantorUser: user }, + { + onSuccess: () => toast({ content: messages.invitationRejected }), + onError + } + ) + }, [reject, currentUserId, onError, toast, user]) + + return ( + + + + + ) +} + +export const AccountsYouManageScreen = () => { + const navigation = useNavigation() + const { data: userId } = useCurrentUserId() + const isManagedAccount = useIsManagedAccount() + const { switchAccount } = useAccountSwitcher() + const { + data: managedAccounts, + isPending, + isSuccess + } = useManagedAccounts(userId, { + // Grants can change from web or another device + staleTime: 0 + }) + const { remove, isPending: isRemoving } = useRemoveManagerWithSwitchBack() + + const [selected, setSelected] = useState(null) + const [isActionsOpen, setIsActionsOpen] = useState(false) + const [isConfirmOpen, setIsConfirmOpen] = useState(false) + + const handlePressAccount = useCallback((data: ManagedUserMetadata) => { + setSelected(data) + setIsActionsOpen(true) + }, []) + + const actionRows = useMemo((): ActionDrawerRow[] => { + if (!selected) return [] + const rows: ActionDrawerRow[] = [] + // Switching from inside a managed account isn't allowed: the grant + // belongs to the signed-in user, not the account being managed. + if (!isManagedAccount) { + rows.push({ + text: messages.switchToUser, + icon: , + callback: () => switchAccount(selected.user) + }) + } + rows.push( + { + text: managerModeMessages.visitProfile, + icon: , + callback: () => + navigation.push('Profile', { id: selected.user.user_id }) + }, + { + text: messages.stopManaging, + icon: , + isDestructive: true, + callback: () => setIsConfirmOpen(true) + } + ) + return rows + }, [selected, isManagedAccount, switchAccount, navigation]) + + const handleConfirmStop = useCallback(() => { + if (!selected || !userId) return + remove({ userId: selected.user.user_id, managerUserId: userId }) + }, [remove, selected, userId]) + + return ( + + + + + + {messages.takeControl} + + + {isPending || isRemoving ? ( + + + + ) : null} + {isSuccess && !managedAccounts?.length ? ( + + {messages.noAccounts} + + ) : null} + + {managedAccounts?.map((data) => + data.grant.is_approved == null ? ( + + + + {managerModeMessages.invitePending} + + + + + ) : ( + handlePressAccount(data)} + > + + + + + ) + )} + + + + + {/* Above the tab bar */} + + setIsActionsOpen(false)} + /> + setIsConfirmOpen(false)} + variant='destructive' + messages={{ + header: messages.stopManagingTitle, + description: messages.stopManagingConfirmation, + confirm: managerModeMessages.remove, + cancel: managerModeMessages.cancel + }} + onConfirm={handleConfirmStop} + /> + + + ) +} diff --git a/packages/mobile/src/screens/settings-screen/manager-mode/FindAccountManagerScreen.tsx b/packages/mobile/src/screens/settings-screen/manager-mode/FindAccountManagerScreen.tsx new file mode 100644 index 00000000000..36cdc878919 --- /dev/null +++ b/packages/mobile/src/screens/settings-screen/manager-mode/FindAccountManagerScreen.tsx @@ -0,0 +1,187 @@ +import { useCallback, useMemo, useState } from 'react' + +import { + useCurrentUserId, + useManagers, + useRequestAddManager, + useSearchUsersModal +} from '@audius/common/api' +import type { User } from '@audius/common/models' +import { Portal } from '@gorhom/portal' +import { FlatList, Keyboard, TouchableOpacity } from 'react-native' +import { useDebounce } from 'react-use' + +import { + Flex, + Hint, + IconError, + IconSearch, + IconShieldUser, + Text, + TextInput, + TextInputSize +} from '@audius/harmony-native' +import { Screen, ScreenContent } from 'app/components/core' +import { ConfirmationDrawerWithoutRedux } from 'app/components/drawers' +import LoadingSpinner from 'app/components/loading-spinner' +import { AccountRow } from 'app/components/manager-mode/AccountRow' +import { useNavigation } from 'app/hooks/useNavigation' +import { useToast } from 'app/hooks/useToast' + +import { managerModeMessages } from './messages' + +const DEBOUNCE_MS = 250 + +const messages = { + title: 'Find Account Manager', + description: 'Invite a manager to join your account.', + searchUsers: 'Search Users', + searchForManager: 'Search for Account Manager', + accessWarning: 'Only grant manager access to people you trust.', + confirmTitle: 'Confirm New Manager', + confirmDescription: + 'Are you sure you want to authorize the following user to manage your Audius account?', + confirm: 'Confirm', + inviteSent: 'Invite sent' +} + +export const FindAccountManagerScreen = () => { + const navigation = useNavigation() + const { toast } = useToast() + const [inputValue, setInputValue] = useState('') + const [query, setQuery] = useState('') + useDebounce(() => setQuery(inputValue.trim()), DEBOUNCE_MS, [inputValue]) + + const { data: userId } = useCurrentUserId() + const { data: managers } = useManagers(userId) + const { users, isLoading } = useSearchUsersModal({ query }) + const { mutate: requestAddManager, isPending: isRequesting } = + useRequestAddManager() + + const [selectedUser, setSelectedUser] = useState(null) + const [isConfirmOpen, setIsConfirmOpen] = useState(false) + + const handleSelectUser = useCallback((user: User) => { + Keyboard.dismiss() + setSelectedUser(user) + setIsConfirmOpen(true) + }, []) + + const results = useMemo(() => { + const excluded = new Set(managers?.map((m) => m.manager.user_id)) + if (userId) excluded.add(userId) + return (users ?? []).filter((user) => !excluded.has(user.user_id)) + }, [managers, userId, users]) + + const handleConfirm = useCallback(() => { + if (!userId || !selectedUser) return + requestAddManager( + { userId, managerUser: selectedUser }, + { + onSuccess: () => { + toast({ content: messages.inviteSent }) + navigation.goBack() + }, + onError: () => { + toast({ + content: managerModeMessages.somethingWentWrong, + type: 'error' + }) + } + } + ) + }, [navigation, requestAddManager, selectedUser, toast, userId]) + + return ( + + + + + {isRequesting ? ( + + + + ) : null} + + {messages.description}{' '} + {managerModeMessages.accountManagersExplanation} + + + + String(user.user_id)} + keyboardShouldPersistTaps='handled' + renderItem={({ item: user }) => ( + handleSelectUser(user)} + > + + + + + )} + ListEmptyComponent={ + query && isLoading ? ( + + + + ) : ( + + + + {messages.searchForManager} + + + {messages.accessWarning} + + + ) + } + /> + + + {/* Above the tab bar */} + + setIsConfirmOpen(false)} + variant='affirmative' + messages={{ + header: messages.confirmTitle, + description: messages.confirmDescription, + confirm: messages.confirm, + cancel: managerModeMessages.cancel + }} + onConfirm={handleConfirm} + > + {selectedUser ? ( + + + + + + {managerModeMessages.accountManagersExplanation} + + + ) : null} + + + + ) +} diff --git a/packages/mobile/src/screens/settings-screen/manager-mode/messages.ts b/packages/mobile/src/screens/settings-screen/manager-mode/messages.ts new file mode 100644 index 00000000000..94122d34df5 --- /dev/null +++ b/packages/mobile/src/screens/settings-screen/manager-mode/messages.ts @@ -0,0 +1,15 @@ +export const managerModeMessages = { + accountManagersExplanation: + 'Account managers can modify your profile, change account preferences, and upload, edit, and delete content on your behalf.', + somethingWentWrong: 'Something went wrong. Please try again.', + accountsManagingYou: 'Accounts Managing You', + accountsManagingYouDescription: + 'Invite other Audius users to make changes to your account on your behalf.', + accountsYouManage: 'Accounts You Manage', + accountsYouManageDescription: + 'Review the accounts you’re authorized to manage.', + invitePending: 'Invite Pending', + visitProfile: 'Visit Profile', + cancel: 'Cancel', + remove: 'Remove' +} diff --git a/packages/mobile/src/screens/settings-screen/manager-mode/useRemoveManagerWithSwitchBack.ts b/packages/mobile/src/screens/settings-screen/manager-mode/useRemoveManagerWithSwitchBack.ts new file mode 100644 index 00000000000..e1a3c900089 --- /dev/null +++ b/packages/mobile/src/screens/settings-screen/manager-mode/useRemoveManagerWithSwitchBack.ts @@ -0,0 +1,52 @@ +import { useCallback } from 'react' + +import { + useCurrentUserId, + useCurrentWeb3Account, + useRemoveManager +} from '@audius/common/api' +import { useAccountSwitcher } from '@audius/common/hooks' + +import { useToast } from 'app/hooks/useToast' + +import { managerModeMessages } from './messages' + +/** + * Removes a manager grant. If the signed-in user just stopped managing the + * account they're using, switches back to their own account. + */ +export const useRemoveManagerWithSwitchBack = () => { + const { mutate: removeManager, isPending } = useRemoveManager() + const { data: currentWeb3User } = useCurrentWeb3Account() + const { data: currentUserId } = useCurrentUserId() + const { switchToWeb3User } = useAccountSwitcher() + const { toast } = useToast() + + const remove = useCallback( + ( + params: { userId: number; managerUserId: number }, + onSuccess?: () => void + ) => { + removeManager(params, { + onSuccess: () => { + onSuccess?.() + if ( + currentUserId === params.userId && + currentWeb3User?.user_id === params.managerUserId + ) { + switchToWeb3User() + } + }, + onError: () => { + toast({ + content: managerModeMessages.somethingWentWrong, + type: 'error' + }) + } + }) + }, + [currentUserId, currentWeb3User, removeManager, switchToWeb3User, toast] + ) + + return { remove, isPending } +} diff --git a/packages/mobile/src/screens/wallet-screen/components/YourCoins.tsx b/packages/mobile/src/screens/wallet-screen/components/YourCoins.tsx index 73c4a5db20b..acde51c9fb5 100644 --- a/packages/mobile/src/screens/wallet-screen/components/YourCoins.tsx +++ b/packages/mobile/src/screens/wallet-screen/components/YourCoins.tsx @@ -20,6 +20,7 @@ import { Paper, Text } from '@audius/harmony-native' +import { useManagedAccountGuard } from 'app/components/manager-mode/useManagedAccountNotAllowed' import { useNavigation } from 'app/hooks/useNavigation' import { AudioCoinCard } from './AudioCoinCard' @@ -44,6 +45,7 @@ const YourCoinsSkeleton = () => { const YourCoinsHeader = () => { const navigation = useNavigation() + const guard = useManagedAccountGuard() const handleBuySell = useCallback(() => { navigation.navigate('BuySell', { @@ -64,7 +66,7 @@ const YourCoinsHeader = () => { {messages.assets} - diff --git a/packages/mobile/src/store/drawers/slice.ts b/packages/mobile/src/store/drawers/slice.ts index d721b8a9752..793ae3436e4 100644 --- a/packages/mobile/src/store/drawers/slice.ts +++ b/packages/mobile/src/store/drawers/slice.ts @@ -30,6 +30,7 @@ export type Drawer = | 'OfflineListening' | 'Welcome' | 'ManagerMode' + | 'AccountSwitcher' | 'Comment' | 'EditTrackFormOverflowMenu' | 'PickWinners' @@ -77,6 +78,7 @@ export type DrawerData = { InboxUnavailable: { userId: number; shouldOpenChat: boolean } Welcome: undefined ManagerMode: undefined + AccountSwitcher: undefined Comment: { userId: ID; entityId: ID; isEntityOwner: boolean; artistId: ID } EditTrackFormOverflowMenu: undefined PickWinners: undefined @@ -120,6 +122,7 @@ const initialState: DrawersState = { DeleteChat: false, Welcome: false, ManagerMode: false, + AccountSwitcher: false, Comment: false, EditTrackFormOverflowMenu: false, PickWinners: false,