diff --git a/packages/common/src/api/index.ts b/packages/common/src/api/index.ts index 31285caa36e..3569a870cde 100644 --- a/packages/common/src/api/index.ts +++ b/packages/common/src/api/index.ts @@ -41,5 +41,6 @@ export * from './tan-query/useFavoriteTrack' export * from './tan-query/useUnfavoriteTrack' export * from './tan-query/useToggleFavoriteTrack' export * from './tan-query/useDeleteTrack' +export * from './tan-query/useUpdateTrack' export * from './tan-query/types' export * from './tan-query/useFollowers' diff --git a/packages/common/src/api/tan-query/queryKeys.ts b/packages/common/src/api/tan-query/queryKeys.ts index 5ad8c1c7c25..bcf5b41c28e 100644 --- a/packages/common/src/api/tan-query/queryKeys.ts +++ b/packages/common/src/api/tan-query/queryKeys.ts @@ -75,5 +75,6 @@ export const QUERY_KEYS = { trendingIds: 'trendingIds', trendingPlaylists: 'trendingPlaylists', trendingUnderground: 'trendingUnderground', - trackPageLineup: 'trackPageLineup' + trackPageLineup: 'trackPageLineup', + userbank: 'userbank' } as const diff --git a/packages/common/src/api/tan-query/useGetOrCreateUserBank.ts b/packages/common/src/api/tan-query/useGetOrCreateUserBank.ts new file mode 100644 index 00000000000..2cbb4f3bf7e --- /dev/null +++ b/packages/common/src/api/tan-query/useGetOrCreateUserBank.ts @@ -0,0 +1,51 @@ +import { PublicKey } from '@solana/web3.js' +import { useQuery } from '@tanstack/react-query' + +import { useAudiusQueryContext } from '~/audius-query' + +import { QUERY_KEYS } from './queryKeys' +import { QueryOptions } from './types' +// Define the allowed mint types based on the SDK +type MintType = 'USDC' | 'wAUDIO' + +export type GetOrCreateUserBankParams = { + wallet: string + mint?: MintType +} + +const getUserBankQueryKey = (wallet: string) => [QUERY_KEYS.userbank, wallet] + +/** + * Hook to get or create a user bank for a wallet + * This is a migration of the getOrCreateUserBank saga function to a TanStack Query mutation + */ +export const useGetOrCreateUserBank = ( + wallet: string, + mint: MintType = 'USDC', + options?: QueryOptions +) => { + const { audiusSdk } = useAudiusQueryContext() + + return useQuery({ + queryKey: getUserBankQueryKey(wallet), + queryFn: async (): Promise => { + try { + const sdk = await audiusSdk() + + // Call the SDK's method to get or create a user bank + const result = + await sdk.services.claimableTokensClient.getOrCreateUserBank({ + ethWallet: wallet, + mint + }) + + return result.userBank + } catch (error) { + console.error('Error getting or creating user bank', error) + throw error + } + }, + ...options, + enabled: options?.enabled !== false && !!wallet + }) +} diff --git a/packages/common/src/api/tan-query/useUpdateStems.ts b/packages/common/src/api/tan-query/useUpdateStems.ts new file mode 100644 index 00000000000..dffabd6f4d3 --- /dev/null +++ b/packages/common/src/api/tan-query/useUpdateStems.ts @@ -0,0 +1,81 @@ +import { useMutation } from '@tanstack/react-query' +import { useDispatch, useStore } from 'react-redux' + +import { ID } from '~/models/Identifiers' +import { StemUpload, StemUploadWithFile } from '~/models/Stems' +import { Stem } from '~/models/Track' +import { deleteTrack } from '~/store/cache/tracks/actions' +import { CommonState } from '~/store/commonStore' +import { stemsUploadActions } from '~/store/stems-upload' +import { getCurrentUploads } from '~/store/stems-upload/selectors' +import { uuid } from '~/utils/uid' + +const { startStemUploads } = stemsUploadActions + +export type UpdateStemsArgs = { + trackId: ID + existingStems: Stem[] | undefined + updatedStems: Array | undefined +} + +/** + * Hook for updating stems on a track, handling both additions and removals + */ +export const useUpdateStems = () => { + const dispatch = useDispatch() + const store = useStore() + + return useMutation({ + mutationFn: async ({ + trackId, + existingStems, + updatedStems + }: UpdateStemsArgs) => { + const inProgressStemUploads = getCurrentUploads( + store.getState() as CommonState, + trackId + ) + + // Find stems that need to be added (new stems) + const addedStems = updatedStems?.filter((stem) => { + return !existingStems?.find((existingStem) => { + return existingStem.track_id === stem.metadata.track_id + }) + }) + + // Filter to only stems that have files to upload + const addedStemsWithFiles = addedStems?.filter( + (stem) => 'file' in stem + ) as StemUploadWithFile[] + + // Start uploads for new stems with files + if (addedStemsWithFiles.length > 0) { + dispatch( + startStemUploads({ + parentId: trackId, + uploads: addedStemsWithFiles, + batchUID: uuid() + }) + ) + } + + // Find stems that need to be removed + const removedStems = existingStems + ?.filter((existingStem) => { + return !updatedStems?.find( + (stem) => stem.metadata.track_id === existingStem.track_id + ) + }) + .filter((existingStem) => { + return !inProgressStemUploads.find( + (upload) => upload.metadata.track_id === existingStem.track_id + ) + }) + + // Delete removed stems + for (const stem of removedStems ?? []) { + dispatch(deleteTrack(stem.track_id)) + } + } + }) +} diff --git a/packages/common/src/api/tan-query/useUpdateTrack.ts b/packages/common/src/api/tan-query/useUpdateTrack.ts index 40888e59dbc..ff162bad05e 100644 --- a/packages/common/src/api/tan-query/useUpdateTrack.ts +++ b/packages/common/src/api/tan-query/useUpdateTrack.ts @@ -1,31 +1,33 @@ -import { Id, Track } from '@audius/sdk' +import { Id } from '@audius/sdk' import { useMutation, useQueryClient } from '@tanstack/react-query' -import { useDispatch, useStore } from 'react-redux' +import { useDispatch } from 'react-redux' import { fileToSdk, trackMetadataForUploadToSdk } from '~/adapters/track' import { useAudiusQueryContext } from '~/audius-query' -import { UserTrackMetadata } from '~/models' +import { useFeatureFlag } from '~/hooks/useFeatureFlag' import { Feature } from '~/models/ErrorReporting' import { ID } from '~/models/Identifiers' -import { CommonState } from '~/store/commonStore' -import { stemsUploadSelectors } from '~/store/stems-upload' +import { Track, UserTrackMetadata } from '~/models/Track' +import { FeatureFlags } from '~/services/remote-config' +import { trackRemixEvent } from '~/store/cache/tracks/actions' import { TrackMetadataForUpload } from '~/store/upload' +import { squashNewLines } from '~/utils/formatUtil' +import { formatMusicalKey } from '~/utils/musicalKeys' import { QUERY_KEYS } from './queryKeys' -import { useDeleteTrack } from './useDeleteTrack' +import { useCurrentUser } from './useCurrentUser' +import { useGetOrCreateUserBank } from './useGetOrCreateUserBank' import { getTrackQueryKey } from './useTrack' -import { handleStemUpdates } from './utils/handleStemUpdates' +import { useUpdateStems } from './useUpdateStems' +import { addPremiumMetadata } from './utils/addPremiumMetadata' import { primeTrackData } from './utils/primeTrackData' -const { getCurrentUploads } = stemsUploadSelectors - type MutationContext = { - previousTrack: UserTrackMetadata | undefined + previousTrack: Track | undefined } export type UpdateTrackParams = { - trackId: ID - userId: ID - metadata: Partial + trackId: ID | null | undefined + metadata: Partial coverArtFile?: File } @@ -33,51 +35,137 @@ export const useUpdateTrack = () => { const { audiusSdk, reportToSentry } = useAudiusQueryContext() const queryClient = useQueryClient() const dispatch = useDispatch() - const store = useStore() - const { mutate: deleteTrack } = useDeleteTrack() + const { data: currentUser } = useCurrentUser() + const { mutate: updateStems } = useUpdateStems() + const { data: userBank } = useGetOrCreateUserBank( + currentUser?.erc_wallet ?? currentUser?.wallet + ) + const { isEnabled: isUsdcPurchaseEnabled } = useFeatureFlag( + FeatureFlags.USDC_PURCHASES + ) return useMutation({ mutationFn: async ({ trackId, - userId, metadata, coverArtFile }: UpdateTrackParams) => { + if (!trackId) { + throw new Error('Track ID is required') + } + const sdk = await audiusSdk() const previousMetadata = queryClient.getQueryData([ QUERY_KEYS.track, trackId ]) + + // Create a mutable copy of metadata + let updatedMetadata = { + ...metadata + } as Track & TrackMetadataForUpload + + // Apply squashNewLines to description + if (updatedMetadata.description) { + updatedMetadata.description = squashNewLines( + updatedMetadata.description + ) + } + + // Handle publishing state for unlisted to listed transition + if ( + previousMetadata && + previousMetadata.is_unlisted && + updatedMetadata.is_unlisted === false + ) { + // Mark track as publishing + updatedMetadata._is_publishing = true + } + + // Add premium metadata if needed + const userWallet = currentUser?.erc_wallet || currentUser?.wallet + if (userWallet && isUsdcPurchaseEnabled) { + updatedMetadata = await addPremiumMetadata( + updatedMetadata, + userWallet, + userBank + ) + } + + // Format musical key + if (updatedMetadata.musical_key) { + updatedMetadata.musical_key = + formatMusicalKey(updatedMetadata.musical_key) ?? null + + // Set custom musical key flag if it's changed or was already set + if (previousMetadata) { + updatedMetadata.is_custom_musical_key = + previousMetadata.is_custom_musical_key || + (!!updatedMetadata.musical_key && + updatedMetadata.musical_key !== previousMetadata.musical_key) + } + } + + // Format BPM + if (updatedMetadata.bpm) { + updatedMetadata.bpm = Number(updatedMetadata.bpm) + + // Set custom BPM flag if it's changed or was already set + if (previousMetadata) { + updatedMetadata.is_custom_bpm = + previousMetadata.is_custom_bpm || + (!!updatedMetadata.bpm && + updatedMetadata.bpm !== previousMetadata.bpm) + } + } + const sdkMetadata = trackMetadataForUploadToSdk( - metadata as TrackMetadataForUpload + updatedMetadata as TrackMetadataForUpload ) + // Determine if we need to generate a preview - check if preview_start has changed OR track file has changed + const generatePreview = + previousMetadata && + ((updatedMetadata.preview_start_seconds !== null && + updatedMetadata.preview_start_seconds !== undefined && + previousMetadata.preview_start_seconds !== + updatedMetadata.preview_start_seconds) || + previousMetadata.track_cid !== updatedMetadata.track_cid) + const response = await sdk.tracks.updateTrack({ coverArtFile: coverArtFile ? fileToSdk(coverArtFile, 'cover_art') : undefined, trackId: Id.parse(trackId), - userId: Id.parse(userId), - metadata: sdkMetadata + userId: Id.parse(currentUser?.user_id), + metadata: sdkMetadata, + generatePreview: generatePreview || undefined }) - // TODO: migrate stem uploads to use tan-query - const inProgressStemUploads = getCurrentUploads( - store.getState() as CommonState, - trackId - ) - if (previousMetadata) { - handleStemUpdates( - metadata, - previousMetadata as any, - inProgressStemUploads, - (trackId: ID) => deleteTrack({ trackId }), - dispatch - ) + // Upload/delete stems + updateStems({ + trackId, + existingStems: previousMetadata?._stems, + updatedStems: updatedMetadata.stems + }) + + // Handle remix event tracking + const isNewRemix = + updatedMetadata?.remix_of?.tracks?.[0]?.parent_track_id && + previousMetadata?.remix_of?.tracks?.[0]?.parent_track_id !== + updatedMetadata?.remix_of?.tracks?.[0]?.parent_track_id + if (isNewRemix) { + dispatch(trackRemixEvent(updatedMetadata)) } - // TODO: remixOf event tracking, see trackNewRemixEvent saga + // If the track was unlisted and is now public, mark it as no longer publishing + if ( + previousMetadata?.is_unlisted && + updatedMetadata.is_unlisted === false + ) { + updatedMetadata._is_publishing = false + } return response }, @@ -93,10 +181,42 @@ export const useUpdateTrack = () => { trackId ]) + // Apply the same transformations for optimistic updates + const optimisticMetadata = { ...metadata } as Partial + + // Apply squashNewLines to description + if (optimisticMetadata.description) { + optimisticMetadata.description = squashNewLines( + optimisticMetadata.description + ) + } + + // Handle publishing state for unlisted to listed transition + if ( + previousTrack && + previousTrack.is_unlisted && + optimisticMetadata.is_unlisted === false + ) { + optimisticMetadata._is_publishing = true + } + + // Format musical key + if (optimisticMetadata.musical_key) { + optimisticMetadata.musical_key = + formatMusicalKey(optimisticMetadata.musical_key) ?? null + } + + // Format BPM + if (optimisticMetadata.bpm) { + optimisticMetadata.bpm = Number(optimisticMetadata.bpm) + } + // Optimistically update track if (previousTrack) { primeTrackData({ - tracks: [{ ...previousTrack, ...metadata }] as UserTrackMetadata[], + tracks: [ + { ...previousTrack, ...optimisticMetadata } + ] as UserTrackMetadata[], queryClient, dispatch, forceReplace: true @@ -106,11 +226,7 @@ export const useUpdateTrack = () => { // Return context with the previous track and metadata return { previousTrack } }, - onError: ( - error, - { trackId, userId, metadata }, - context?: MutationContext - ) => { + onError: (error, { trackId, metadata }, context?: MutationContext) => { // If the mutation fails, roll back track data if (context?.previousTrack) { queryClient.setQueryData( @@ -140,16 +256,12 @@ export const useUpdateTrack = () => { error, additionalInfo: { trackId, - userId, + userId: currentUser?.user_id, metadata }, feature: Feature.Edit, name: 'Edit Track' }) - }, - onSettled: (_, __, { trackId }) => { - // Always refetch after error or success to ensure cache is in sync with server - // queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.track, trackId] }) } }) } diff --git a/packages/common/src/api/tan-query/utils/addPremiumMetadata.ts b/packages/common/src/api/tan-query/utils/addPremiumMetadata.ts new file mode 100644 index 00000000000..69edf4a6a8a --- /dev/null +++ b/packages/common/src/api/tan-query/utils/addPremiumMetadata.ts @@ -0,0 +1,75 @@ +import BN from 'bn.js' + +import { + isContentUSDCPurchaseGated, + USDCPurchaseConditions +} from '~/models/Track' +import type { Track } from '~/models/Track' + +// Constants from the saga implementation +const BN_USDC_CENT_WEI = new BN(10).pow(new BN(16)) + +/** + * Adds premium metadata to a track's stream and download conditions + * This is a non-saga version of the addPremiumMetadata function from the saga implementation + */ +export const addPremiumMetadata = async >( + track: T, + wallet?: string, + userBank?: string +): Promise => { + if (!wallet) return track + + const updatedTrack = { ...track } + + // download_conditions could be set separately from stream_conditions, so we check for them first + if (isContentUSDCPurchaseGated(updatedTrack.download_conditions)) { + updatedTrack.download_conditions = await getUSDCMetadata( + updatedTrack.download_conditions, + wallet, + userBank + ) + } + + if (isContentUSDCPurchaseGated(updatedTrack.stream_conditions)) { + updatedTrack.stream_conditions = await getUSDCMetadata( + updatedTrack.stream_conditions, + wallet, + userBank + ) + + // If stream_conditions are set, download_conditions should always match + updatedTrack.download_conditions = await getUSDCMetadata( + updatedTrack.stream_conditions, + wallet, + userBank + ) + } + + return updatedTrack +} + +/** + * Gets USDC metadata for a track's conditions + * This is a non-saga version of the getUSDCMetadata function from the saga implementation + */ +const getUSDCMetadata = async ( + conditions: USDCPurchaseConditions, + wallet: string, + userBank?: string +): Promise => { + const ownerUserbank = userBank ?? wallet + const priceCents = conditions.usdc_purchase.price + const priceWei = new BN(priceCents).mul(BN_USDC_CENT_WEI).toNumber() + + const conditionsWithMetadata: USDCPurchaseConditions = { + usdc_purchase: { + price: priceCents, + splits: { + [ownerUserbank?.toString() ?? '']: priceWei + } + } + } + + return conditionsWithMetadata +} diff --git a/packages/common/src/store/cache/tracks/actions.ts b/packages/common/src/store/cache/tracks/actions.ts index 4c5c9d95f7f..e0c94ffb621 100644 --- a/packages/common/src/store/cache/tracks/actions.ts +++ b/packages/common/src/store/cache/tracks/actions.ts @@ -1,4 +1,4 @@ -import { TrackMetadataForUpload } from '~/store' +import { TrackMetadataForUpload, TrackWithRemix } from '~/store' import { Nullable } from '~/utils' import { ID } from '../../../models' @@ -18,6 +18,8 @@ export const SET_TRACK_COMMENT_COUNT = 'CACHE/TRACKS/SET_TRACK_COMMENT_COUNT' export const SET_PINNED_COMMENT_ID = 'CACHE/TRACKS/SET_PINNED_COMMENT_ID' +export const TRACK_REMIX_EVENT = 'CACHE/TRACKS/TRACK_REMIX_EVENT' + export function editTrack(trackId: ID, formFields: TrackMetadataForUpload) { return { type: EDIT_TRACK, trackId, formFields } } @@ -62,3 +64,8 @@ export const setPinnedCommentId = (trackId: ID, commentId: Nullable) => ({ trackId, commentId }) + +export const trackRemixEvent = (track: TrackWithRemix) => ({ + type: TRACK_REMIX_EVENT, + track +}) diff --git a/packages/common/src/store/cache/tracks/types.ts b/packages/common/src/store/cache/tracks/types.ts index 71563f73855..bd5df6e90b3 100644 --- a/packages/common/src/store/cache/tracks/types.ts +++ b/packages/common/src/store/cache/tracks/types.ts @@ -1,5 +1,9 @@ -import { Cache, ID, Track } from '../../../models' +import { Cache, ID, Remix, Track } from '../../../models' export interface TracksCacheState extends Cache { permalinks: { [permalink: string]: ID } } + +export type TrackWithRemix = Pick & { + remix_of: { tracks: Pick[] } | null +} diff --git a/packages/mobile/src/screens/edit-track-screen/EditTrackModalScreen.tsx b/packages/mobile/src/screens/edit-track-screen/EditTrackModalScreen.tsx index 842edb51f68..ba4ee903d16 100644 --- a/packages/mobile/src/screens/edit-track-screen/EditTrackModalScreen.tsx +++ b/packages/mobile/src/screens/edit-track-screen/EditTrackModalScreen.tsx @@ -1,9 +1,11 @@ import { useCallback } from 'react' +import { useUpdateTrack } from '@audius/common/api' +import type { Track } from '@audius/common/models' import { SquareSizes } from '@audius/common/models' import type { TrackMetadataForUpload } from '@audius/common/store' -import { cacheTracksActions, cacheTracksSelectors } from '@audius/common/store' -import { useDispatch, useSelector } from 'react-redux' +import { cacheTracksSelectors } from '@audius/common/store' +import { useSelector } from 'react-redux' import { ModalScreen } from 'app/components/core' import { useTrackImage } from 'app/components/image/TrackImage' @@ -16,7 +18,6 @@ import { UploadFileContextProvider } from '../upload-screen/screens/UploadFileCo import { EditTrackScreen } from './EditTrackScreen' const { getTrack } = cacheTracksSelectors -const { editTrack } = cacheTracksActions const messages = { title: 'Edit Track', @@ -26,8 +27,8 @@ const messages = { export const EditTrackModalScreen = () => { const { params } = useRoute<'EditTrack'>() const { id } = params - const dispatch = useDispatch() const navigation = useNavigation() + const { mutate: updateTrack } = useUpdateTrack() const track = useSelector((state) => getTrack(state, { id })) @@ -38,10 +39,13 @@ export const EditTrackModalScreen = () => { const handleSubmit = useCallback( (metadata: TrackMetadataForUpload) => { - dispatch(editTrack(id, metadata)) + updateTrack({ + trackId: track?.track_id, + metadata: metadata as Partial + }) navigation.navigate('Track', { id }) }, - [dispatch, id, navigation] + [id, navigation, track?.track_id, updateTrack] ) if (!track) return null diff --git a/packages/web/src/common/store/cache/tracks/sagas.ts b/packages/web/src/common/store/cache/tracks/sagas.ts index ccbf543bf10..2e86498f751 100644 --- a/packages/web/src/common/store/cache/tracks/sagas.ts +++ b/packages/web/src/common/store/cache/tracks/sagas.ts @@ -9,7 +9,6 @@ import { Track, Collection, ID, - Remix, StemUploadWithFile } from '@audius/common/models' import { @@ -24,7 +23,8 @@ import { TrackMetadataForUpload, stemsUploadActions, stemsUploadSelectors, - getSDK + getSDK, + TrackWithRemix } from '@audius/common/store' import { formatMusicalKey, @@ -80,8 +80,10 @@ function* watchAdd() { ) } -type TrackWithRemix = Pick & { - remix_of: { tracks: Pick[] } | null +function* trackNewRemixEventAsync( + action: ReturnType +) { + yield* call(trackNewRemixEvent, action.track) } export function* trackNewRemixEvent(track: TrackWithRemix) { @@ -299,8 +301,12 @@ function* watchEditTrack() { yield* takeEvery(trackActions.EDIT_TRACK, editTrackAsync) } +function* watchTrackRemixEvent() { + yield* takeEvery(trackActions.TRACK_REMIX_EVENT, trackNewRemixEventAsync) +} + const sagas = () => { - return [watchAdd, watchEditTrack] + return [watchAdd, watchEditTrack, watchTrackRemixEvent] } export default sagas diff --git a/packages/web/src/pages/edit-page/EditTrackPage.tsx b/packages/web/src/pages/edit-page/EditTrackPage.tsx index 475a2aa1cb6..d82cb659409 100644 --- a/packages/web/src/pages/edit-page/EditTrackPage.tsx +++ b/packages/web/src/pages/edit-page/EditTrackPage.tsx @@ -1,6 +1,10 @@ import { createContext } from 'react' -import { useGetCurrentUserId, useGetTrackByPermalink } from '@audius/common/api' +import { + useGetCurrentUserId, + useGetTrackByPermalink, + useUpdateTrack +} from '@audius/common/api' import { SquareSizes, Status, @@ -11,7 +15,6 @@ import { } from '@audius/common/models' import { TrackMetadataForUpload, - cacheTracksActions, cacheTracksSelectors, uploadActions, useReplaceTrackConfirmationModal, @@ -32,7 +35,6 @@ import { useRequiresAccount } from 'hooks/useRequiresAccount' import { useTrackCoverArt } from 'hooks/useTrackCoverArt' import { push } from 'utils/navigation' -const { editTrack } = cacheTracksActions const { getStems } = cacheTracksSelectors const { updateTrackAudio } = uploadActions @@ -55,6 +57,7 @@ export const EditTrackPage = (props: EditPageProps) => { const { onOpen: openReplaceTrackConfirmation } = useReplaceTrackConfirmationModal() const { onOpen: openReplaceTrackProgress } = useReplaceTrackProgressModal() + const { mutate: updateTrack } = useUpdateTrack() const { data: currentUserId } = useGetCurrentUserId({}) const permalink = `/${handle}/${slug}` @@ -96,7 +99,10 @@ export const EditTrackPage = (props: EditPageProps) => { } }) } else { - dispatch(editTrack(trackId, metadata)) + updateTrack({ + trackId, + metadata: metadata as Partial + }) dispatch(push(metadata.permalink)) } } diff --git a/packages/web/src/pages/track-page/TrackPageProvider.tsx b/packages/web/src/pages/track-page/TrackPageProvider.tsx index 11f429cd2bc..4a4ee106282 100644 --- a/packages/web/src/pages/track-page/TrackPageProvider.tsx +++ b/packages/web/src/pages/track-page/TrackPageProvider.tsx @@ -14,7 +14,6 @@ import { } from '@audius/common/models' import { accountSelectors, - cacheTracksActions as cacheTrackActions, lineupSelectors, trackPageLineupActions, trackPageActions, @@ -548,8 +547,6 @@ function mapDispatchToProps(dispatch: Dispatch) { dispatch( socialTracksActions.undoRepostTrack(trackId, RepostSource.TRACK_PAGE) ), - editTrack: (trackId: ID, formFields: any) => - dispatch(cacheTrackActions.editTrack(trackId, formFields)), onFollow: (userId: ID) => dispatch(socialUsersActions.followUser(userId, FollowSource.TRACK_PAGE)), onUnfollow: (userId: ID) =>