mirror of
https://github.com/oceanprotocol/market.git
synced 2024-12-02 05:57:29 +01:00
move asset selection to compute helper
This commit is contained in:
parent
24ad553765
commit
cd2a4da8c5
@ -45,7 +45,7 @@ export function generateBaseQuery(
|
||||
filter: [
|
||||
...(baseQueryParams.filters || []),
|
||||
getFilterTerm('chainId', baseQueryParams.chainIds),
|
||||
getFilterTerm('_index', 'aquarius'),
|
||||
// getFilterTerm('_index', 'aquarius'),
|
||||
...(baseQueryParams.ignorePurgatory
|
||||
? []
|
||||
: [getFilterTerm('purgatory.state', false)])
|
||||
|
@ -15,14 +15,22 @@ import {
|
||||
ComputeAlgorithm,
|
||||
Service,
|
||||
LoggerInstance,
|
||||
ProviderInstance
|
||||
ProviderInstance,
|
||||
PublisherTrustedAlgorithm
|
||||
} from '@oceanprotocol/lib'
|
||||
import { CancelToken } from 'axios'
|
||||
import { gql } from 'urql'
|
||||
import { queryMetadata, getFilterTerm, generateBaseQuery } from './aquarius'
|
||||
import {
|
||||
queryMetadata,
|
||||
getFilterTerm,
|
||||
generateBaseQuery,
|
||||
transformDDOToAssetSelection
|
||||
} from './aquarius'
|
||||
import { fetchDataForMultipleChains } from './subgraph'
|
||||
import { getServiceById } from './ddo'
|
||||
import { getServiceById, getServiceByName } from './ddo'
|
||||
import { getOceanConfig } from './ocean'
|
||||
import { SortTermOptions } from 'src/@types/aquarius/SearchQuery'
|
||||
import { AssetSelectionAsset } from '@shared/FormFields/AssetSelection'
|
||||
|
||||
const getComputeOrders = gql`
|
||||
query ComputeOrders($user: String!) {
|
||||
@ -115,6 +123,74 @@ export async function isOrderable(
|
||||
}
|
||||
}
|
||||
|
||||
function getQuerryString(
|
||||
trustedAlgorithmList: PublisherTrustedAlgorithm[],
|
||||
chainId?: number
|
||||
): SearchQuery {
|
||||
const algorithmDidList = trustedAlgorithmList.map((x) => x.did)
|
||||
|
||||
const baseParams = {
|
||||
chainIds: [chainId],
|
||||
sort: { sortBy: SortTermOptions.Created },
|
||||
filters: [
|
||||
getFilterTerm('metadata.type', 'algorithm'),
|
||||
getFilterTerm('id', algorithmDidList)
|
||||
]
|
||||
} as BaseQueryParams
|
||||
|
||||
const query = generateBaseQuery(baseParams)
|
||||
return query
|
||||
}
|
||||
|
||||
export async function getAlgorithmsForAsset(
|
||||
asset: Asset,
|
||||
token: CancelToken
|
||||
): Promise<Asset[]> {
|
||||
const computeService: Service = getServiceByName(asset, 'compute')
|
||||
let algorithms: Asset[]
|
||||
if (
|
||||
!computeService.compute ||
|
||||
!computeService.compute.publisherTrustedAlgorithms ||
|
||||
computeService.compute.publisherTrustedAlgorithms.length === 0
|
||||
) {
|
||||
algorithms = []
|
||||
} else {
|
||||
const gueryResults = await queryMetadata(
|
||||
getQuerryString(
|
||||
computeService.compute.publisherTrustedAlgorithms,
|
||||
asset.chainId
|
||||
),
|
||||
token
|
||||
)
|
||||
algorithms = gueryResults?.results
|
||||
}
|
||||
return algorithms
|
||||
}
|
||||
|
||||
export async function getAlgorithmAssetSelectionList(
|
||||
asset: Asset,
|
||||
algorithms: Asset[],
|
||||
token: CancelToken
|
||||
): Promise<AssetSelectionAsset[]> {
|
||||
const computeService: Service = getServiceByName(asset, 'compute')
|
||||
let algorithmSelectionList: AssetSelectionAsset[]
|
||||
if (
|
||||
!computeService.compute ||
|
||||
!computeService.compute.publisherTrustedAlgorithms ||
|
||||
computeService.compute.publisherTrustedAlgorithms.length === 0
|
||||
) {
|
||||
algorithmSelectionList = []
|
||||
} else {
|
||||
algorithmSelectionList = await transformDDOToAssetSelection(
|
||||
computeService?.serviceEndpoint,
|
||||
algorithms,
|
||||
[],
|
||||
token
|
||||
)
|
||||
}
|
||||
return algorithmSelectionList
|
||||
}
|
||||
|
||||
function getServiceEndpoints(data: TokenOrder[], assets: Asset[]): string[] {
|
||||
// const serviceEndpoints: string[] = []
|
||||
|
||||
|
@ -17,7 +17,8 @@ import {
|
||||
approve,
|
||||
TokenInOutMarket,
|
||||
AmountsInMaxFee,
|
||||
AmountsOutMaxFee
|
||||
AmountsOutMaxFee,
|
||||
Service
|
||||
} from '@oceanprotocol/lib'
|
||||
import { toast } from 'react-toastify'
|
||||
import Price from '@shared/Price'
|
||||
@ -39,8 +40,14 @@ import FormStartComputeDataset from './FormComputeDataset'
|
||||
import styles from './index.module.css'
|
||||
import SuccessConfetti from '@shared/SuccessConfetti'
|
||||
import { getServiceByName, secondsToString } from '@utils/ddo'
|
||||
import { isOrderable } from '@utils/compute'
|
||||
import { AssetSelectionAsset } from '@shared/FormFields/AssetSelection'
|
||||
import {
|
||||
isOrderable,
|
||||
getAlgorithmAssetSelectionList,
|
||||
getAlgorithmsForAsset
|
||||
} from '@utils/compute'
|
||||
import AssetSelection, {
|
||||
AssetSelectionAsset
|
||||
} from '@shared/FormFields/AssetSelection'
|
||||
import AlgorithmDatasetsListForCompute from './AlgorithmDatasetsListForCompute'
|
||||
import { getPreviousOrders } from '@utils/subgraph'
|
||||
import AssetActionHistoryTable from '../AssetActionHistoryTable'
|
||||
@ -52,10 +59,9 @@ import { Decimal } from 'decimal.js'
|
||||
import { TransactionReceipt } from 'web3-core'
|
||||
import { useAbortController } from '@hooks/useAbortController'
|
||||
import { getAccessDetails } from '@utils/accessDetailsAndPricing'
|
||||
import AssetDetails from '../..'
|
||||
|
||||
export default function Compute({
|
||||
ddo,
|
||||
asset,
|
||||
accessDetails,
|
||||
dtBalance,
|
||||
file,
|
||||
@ -63,7 +69,7 @@ export default function Compute({
|
||||
isConsumable,
|
||||
consumableFeedback
|
||||
}: {
|
||||
ddo: Asset
|
||||
asset: Asset
|
||||
accessDetails: AccessDetails
|
||||
dtBalance: string
|
||||
file: FileMetadata
|
||||
@ -94,9 +100,9 @@ export default function Compute({
|
||||
useState<string>()
|
||||
const [datasetTimeout, setDatasetTimeout] = useState<string>()
|
||||
const [algorithmTimeout, setAlgorithmTimeout] = useState<string>()
|
||||
const newCancelToken = useCancelToken()
|
||||
const hasDatatoken = Number(dtBalance) >= 1
|
||||
const isMounted = useIsMounted()
|
||||
const newCancelToken = useCancelToken()
|
||||
const [isConsumablePrice, setIsConsumablePrice] = useState(true)
|
||||
const [isAlgoConsumablePrice, setIsAlgoConsumablePrice] = useState(true)
|
||||
const isComputeButtonDisabled =
|
||||
@ -107,13 +113,13 @@ export default function Compute({
|
||||
!hasAlgoAssetDatatoken &&
|
||||
!isAlgoConsumablePrice)
|
||||
|
||||
const { timeout } = ddo?.services[0]
|
||||
const { timeout } = asset?.services[0]
|
||||
|
||||
async function checkPreviousOrders(ddo: DDO) {
|
||||
const { type } = ddo.metadata
|
||||
async function checkPreviousOrders(asset: DDO) {
|
||||
const { type } = asset.metadata
|
||||
|
||||
const orderId = await getPreviousOrders(
|
||||
ddo.services[0].datatokenAddress?.toLowerCase(),
|
||||
asset.services[0].datatokenAddress?.toLowerCase(),
|
||||
accountId?.toLowerCase(),
|
||||
timeout.toString()
|
||||
)
|
||||
@ -138,60 +144,11 @@ export default function Compute({
|
||||
setHasAlgoAssetDatatoken(Number(AssetDtBalance) >= 1)
|
||||
}
|
||||
|
||||
function getQuerryString(
|
||||
trustedAlgorithmList: PublisherTrustedAlgorithm[],
|
||||
chainId?: number
|
||||
): SearchQuery {
|
||||
const algorithmDidList = trustedAlgorithmList.map((x) => x.did)
|
||||
|
||||
const baseParams = {
|
||||
chainIds: [chainId],
|
||||
sort: { sortBy: SortTermOptions.Created },
|
||||
filters: [
|
||||
getFilterTerm('service.attributes.main.type', 'algorithm'),
|
||||
getFilterTerm('id', algorithmDidList)
|
||||
]
|
||||
} as BaseQueryParams
|
||||
|
||||
const query = generateBaseQuery(baseParams)
|
||||
return query
|
||||
}
|
||||
|
||||
async function getAlgorithmList(): Promise<AssetSelectionAsset[]> {
|
||||
const source = axios.CancelToken.source()
|
||||
const computeService = ddo.services[0]
|
||||
let algorithmSelectionList: AssetSelectionAsset[]
|
||||
if (
|
||||
!computeService.compute ||
|
||||
!computeService.compute.publisherTrustedAlgorithms ||
|
||||
computeService.compute.publisherTrustedAlgorithms.length === 0
|
||||
) {
|
||||
algorithmSelectionList = []
|
||||
} else {
|
||||
const gueryResults = await queryMetadata(
|
||||
getQuerryString(
|
||||
computeService.compute.publisherTrustedAlgorithms,
|
||||
ddo.chainId
|
||||
),
|
||||
source.token
|
||||
)
|
||||
setDdoAlgorithmList(gueryResults.results)
|
||||
|
||||
algorithmSelectionList = await transformDDOToAssetSelection(
|
||||
computeService?.serviceEndpoint,
|
||||
gueryResults.results,
|
||||
[],
|
||||
newCancelToken()
|
||||
)
|
||||
}
|
||||
return algorithmSelectionList
|
||||
}
|
||||
|
||||
const initMetadata = useCallback(async (ddo: Asset): Promise<void> => {
|
||||
if (!ddo) return
|
||||
const initMetadata = useCallback(async (asset: Asset): Promise<void> => {
|
||||
if (!asset) return
|
||||
const accessDetails = await getAccessDetails(
|
||||
ddo.chainId,
|
||||
ddo.services[0].datatokenAddress
|
||||
asset.chainId,
|
||||
asset.services[0].datatokenAddress
|
||||
)
|
||||
setAlgorithmConsumeDetails(accessDetails)
|
||||
}, [])
|
||||
@ -213,23 +170,31 @@ export default function Compute({
|
||||
// }, [ddo])
|
||||
|
||||
useEffect(() => {
|
||||
if (!ddo) return
|
||||
getAlgorithmList().then((algorithms) => {
|
||||
setAlgorithmList(algorithms)
|
||||
if (!asset) return
|
||||
|
||||
getAlgorithmsForAsset(asset, newCancelToken()).then((algorithmsAssets) => {
|
||||
setDdoAlgorithmList(algorithmsAssets)
|
||||
getAlgorithmAssetSelectionList(
|
||||
asset,
|
||||
algorithmsAssets,
|
||||
newCancelToken()
|
||||
).then((algorithmSelectionList) => {
|
||||
setAlgorithmList(algorithmSelectionList)
|
||||
})
|
||||
}, [ddo])
|
||||
})
|
||||
}, [asset])
|
||||
|
||||
useEffect(() => {
|
||||
if (!accountId) return
|
||||
checkPreviousOrders(ddo)
|
||||
}, [ddo, accountId])
|
||||
checkPreviousOrders(asset)
|
||||
}, [asset, accountId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedAlgorithmAsset) return
|
||||
|
||||
initMetadata(selectedAlgorithmAsset)
|
||||
|
||||
const { timeout } = ddo.services[0]
|
||||
const { timeout } = asset.services[0]
|
||||
|
||||
// setAlgorithmTimeout(secondsToString(timeout))
|
||||
|
||||
@ -248,7 +213,7 @@ export default function Compute({
|
||||
}
|
||||
}
|
||||
checkAssetDTBalance(selectedAlgorithmAsset)
|
||||
}, [ddo, selectedAlgorithmAsset, accountId, hasPreviousAlgorithmOrder])
|
||||
}, [asset, selectedAlgorithmAsset, accountId, hasPreviousAlgorithmOrder])
|
||||
|
||||
// Output errors in toast UI
|
||||
useEffect(() => {
|
||||
@ -257,13 +222,13 @@ export default function Compute({
|
||||
toast.error(newError)
|
||||
}, [error, pricingError])
|
||||
|
||||
async function startJob(algorithmId: string) {
|
||||
async function startJob(algorithmId: string): Promise<string> {
|
||||
try {
|
||||
setIsJobStarting(true)
|
||||
setIsPublished(false) // would be nice to rename this
|
||||
setError(undefined)
|
||||
|
||||
const computeService = getServiceByName(ddo, 'compute')
|
||||
const computeService = getServiceByName(asset, 'compute')
|
||||
const serviceAlgo = getServiceByName(selectedAlgorithmAsset, 'access')
|
||||
? getServiceByName(selectedAlgorithmAsset, 'access')
|
||||
: getServiceByName(selectedAlgorithmAsset, 'compute')
|
||||
@ -274,7 +239,7 @@ export default function Compute({
|
||||
}
|
||||
|
||||
const allowed = await isOrderable(
|
||||
ddo,
|
||||
asset,
|
||||
computeService.id,
|
||||
computeAlgorithm,
|
||||
selectedAlgorithmAsset
|
||||
@ -296,11 +261,11 @@ export default function Compute({
|
||||
if (!hasPreviousDatasetOrder) {
|
||||
// going to move/replace part of this logic when the use consume hook will be ready
|
||||
const initializeData = await ProviderInstance.initialize(
|
||||
ddo.id,
|
||||
ddo.services[0].id,
|
||||
asset.id,
|
||||
asset.services[0].id,
|
||||
0,
|
||||
accountId,
|
||||
ddo.services[0].serviceEndpoint //to check
|
||||
asset.services[0].serviceEndpoint // to check
|
||||
)
|
||||
const providerFees: ProviderFees = {
|
||||
providerFeeAddress: initializeData.providerFee.providerFeeAddress,
|
||||
@ -375,6 +340,8 @@ export default function Compute({
|
||||
tokenInOutMarket,
|
||||
amountsInOutMaxFee
|
||||
)
|
||||
|
||||
break
|
||||
}
|
||||
case 'fixed': {
|
||||
const datatokenInstance = new Datatoken(web3)
|
||||
@ -392,12 +359,14 @@ export default function Compute({
|
||||
marketFeeAddress: appConfig.marketFeeAddress
|
||||
}
|
||||
tx = await datatokenInstance.buyFromFreAndOrder(
|
||||
ddo.datatokens[0].address,
|
||||
asset.datatokens[0].address,
|
||||
accountId,
|
||||
order,
|
||||
fre
|
||||
)
|
||||
assetOrderId = tx.transactionHash
|
||||
|
||||
break
|
||||
}
|
||||
case 'free': {
|
||||
const datatokenInstance = new Datatoken(web3)
|
||||
@ -415,26 +384,27 @@ export default function Compute({
|
||||
marketFeeAddress: appConfig.marketFeeAddress
|
||||
}
|
||||
tx = await datatokenInstance.buyFromDispenserAndOrder(
|
||||
ddo.datatokens[0].address,
|
||||
asset.datatokens[0].address,
|
||||
accountId,
|
||||
order,
|
||||
accessDetails.addressOrId
|
||||
)
|
||||
assetOrderId = tx.transactionHash
|
||||
}
|
||||
}
|
||||
if (!tx) {
|
||||
setError('Error buying datatoken.')
|
||||
LoggerInstance.error(
|
||||
'[compute] Error buying datatoken for data set ',
|
||||
ddo.id
|
||||
asset.id
|
||||
)
|
||||
return
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const datatokenInstance = new Datatoken(web3)
|
||||
const tx = await datatokenInstance.startOrder(
|
||||
ddo.datatokens[0].address,
|
||||
asset.datatokens[0].address,
|
||||
accountId,
|
||||
initializeData.computeAddress,
|
||||
0,
|
||||
@ -531,6 +501,7 @@ export default function Compute({
|
||||
tokenInOutMarket,
|
||||
amountsInOutMaxFee
|
||||
)
|
||||
break
|
||||
}
|
||||
case 'fixed': {
|
||||
const datatokenInstance = new Datatoken(web3)
|
||||
@ -554,6 +525,7 @@ export default function Compute({
|
||||
fre
|
||||
)
|
||||
algorithmAssetOrderId = tx.transactionHash
|
||||
break
|
||||
}
|
||||
case 'free': {
|
||||
const datatokenInstance = new Datatoken(web3)
|
||||
@ -577,6 +549,7 @@ export default function Compute({
|
||||
algorithmConsumeDetails.addressOrId
|
||||
)
|
||||
algorithmAssetOrderId = tx.transactionHash
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@ -615,8 +588,8 @@ export default function Compute({
|
||||
LoggerInstance.log('[compute] Starting compute job.')
|
||||
|
||||
const computeAsset: ComputeAsset = {
|
||||
documentId: ddo.id,
|
||||
serviceId: ddo.services[0].id,
|
||||
documentId: asset.id,
|
||||
serviceId: asset.services[0].id,
|
||||
transferTxId: assetOrderId
|
||||
}
|
||||
computeAlgorithm.transferTxId = algorithmAssetOrderId
|
||||
@ -627,7 +600,7 @@ export default function Compute({
|
||||
}
|
||||
|
||||
const response = await ProviderInstance.computeStart(
|
||||
ddo.services[0].serviceEndpoint,
|
||||
asset.services[0].serviceEndpoint,
|
||||
web3,
|
||||
accountId,
|
||||
'env1',
|
||||
@ -646,17 +619,18 @@ export default function Compute({
|
||||
LoggerInstance.log('[compute] Starting compute job response: ', response)
|
||||
|
||||
await checkPreviousOrders(selectedAlgorithmAsset)
|
||||
await checkPreviousOrders(ddo)
|
||||
await checkPreviousOrders(asset)
|
||||
setIsPublished(true)
|
||||
} catch (error) {
|
||||
await checkPreviousOrders(selectedAlgorithmAsset)
|
||||
await checkPreviousOrders(ddo)
|
||||
await checkPreviousOrders(asset)
|
||||
setError('Failed to start job!')
|
||||
LoggerInstance.error('[compute] Failed to start job: ', error.message)
|
||||
} finally {
|
||||
setIsJobStarting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.info}>
|
||||
@ -664,13 +638,16 @@ export default function Compute({
|
||||
<Price accessDetails={accessDetails} conversion />
|
||||
</div>
|
||||
|
||||
{ddo.metadata.type === 'algorithm' ? (
|
||||
{asset.metadata.type === 'algorithm' ? (
|
||||
<>
|
||||
<Alert
|
||||
text="This algorithm has been set to private by the publisher and can't be downloaded. You can run it against any allowed data sets though!"
|
||||
state="info"
|
||||
/>
|
||||
<AlgorithmDatasetsListForCompute algorithmDid={ddo.id} ddo={ddo} />
|
||||
<AlgorithmDatasetsListForCompute
|
||||
algorithmDid={asset.id}
|
||||
ddo={asset}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Formik
|
||||
@ -691,7 +668,7 @@ export default function Compute({
|
||||
hasDatatoken={hasDatatoken}
|
||||
dtBalance={dtBalance}
|
||||
datasetLowPoolLiquidity={!isConsumablePrice}
|
||||
assetType={ddo?.metadata.type}
|
||||
assetType={asset?.metadata.type}
|
||||
assetTimeout={datasetTimeout}
|
||||
hasPreviousOrderSelectedComputeAsset={hasPreviousAlgorithmOrder}
|
||||
hasDatatokenSelectedComputeAsset={hasAlgoAssetDatatoken}
|
||||
|
@ -131,7 +131,7 @@ export default function AssetActions({
|
||||
|
||||
const UseContent = isCompute ? (
|
||||
<Compute
|
||||
ddo={asset}
|
||||
asset={asset}
|
||||
accessDetails={asset?.accessDetails}
|
||||
dtBalance={dtBalance}
|
||||
file={fileMetadata}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import React, { ReactElement } from 'react'
|
||||
import React, { ReactElement, useEffect, useState } from 'react'
|
||||
import { useWeb3 } from '@context/Web3'
|
||||
import Markdown from '@shared/Markdown'
|
||||
import MetaFull from './MetaFull'
|
||||
import MetaSecondary from './MetaSecondary'
|
||||
@ -14,15 +15,39 @@ import styles from './index.module.css'
|
||||
import NetworkName from '@shared/NetworkName'
|
||||
import content from '../../../../content/purgatory.json'
|
||||
import { AssetExtended } from 'src/@types/AssetExtended'
|
||||
import Button from '@shared/atoms/Button'
|
||||
import { getServiceById, getServiceByName } from '@utils/ddo'
|
||||
import EditComputeDataset from '../Edit/EditComputeDataset'
|
||||
|
||||
export default function AssetContent({
|
||||
asset
|
||||
}: {
|
||||
asset: AssetExtended
|
||||
}): ReactElement {
|
||||
const { accountId } = useWeb3()
|
||||
const { debug } = useUserPreferences()
|
||||
const { isInPurgatory, purgatoryData } = useAsset()
|
||||
const { owner, isInPurgatory, purgatoryData, isAssetNetwork } = useAsset()
|
||||
const [isOwner, setIsOwner] = useState(false)
|
||||
const [isComputeType, setIsComputeType] = useState<boolean>(false)
|
||||
const [showEditCompute, setShowEditCompute] = useState<boolean>()
|
||||
|
||||
useEffect(() => {
|
||||
if (!accountId || !owner) return
|
||||
|
||||
const isOwner = accountId.toLowerCase() === owner.toLowerCase()
|
||||
setIsOwner(isOwner)
|
||||
// setShowPricing(isOwner && price.type === '')
|
||||
setIsComputeType(Boolean(getServiceByName(asset, 'compute')))
|
||||
}, [accountId, asset?.accessDetails, owner, asset])
|
||||
|
||||
function handleEditComputeButton() {
|
||||
window.scrollTo({ top: 0, left: 0, behavior: 'smooth' })
|
||||
setShowEditCompute(true)
|
||||
}
|
||||
|
||||
// return showEditCompute ? (
|
||||
// <EditComputeDataset setShowEdit={setShowEditCompute} />
|
||||
// ) : (
|
||||
return (
|
||||
<>
|
||||
<div className={styles.networkWrap}>
|
||||
@ -68,16 +93,16 @@ export default function AssetContent({
|
||||
with own URL instead of switching out AssetContent in place.
|
||||
Simple way would be modal usage
|
||||
*/}
|
||||
{/* {isOwner && isAssetNetwork && (
|
||||
{isOwner && isAssetNetwork && (
|
||||
<div className={styles.ownerActions}>
|
||||
<Button
|
||||
{/* <Button
|
||||
style="text"
|
||||
size="small"
|
||||
onClick={handleEditButton}
|
||||
onClick={handleEditComputeButton}
|
||||
>
|
||||
Edit Metadata
|
||||
</Button>
|
||||
{serviceCompute && ddo?.metadata.type === 'dataset' && (
|
||||
</Button> */}
|
||||
{isComputeType && asset.metadata.type === 'dataset' && (
|
||||
<>
|
||||
<span className={styles.separator}>|</span>
|
||||
<Button
|
||||
@ -90,7 +115,7 @@ export default function AssetContent({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)} */}
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</>
|
||||
|
Loading…
x
Reference in New Issue
Block a user