Skip to content

Commit

Permalink
[wallet-ext] - Updated Kiosk UI / Enable Transfer for Sui Kiosk (#13068)
Browse files Browse the repository at this point in the history
## Description 

This PR updates the UI for displaying Kiosks in the wallet. It also
includes updates to allow transfer of items within a Sui Kiosk for
non-locked items. Also includes UI for representing assets that are
locked in a Kiosk



https://github.com/MystenLabs/sui/assets/122397493/e76b86a6-5648-4b90-ae2e-d4811072ba77




## Test Plan 

How did you test the new or updated feature?

---
If your changes are not user-facing and not a breaking change, you can
skip the following section. Otherwise, please indicate what changed, and
then add to the Release Notes section as highlighted during the release
process.

### Type of Change (Check all that apply)

- [ ] protocol change
- [ ] user-visible impact
- [ ] breaking change for a client SDKs
- [ ] breaking change for FNs (FN binary must upgrade)
- [ ] breaking change for validators or node operators (must upgrade
binaries)
- [ ] breaking change for on-chain data layout
- [ ] necessitate either a data wipe or data migration

### Release notes

---------

Co-authored-by: Pavlos Chrysochoidis <[email protected]>
  • Loading branch information
mamos-mysten and pchrysochoidis authored Jul 24, 2023
1 parent 52a6e7b commit 13df03f
Show file tree
Hide file tree
Showing 18 changed files with 479 additions and 202 deletions.
129 changes: 82 additions & 47 deletions apps/core/src/hooks/useGetKioskContents.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,29 @@
// Copyright (c) Mysten Labs, Inc.
// SPDX-License-Identifier: Apache-2.0

import { SuiObjectResponse } from '@mysten/sui.js';
import { fetchKiosk, getOwnedKiosks } from '@mysten/kiosk';
import { type SuiObjectResponse } from '@mysten/sui.js';
import { KIOSK_ITEM, KioskData, KioskItem, fetchKiosk, getOwnedKiosks } from '@mysten/kiosk';
import { useQuery } from '@tanstack/react-query';
import { useRpcClient } from '../api/RpcClientContext';
import { SuiClient } from '@mysten/sui.js/client';
import { ORIGINBYTE_KIOSK_OWNER_TOKEN, getKioskIdFromOwnerCap } from '../utils/kiosk';
import { SuiClient } from '@mysten/sui.js/src/client';

const getKioskId = (obj: SuiObjectResponse) =>
obj.data?.content &&
'fields' in obj.data.content &&
(obj.data.content.fields.for ?? obj.data.content.fields.kiosk);
export type KioskContents = Omit<KioskData, 'items'> & {
items: Partial<KioskItem & SuiObjectResponse>[];
ownerCap?: string;
};

// OriginByte module for mainnet (we only support mainnet)
export const ORIGINBYTE_KIOSK_MODULE =
'0x95a441d389b07437d00dd07e0b6f05f513d7659b13fd7c5d3923c7d9d847199b::ob_kiosk' as const;
export const ORIGINBYTE_KIOSK_OWNER_TOKEN = `${ORIGINBYTE_KIOSK_MODULE}::OwnerToken`;
export enum KioskTypes {
SUI = 'sui',
ORIGINBYTE = 'originByte',
}

export type Kiosk = {
items: Partial<KioskItem & SuiObjectResponse>[];
kioskId: string;
type: KioskTypes;
ownerCap?: string;
};

async function getOriginByteKioskContents(address: string, client: SuiClient) {
const data = await client.getOwnedObjects({
Expand All @@ -27,7 +35,8 @@ async function getOriginByteKioskContents(address: string, client: SuiClient) {
showContent: true,
},
});
const ids = data.data.map((object) => getKioskId(object) ?? []);
const ids = data.data.map((object) => getKioskIdFromOwnerCap(object));
const kiosks = new Map<string, Kiosk>();

// fetch the user's kiosks
const ownedKiosks = await client.multiGetObjects({
Expand All @@ -38,49 +47,65 @@ async function getOriginByteKioskContents(address: string, client: SuiClient) {
});

// find object IDs within a kiosk
const kioskObjectIds = await Promise.all(
await Promise.all(
ownedKiosks.map(async (kiosk) => {
if (!kiosk.data?.objectId) return [];
const objects = await client.getDynamicFields({
parentId: kiosk.data.objectId,
});
return objects.data.map((obj) => obj.objectId);

const objectIds = objects.data
.filter((obj) => obj.name.type === KIOSK_ITEM)
.map((obj) => obj.objectId);

// fetch the contents of the objects within a kiosk
const kioskContent = await client.multiGetObjects({
ids: objectIds,
options: {
showDisplay: true,
showType: true,
},
});

kiosks.set(kiosk.data.objectId, {
items: kioskContent.map((item) => ({ ...item, kioskId: kiosk.data?.objectId })),
kioskId: kiosk.data.objectId,
type: KioskTypes.ORIGINBYTE,
});
}),
);

// fetch the contents of the objects within a kiosk
const kioskContent = await client.multiGetObjects({
ids: kioskObjectIds.flat(),
options: {
showDisplay: true,
showType: true,
},
});

return kioskContent;
return kiosks;
}

async function getSuiKioskContents(address: string, client: SuiClient) {
const ownedKiosks = await getOwnedKiosks(client, address!);
const kioskContents = await Promise.all(
const kiosks = new Map<string, Kiosk>();

await Promise.all(
ownedKiosks.kioskIds.map(async (id) => {
return fetchKiosk(client, id, { limit: 1000 }, {});
}),
);
const items = kioskContents.flatMap((k) => k.data.items);
const ids = items.map((item) => item.objectId);
const kiosk = await fetchKiosk(client, id, { limit: 1000 }, {});
const contents = await client.multiGetObjects({
ids: kiosk.data.itemIds,
options: { showDisplay: true, showContent: true },
});

// fetch the contents of the objects within a kiosk
const kioskContent = await client.multiGetObjects({
ids,
options: {
showContent: true,
showDisplay: true,
showType: true,
},
});
const items = contents.map((object) => {
const kioskData = kiosk.data.items.find((item) => item.objectId === object.data?.objectId);
return { ...object, ...kioskData, kioskId: id };
});

kiosks.set(id, {
...kiosk.data,
items,
kioskId: id,
type: KioskTypes.SUI,
ownerCap: ownedKiosks.kioskOwnerCaps.find((k) => k.kioskId === id)?.objectId,
});
}, kiosks),
);

return kioskContent;
return kiosks;
}

export function useGetKioskContents(address?: string | null, disableOriginByteKiosk?: boolean) {
Expand All @@ -89,15 +114,25 @@ export function useGetKioskContents(address?: string | null, disableOriginByteKi
// eslint-disable-next-line @tanstack/query/exhaustive-deps
queryKey: ['get-kiosk-contents', address, disableOriginByteKiosk],
queryFn: async () => {
const obKioskContents = await getOriginByteKioskContents(address!, rpc);
const suiKioskContents = await getSuiKioskContents(address!, rpc);
const suiKiosks = await getSuiKioskContents(address!, rpc);
const obKiosks = !disableOriginByteKiosk
? await getOriginByteKioskContents(address!, rpc)
: new Map();

const list = [...Array.from(suiKiosks.values()), ...Array.from(obKiosks.values())].flatMap(
(d) => d.items,
);
const kiosks = new Map([...suiKiosks, ...obKiosks]) as Map<string, Kiosk>;
// a map of object ID to Kiosk ID
const lookup = list.reduce((acc, curr) => {
acc.set(curr.data.objectId, curr.kioskId);
return acc;
}, new Map<string, string>());

return {
list: [...suiKioskContents, ...obKioskContents],
kiosks: {
sui: suiKioskContents ?? [],
originByte: obKioskContents ?? [],
},
list,
lookup,
kiosks,
};
},
});
Expand Down
1 change: 1 addition & 0 deletions apps/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,4 @@ export * from './hooks/useGetBinaryVersion';
export * from './hooks/useProductAnalyticsConfig';
export * from './hooks/useCookieConsentBanner';
export * from './hooks/useGetKioskContents';
export * from './utils/kiosk';
28 changes: 28 additions & 0 deletions apps/core/src/utils/kiosk.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Copyright (c) Mysten Labs, Inc.
// SPDX-License-Identifier: Apache-2.0

import {
SuiObjectData,
SuiObjectResponse,
getSuiObjectData,
isSuiObjectResponse,
getObjectFields,
} from '@mysten/sui.js';
import { KIOSK_OWNER_CAP } from '@mysten/kiosk';

export const ORIGINBYTE_KIOSK_MODULE =
'0x95a441d389b07437d00dd07e0b6f05f513d7659b13fd7c5d3923c7d9d847199b::ob_kiosk';

export const ORIGINBYTE_KIOSK_OWNER_TOKEN = `${ORIGINBYTE_KIOSK_MODULE}::OwnerToken`;

export function isKioskOwnerToken(object?: SuiObjectResponse | SuiObjectData) {
if (!object) return false;
const objectData = isSuiObjectResponse(object) ? getSuiObjectData(object) : object;
return [KIOSK_OWNER_CAP, ORIGINBYTE_KIOSK_OWNER_TOKEN].includes(objectData?.type ?? '');
}

export function getKioskIdFromOwnerCap(object: SuiObjectResponse | SuiObjectData) {
const objectData = isSuiObjectResponse(object) ? getSuiObjectData(object) : object;
const fields = getObjectFields(objectData!);
return fields?.for ?? fields?.kiosk;
}
89 changes: 89 additions & 0 deletions apps/wallet/src/ui/app/components/nft-display/Kiosk.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// Copyright (c) Mysten Labs, Inc.
// SPDX-License-Identifier: Apache-2.0
import { getKioskIdFromOwnerCap, hasDisplayData, useGetKioskContents } from '@mysten/core';
import { getObjectDisplay, type SuiObjectResponse } from '@mysten/sui.js';
import cl from 'classnames';

import { NftImage, type NftImageProps } from './NftImage';
import { useActiveAddress } from '../../hooks';
import { Text } from '../../shared/text';

type KioskProps = {
object: SuiObjectResponse;
orientation?: 'vertical' | 'horizontal' | null;
} & Partial<NftImageProps>;

// used to prevent the top image from overflowing the bottom of the container
// (clip-path is used instead of overflow-hidden as it can be animated)
const clipPath = '[clip-path:inset(0_0_7px_0_round_12px)] group-hover:[clip-path:inset(0_0_0_0)]';

const timing = 'transition-all duration-300 ease-[cubic-bezier(0.68,-0.55,0.265,1.55)]';
const cardStyles = [
`scale-100 group-hover:scale-95 object-cover origin-bottom z-20 group-hover:translate-y-0 translate-y-2 group-hover:shadow-md`,
`scale-[0.95] group-hover:-rotate-6 group-hover:-translate-x-5 group-hover:-translate-y-2 z-10 translate-y-0 group-hover:shadow-md`,
`scale-[0.90] group-hover:rotate-6 group-hover:translate-x-5 group-hover:-translate-y-2 z-0 -translate-y-2 group-hover:shadow-xl`,
];

export function Kiosk({ object, orientation, ...nftImageProps }: KioskProps) {
const address = useActiveAddress();
const { data: kioskData, isLoading } = useGetKioskContents(address);

if (isLoading) return null;
const kioskId = getKioskIdFromOwnerCap(object);
const kiosk = kioskData?.kiosks.get(kioskId!);
const itemsWithDisplay = kiosk?.items.filter((item) => hasDisplayData(item)) ?? [];
const items = kiosk?.items?.sort((item) => (hasDisplayData(item) ? -1 : 1)) ?? [];

const showCardStackAnimation = itemsWithDisplay.length > 1 && orientation !== 'horizontal';
const imagesToDisplay = orientation !== 'horizontal' ? 3 : 1;

return (
<div
className={cl(
'relative hover:bg-transparent group rounded-xl transform-gpu overflow-visible w-36 h-36',
)}
>
<div className="absolute z-0">
{itemsWithDisplay.length === 0 ? (
<NftImage animateHover src={null} name="Kiosk" {...nftImageProps} />
) : items?.length ? (
items.slice(0, imagesToDisplay).map((item, idx) => {
const display = getObjectDisplay(item)?.data;
return (
<div
key={item.data?.objectId}
className={cl(
'absolute rounded-xl border',
timing,
showCardStackAnimation ? cardStyles[idx] : '',
)}
>
<div className={`${idx === 0 && showCardStackAnimation ? clipPath : ''} ${timing}`}>
<NftImage
{...nftImageProps}
src={display?.image_url!}
animateHover={items.length <= 1}
name="Kiosk"
/>
</div>
</div>
);
})
) : null}
</div>
{orientation !== 'horizontal' && (
<div
className={cl(
timing,
'right-1.5 bottom-1.5 flex items-center justify-center absolute h-6 w-6 bg-gray-100 text-white rounded-md',
{ 'group-hover:-translate-x-0.5 group-hover:scale-95': showCardStackAnimation },
)}
>
<Text variant="subtitle" weight="medium">
{items?.length}
</Text>
</div>
)}
</div>
);
}
Loading

0 comments on commit 13df03f

Please sign in to comment.