From a3676d223a6f8e09e75a8755493a4e9bf8b6c402 Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Sat, 7 Nov 2026 14:58:11 -0800 Subject: [PATCH] feat(mobile): manager mode behind mobile_manager_mode flag Managers can switch into accounts they manage from the left nav, and artists can invite and remove managers from Settings. Account switching restarts the app through a new optional AppContext.reloadApp; web keeps its page reload. While managing an account the app shows a Managed Account strip and an accented header avatar, and blocks DMs, purchases, buy/sell, sending coins, and the email, password, verification and delete account rows. The flag only gates the entry points, so switching back and the restrictions keep working if it is turned off. Co-Authored-By: Claude Opus 5.5 --- packages/common/src/context/appContext.ts | 2 + .../common/src/hooks/useAccountSwitcher.ts | 10 +- .../services/remote-config/feature-flags.ts | 11 +- .../mobile/src/app/AppContextProvider.tsx | 4 +- packages/mobile/src/app/Drawers.tsx | 2 + .../useShowManagerModeNotAvailable.tsx | 22 +- .../components/manager-mode/AccountRow.tsx | 42 +++ .../manager-mode/AccountSwitcherDrawer.tsx | 118 ++++++++ .../ManagedAccountUnavailableScreen.tsx | 58 ++++ .../useManagedAccountAccessCheck.ts | 33 +++ .../useManagedAccountNotAllowed.ts | 51 ++++ .../manager-mode/useManagerModeAccounts.ts | 47 ++++ .../PremiumContentPurchaseDrawer.tsx | 3 + .../components/share-drawer/ShareDrawer.tsx | 13 +- packages/mobile/src/harmony-native/icons.tsx | 1 + .../src/hooks/useNotificationNavigation.ts | 15 +- .../left-nav-drawer/AccountDetails.tsx | 71 +++-- .../left-nav-drawer/MessagesNavItem.tsx | 5 + .../app-screen/AccountPictureHeader.tsx | 18 +- .../src/screens/app-screen/AppTabScreen.tsx | 20 +- .../screens/chat-screen/ChatListScreen.tsx | 8 +- .../src/screens/chat-screen/ChatScreen.tsx | 6 +- .../chat-screen/ChatUserListScreen.tsx | 8 +- .../screens/profile-screen/ProfileInfo.tsx | 7 +- .../src/screens/root-screen/RootScreen.tsx | 2 + .../settings-screen/AccountSettingsScreen.tsx | 106 ++++---- .../settings-screen/SettingsScreen.tsx | 56 +++- .../src/screens/settings-screen/index.ts | 3 + .../AccountsManagingYouScreen.tsx | 179 ++++++++++++ .../manager-mode/AccountsYouManageScreen.tsx | 254 ++++++++++++++++++ .../manager-mode/FindAccountManagerScreen.tsx | 187 +++++++++++++ .../settings-screen/manager-mode/messages.ts | 15 ++ .../useRemoveManagerWithSwitchBack.ts | 52 ++++ .../wallet-screen/components/YourCoins.tsx | 4 +- packages/mobile/src/store/drawers/slice.ts | 3 + 35 files changed, 1343 insertions(+), 93 deletions(-) create mode 100644 packages/mobile/src/components/manager-mode/AccountRow.tsx create mode 100644 packages/mobile/src/components/manager-mode/AccountSwitcherDrawer.tsx create mode 100644 packages/mobile/src/components/manager-mode/ManagedAccountUnavailableScreen.tsx create mode 100644 packages/mobile/src/components/manager-mode/useManagedAccountAccessCheck.ts create mode 100644 packages/mobile/src/components/manager-mode/useManagedAccountNotAllowed.ts create mode 100644 packages/mobile/src/components/manager-mode/useManagerModeAccounts.ts create mode 100644 packages/mobile/src/screens/settings-screen/manager-mode/AccountsManagingYouScreen.tsx create mode 100644 packages/mobile/src/screens/settings-screen/manager-mode/AccountsYouManageScreen.tsx create mode 100644 packages/mobile/src/screens/settings-screen/manager-mode/FindAccountManagerScreen.tsx create mode 100644 packages/mobile/src/screens/settings-screen/manager-mode/messages.ts create mode 100644 packages/mobile/src/screens/settings-screen/manager-mode/useRemoveManagerWithSwitchBack.ts 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,