diff --git a/apps/mobile/src/components/add-wallet/add-wallet-list-item.tsx b/apps/mobile/src/components/add-wallet/add-wallet-list-item.tsx index f557ee93f..237e171c3 100644 --- a/apps/mobile/src/components/add-wallet/add-wallet-list-item.tsx +++ b/apps/mobile/src/components/add-wallet/add-wallet-list-item.tsx @@ -1,5 +1,3 @@ -import React from 'react'; - import { Box, Text, TouchableOpacity } from '@leather.io/ui/native'; interface AddWalletListItemProps { diff --git a/apps/mobile/src/components/home/home.tsx b/apps/mobile/src/components/home/home.tsx index 6a5341d92..c4b29bff2 100644 --- a/apps/mobile/src/components/home/home.tsx +++ b/apps/mobile/src/components/home/home.tsx @@ -1,12 +1,22 @@ import { AccountsWidget } from '@/components/widgets/accounts/accounts-widget'; -import { TokensWidget } from '@/components/widgets/tokens/tokens-widget'; -import { getMockTokens, mockTotalBalance } from '@/components/widgets/tokens/tokens.mocks'; +import { + CollectiblesWidget, + mockCollectibles, + serializeCollectibles, +} from '@/components/widgets/collectibles'; +import { TokensWidget, getMockTokens } from '@/components/widgets/tokens'; import { useAccounts } from '@/store/accounts/accounts.read'; import { useWallets } from '@/store/wallets/wallets.read'; import { useLingui } from '@lingui/react'; import { HomeLayout } from './home.layout'; +const mockTotalBalance = { + totalUsdBalance: '$126.74', + totalBtcBalance: '0.00215005', + totalStxBalance: '0.0024', +}; + export function Home() { useLingui(); const wallets = useWallets(); @@ -16,6 +26,10 @@ export function Home() { + ); } diff --git a/apps/mobile/src/components/widgets/accounts/accounts-header.tsx b/apps/mobile/src/components/widgets/accounts/accounts-header.tsx index a02c26ffa..8e0c4f1b1 100644 --- a/apps/mobile/src/components/widgets/accounts/accounts-header.tsx +++ b/apps/mobile/src/components/widgets/accounts/accounts-header.tsx @@ -1,5 +1,3 @@ -import React from 'react'; - import { AccountSelectorSheet } from '@/features/account-selector-sheet'; import { t } from '@lingui/macro'; diff --git a/apps/mobile/src/components/widgets/accounts/accounts-widget.layout.tsx b/apps/mobile/src/components/widgets/accounts/accounts-widget.layout.tsx index 3abf4a56c..57f2c4567 100644 --- a/apps/mobile/src/components/widgets/accounts/accounts-widget.layout.tsx +++ b/apps/mobile/src/components/widgets/accounts/accounts-widget.layout.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { ScrollView } from 'react-native-gesture-handler'; import { useTheme } from '@shopify/restyle'; diff --git a/apps/mobile/src/components/widgets/collectibles/collectibles-header.tsx b/apps/mobile/src/components/widgets/collectibles/collectibles-header.tsx new file mode 100644 index 000000000..6d5a01ee6 --- /dev/null +++ b/apps/mobile/src/components/widgets/collectibles/collectibles-header.tsx @@ -0,0 +1,30 @@ +import { t } from '@lingui/macro'; + +import { Box, ChevronRightIcon, Chip, SheetRef, Text } from '@leather.io/ui/native'; + +import { FiatBalance } from '../components/balance/fiat-balance'; + +interface CollectiblesHeaderProps { + collectibleCount: number; + sheetRef: React.RefObject; + totalBalance: string; +} + +function CollectiblesHeaderText() { + return {t`My collectibles`}; +} + +export function CollectiblesHeader({ collectibleCount, totalBalance }: CollectiblesHeaderProps) { + const hasCollectibles = collectibleCount > 0; + if (!hasCollectibles) return ; + return ( + + + + + + + + + ); +} diff --git a/apps/mobile/src/components/widgets/collectibles/collectibles-serializer.spec.ts b/apps/mobile/src/components/widgets/collectibles/collectibles-serializer.spec.ts new file mode 100644 index 000000000..6204fa1db --- /dev/null +++ b/apps/mobile/src/components/widgets/collectibles/collectibles-serializer.spec.ts @@ -0,0 +1,60 @@ +import { mockCollectibles } from '@leather.io/ui/native'; + +import { serializeCollectibles } from './collectibles-serializer'; + +describe('serializeCollectibles', () => { + it('should correctly serialize collectibles', () => { + const serializedCollectibles = serializeCollectibles(mockCollectibles); + + expect(serializedCollectibles).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(String), + title: expect.any(String), + imageUrl: expect.any(String), + collection: expect.any(String), + type: expect.stringMatching(/^(ordinal|stacks)$/), + }), + ]) + ); + }); + + it('should handle empty input', () => { + const serializedCollectibles = serializeCollectibles([]); + expect(serializedCollectibles).toEqual([]); + }); + + it('should correctly serialize Ordinals', () => { + const ordinals = mockCollectibles.filter(c => 'name' in c && 'mimeType' in c); + const serializedOrdinals = serializeCollectibles(ordinals); + + serializedOrdinals.forEach(ordinal => { + expect(ordinal).toEqual( + expect.objectContaining({ + type: 'inscription', + id: expect.any(String), + title: expect.any(String), + imageUrl: expect.any(String), + collection: expect.any(String), + }) + ); + }); + }); + + it('should correctly serialize StacksNfts', () => { + const stacksNfts = mockCollectibles.filter(c => 'metadata' in c); + const serializedStacksNfts = serializeCollectibles(stacksNfts); + + serializedStacksNfts.forEach(nft => { + expect(nft).toEqual( + expect.objectContaining({ + type: 'stacks', + id: expect.any(String), + title: expect.any(String), + imageUrl: expect.any(String), + collection: expect.any(String), + }) + ); + }); + }); +}); diff --git a/apps/mobile/src/components/widgets/collectibles/collectibles-serializer.ts b/apps/mobile/src/components/widgets/collectibles/collectibles-serializer.ts new file mode 100644 index 000000000..0c6a9ad32 --- /dev/null +++ b/apps/mobile/src/components/widgets/collectibles/collectibles-serializer.ts @@ -0,0 +1,21 @@ +import { type Collectible, type CollectibleCardProps } from '@leather.io/ui/native'; + +export function serializeCollectibles(collectibles: Collectible[]): CollectibleCardProps[] { + return collectibles.map(collectible => { + const isOrdinal = 'name' in collectible && 'mimeType' in collectible; + if (isOrdinal) { + return { + type: 'inscription', + name: collectible.title, + src: collectible.src, + mimeType: collectible.mimeType, + }; + } + return { + type: 'stacks', + name: collectible.metadata.name, + src: collectible.metadata.cached_image, + mimeType: null, + }; + }); +} diff --git a/apps/mobile/src/components/widgets/collectibles/collectibles-widget.layout.tsx b/apps/mobile/src/components/widgets/collectibles/collectibles-widget.layout.tsx new file mode 100644 index 000000000..f083d0d55 --- /dev/null +++ b/apps/mobile/src/components/widgets/collectibles/collectibles-widget.layout.tsx @@ -0,0 +1,30 @@ +import { ScrollView } from 'react-native-gesture-handler'; + +import { useTheme } from '@shopify/restyle'; + +import { Box, SheetRef, Theme } from '@leather.io/ui/native'; + +import { Widget } from '../widget'; + +interface CollectiblesWidgetProps { + balance?: React.ReactNode; + children: React.ReactNode; + header?: React.ReactNode; + sheetRef?: React.RefObject; +} + +export function CollectiblesWidgetLayout({ children, header }: CollectiblesWidgetProps) { + const theme = useTheme(); + return ( + + {header} + + {children} + + + ); +} diff --git a/apps/mobile/src/components/widgets/collectibles/collectibles-widget.tsx b/apps/mobile/src/components/widgets/collectibles/collectibles-widget.tsx new file mode 100644 index 000000000..025102197 --- /dev/null +++ b/apps/mobile/src/components/widgets/collectibles/collectibles-widget.tsx @@ -0,0 +1,33 @@ +import React, { useRef } from 'react'; + +import { CollectibleCard, CollectibleCardProps, SheetRef } from '@leather.io/ui/native'; + +import { TokenBalance } from '../components/balance/token-balance'; +import { CollectiblesHeader } from './collectibles-header'; +import { CollectiblesWidgetLayout } from './collectibles-widget.layout'; + +interface CollectiblesWidgetProps { + collectibles: CollectibleCardProps[]; + totalBalance: string; +} + +export function CollectiblesWidget({ collectibles, totalBalance }: CollectiblesWidgetProps) { + const sheetRef = useRef(null); + + return ( + + } + balance={collectibles.length > 0 && } + > + {collectibles.map((collectible: CollectibleCardProps, index) => ( + + ))} + + ); +} diff --git a/apps/mobile/src/components/widgets/collectibles/index.ts b/apps/mobile/src/components/widgets/collectibles/index.ts new file mode 100644 index 000000000..9f4a0ef31 --- /dev/null +++ b/apps/mobile/src/components/widgets/collectibles/index.ts @@ -0,0 +1,3 @@ +export { mockCollectibles } from '@leather.io/ui/native'; +export { CollectiblesWidget } from './collectibles-widget'; +export { serializeCollectibles } from './collectibles-serializer'; diff --git a/apps/mobile/src/components/widgets/components/balance/token-balance.tsx b/apps/mobile/src/components/widgets/components/balance/token-balance.tsx index 3066a8d22..197874aff 100644 --- a/apps/mobile/src/components/widgets/components/balance/token-balance.tsx +++ b/apps/mobile/src/components/widgets/components/balance/token-balance.tsx @@ -1,5 +1,3 @@ -import React from 'react'; - import { t } from '@lingui/macro'; import { BulletSeparator, Text } from '@leather.io/ui/native'; diff --git a/apps/mobile/src/components/widgets/tokens/index.ts b/apps/mobile/src/components/widgets/tokens/index.ts new file mode 100644 index 000000000..8f68cf5ca --- /dev/null +++ b/apps/mobile/src/components/widgets/tokens/index.ts @@ -0,0 +1,2 @@ +export { TokensWidget } from './tokens-widget'; +export { getMockTokens } from './tokens.mocks'; diff --git a/apps/mobile/src/components/widgets/tokens/tokens-header.tsx b/apps/mobile/src/components/widgets/tokens/tokens-header.tsx index d9c7f273e..acea83d17 100644 --- a/apps/mobile/src/components/widgets/tokens/tokens-header.tsx +++ b/apps/mobile/src/components/widgets/tokens/tokens-header.tsx @@ -1,5 +1,3 @@ -import React from 'react'; - import { t } from '@lingui/macro'; import { Box, ChevronRightIcon, Chip, SheetRef, Text } from '@leather.io/ui/native'; diff --git a/apps/mobile/src/components/widgets/tokens/tokens-widget.layout.tsx b/apps/mobile/src/components/widgets/tokens/tokens-widget.layout.tsx index d3199790a..098cfdc2a 100644 --- a/apps/mobile/src/components/widgets/tokens/tokens-widget.layout.tsx +++ b/apps/mobile/src/components/widgets/tokens/tokens-widget.layout.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { ScrollView } from 'react-native-gesture-handler'; import { useTheme } from '@shopify/restyle'; diff --git a/apps/mobile/src/components/widgets/tokens/tokens.mocks.tsx b/apps/mobile/src/components/widgets/tokens/tokens.mocks.tsx index bc79f8c16..399ac18f7 100644 --- a/apps/mobile/src/components/widgets/tokens/tokens.mocks.tsx +++ b/apps/mobile/src/components/widgets/tokens/tokens.mocks.tsx @@ -4,14 +4,6 @@ import BigNumber from 'bignumber.js'; import { Money } from '@leather.io/models'; import { BtcAvatarIcon, StxAvatarIcon } from '@leather.io/ui/native'; -// provided by useTotalBalance hook in extension/src/app/common/hooks/balance/use-total-balance.tsx - -export const mockTotalBalance = { - totalUsdBalance: '$126.74', - totalBtcBalance: '0.00215005', - totalStxBalance: '0.0024', -}; - export interface Token { availableBalance: Record; formattedBalance: { isAbbreviated: boolean; value: string }; diff --git a/apps/mobile/src/components/widgets/widget.tsx b/apps/mobile/src/components/widgets/widget.tsx index 11291babb..4ba72990c 100644 --- a/apps/mobile/src/components/widgets/widget.tsx +++ b/apps/mobile/src/components/widgets/widget.tsx @@ -1,5 +1,3 @@ -import React from 'react'; - import { Box } from '@leather.io/ui/native'; interface WidgetProps { diff --git a/apps/mobile/src/locales/en/messages.po b/apps/mobile/src/locales/en/messages.po index 7377d59fe..dd07fcc2e 100644 --- a/apps/mobile/src/locales/en/messages.po +++ b/apps/mobile/src/locales/en/messages.po @@ -1,6 +1,6 @@ msgid "" msgstr "" -"POT-Creation-Date: 2024-09-23 11:25-0400\n" +"POT-Creation-Date: 2024-09-25 06:31+0100\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" @@ -194,11 +194,11 @@ msgstr "Awaiting verification" msgid "BIP39 passphrase" msgstr "BIP39 passphrase" -#: src/components/widgets/tokens/tokens.mocks.tsx:50 +#: src/components/widgets/tokens/tokens.mocks.tsx:42 msgid "Bitcoin" msgstr "Bitcoin" -#: src/components/widgets/tokens/tokens.mocks.tsx:27 +#: src/components/widgets/tokens/tokens.mocks.tsx:19 msgid "Bitcoin blockchain" msgstr "Bitcoin blockchain" @@ -490,7 +490,7 @@ msgid "Lock app" msgstr "Lock app" #: src/components/widgets/components/balance/fiat-balance.tsx:45 -#: src/components/widgets/components/balance/token-balance.tsx:21 +#: src/components/widgets/components/balance/token-balance.tsx:19 msgid "locked" msgstr "locked" @@ -507,11 +507,15 @@ msgstr "More options" msgid "Must use physical device for Push Notifications" msgstr "Must use physical device for Push Notifications" -#: src/components/widgets/accounts/accounts-header.tsx:14 +#: src/components/widgets/accounts/accounts-header.tsx:12 msgid "My accounts" msgstr "My accounts" -#: src/components/widgets/tokens/tokens-header.tsx:18 +#: src/components/widgets/collectibles/collectibles-header.tsx:14 +msgid "My collectibles" +msgstr "My collectibles" + +#: src/components/widgets/tokens/tokens-header.tsx:16 msgid "My tokens" msgstr "My tokens" @@ -704,11 +708,11 @@ msgstr "Something is wrong!" msgid "Something went wrong" msgstr "Something went wrong" -#: src/components/widgets/tokens/tokens.mocks.tsx:113 +#: src/components/widgets/tokens/tokens.mocks.tsx:105 msgid "Stacks" msgstr "Stacks" -#: src/components/widgets/tokens/tokens.mocks.tsx:59 +#: src/components/widgets/tokens/tokens.mocks.tsx:51 msgid "Stacks blockchain" msgstr "Stacks blockchain" diff --git a/apps/mobile/src/locales/pseudo-locale/messages.po b/apps/mobile/src/locales/pseudo-locale/messages.po index 5729fe75a..0af230288 100644 --- a/apps/mobile/src/locales/pseudo-locale/messages.po +++ b/apps/mobile/src/locales/pseudo-locale/messages.po @@ -1,6 +1,6 @@ msgid "" msgstr "" -"POT-Creation-Date: 2024-09-23 11:25-0400\n" +"POT-Creation-Date: 2024-09-25 06:31+0100\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" @@ -194,11 +194,11 @@ msgstr "" msgid "BIP39 passphrase" msgstr "" -#: src/components/widgets/tokens/tokens.mocks.tsx:50 +#: src/components/widgets/tokens/tokens.mocks.tsx:42 msgid "Bitcoin" msgstr "" -#: src/components/widgets/tokens/tokens.mocks.tsx:27 +#: src/components/widgets/tokens/tokens.mocks.tsx:19 msgid "Bitcoin blockchain" msgstr "" @@ -490,7 +490,7 @@ msgid "Lock app" msgstr "" #: src/components/widgets/components/balance/fiat-balance.tsx:45 -#: src/components/widgets/components/balance/token-balance.tsx:21 +#: src/components/widgets/components/balance/token-balance.tsx:19 msgid "locked" msgstr "" @@ -507,11 +507,15 @@ msgstr "" msgid "Must use physical device for Push Notifications" msgstr "" -#: src/components/widgets/accounts/accounts-header.tsx:14 +#: src/components/widgets/accounts/accounts-header.tsx:12 msgid "My accounts" msgstr "" -#: src/components/widgets/tokens/tokens-header.tsx:18 +#: src/components/widgets/collectibles/collectibles-header.tsx:14 +msgid "My collectibles" +msgstr "" + +#: src/components/widgets/tokens/tokens-header.tsx:16 msgid "My tokens" msgstr "" @@ -704,11 +708,11 @@ msgstr "" msgid "Something went wrong" msgstr "" -#: src/components/widgets/tokens/tokens.mocks.tsx:113 +#: src/components/widgets/tokens/tokens.mocks.tsx:105 msgid "Stacks" msgstr "" -#: src/components/widgets/tokens/tokens.mocks.tsx:59 +#: src/components/widgets/tokens/tokens.mocks.tsx:51 msgid "Stacks blockchain" msgstr "" diff --git a/packages/ui/native.ts b/packages/ui/native.ts index c62bb9039..4f8492e28 100644 --- a/packages/ui/native.ts +++ b/packages/ui/native.ts @@ -38,3 +38,4 @@ export { export { SheetHeader } from './src/components/sheet/components/sheet-header.native'; export { RadioButton } from './src/components/radio-button/radio-button.native'; export { Switch } from './src/components/switch/switch.native'; +export * from './src/components/collectibles/index.native'; diff --git a/packages/ui/package.json b/packages/ui/package.json index 3856b9ec2..92fb0f7ca 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -44,6 +44,7 @@ "@rnx-kit/metro-config": "1.3.14", "@rnx-kit/metro-resolver-symlinks": "0.1.35", "@shopify/restyle": "2.4.2", + "dompurify": "3.1.4", "expo": "51.0.26", "expo-asset": "10.0.6", "expo-blur": "13.0.2", @@ -60,12 +61,15 @@ "react-native": "0.74.1", "react-native-reanimated": "3.10.1", "react-native-safe-area-context": "4.10.1", - "react-native-svg": "15.2.0" + "react-native-svg": "15.2.0", + "react-native-webview": "13.8.6", + "use-events": "1.4.2" }, "devDependencies": { "@babel/core": "7.24.6", "@babel/runtime": "7.25.0", "@leather.io/eslint-config": "workspace:*", + "@leather.io/models": "workspace:*", "@leather.io/panda-preset": "workspace:*", "@microsoft/api-extractor": "7.47.6", "@pandacss/dev": "0.46.0", @@ -88,6 +92,7 @@ "@storybook/test": "8.3.2", "@storybook/theming": "8.3.2", "@svgr/webpack": "8.1.0", + "@types/dompurify": "3.0.5", "@types/react": "18.2.79", "@types/react-dom": "18.2.25", "babel-preset-expo": "11.0.6", diff --git a/packages/ui/src/components/animate-height/animate-height.web.tsx b/packages/ui/src/components/animate-height/animate-height.web.tsx index ae2815079..8df0746d9 100644 --- a/packages/ui/src/components/animate-height/animate-height.web.tsx +++ b/packages/ui/src/components/animate-height/animate-height.web.tsx @@ -2,7 +2,7 @@ import { useRef, useState } from 'react'; import { motion } from 'framer-motion'; -import { HasChildren } from '../../utils/has-children'; +import { HasChildren } from '../../utils/has-children.shared'; import { useElementHeightListener } from '../../utils/use-element-height-listener.web'; // https://github.com/framer/motion/discussions/1884#discussioncomment-5861808 diff --git a/packages/ui/src/components/approver/animations/approver-animation.web.tsx b/packages/ui/src/components/approver/animations/approver-animation.web.tsx index 0c86e5101..3c379f0ea 100644 --- a/packages/ui/src/components/approver/animations/approver-animation.web.tsx +++ b/packages/ui/src/components/approver/animations/approver-animation.web.tsx @@ -1,6 +1,6 @@ import { motion, stagger, useAnimate } from 'framer-motion'; import { css } from 'leather-styles/css'; -import { HasChildren } from 'src/utils/has-children'; +import { HasChildren } from 'src/utils/has-children.shared'; import { useOnMount } from 'src/utils/use-on-mount'; const animationSelector = '& > *:not(.skip-animation)'; diff --git a/packages/ui/src/components/approver/components/approver-actions.web.tsx b/packages/ui/src/components/approver/components/approver-actions.web.tsx index 75f3ee967..eb36bd334 100644 --- a/packages/ui/src/components/approver/components/approver-actions.web.tsx +++ b/packages/ui/src/components/approver/components/approver-actions.web.tsx @@ -1,7 +1,7 @@ import { css } from 'leather-styles/css'; import { Flex, styled } from 'leather-styles/jsx'; -import type { HasChildren } from '../../../utils/has-children'; +import type { HasChildren } from '../../../utils/has-children.shared'; import { ApproverActionAnimation, ApproverActionsAnimationContainer, diff --git a/packages/ui/src/components/approver/components/approver-advanced.web.tsx b/packages/ui/src/components/approver/components/approver-advanced.web.tsx index bdb70d769..274e37612 100644 --- a/packages/ui/src/components/approver/components/approver-advanced.web.tsx +++ b/packages/ui/src/components/approver/components/approver-advanced.web.tsx @@ -6,7 +6,7 @@ import { AnimateChangeInHeight } from 'src/components/animate-height/animate-hei import { Button } from 'src/components/button/button.web'; import { Flag } from 'src/components/flag/flag.web'; import { ChevronDownIcon } from 'src/icons/chevron-down-icon.web'; -import { HasChildren } from 'src/utils/has-children'; +import { HasChildren } from 'src/utils/has-children.shared'; import { getScrollParent } from 'src/utils/utils.web'; import { delay } from '@leather.io/utils'; diff --git a/packages/ui/src/components/approver/components/approver-section.web.tsx b/packages/ui/src/components/approver/components/approver-section.web.tsx index 0d6991146..efa4e01ca 100644 --- a/packages/ui/src/components/approver/components/approver-section.web.tsx +++ b/packages/ui/src/components/approver/components/approver-section.web.tsx @@ -1,5 +1,5 @@ import { styled } from 'leather-styles/jsx'; -import { HasChildren } from 'src/utils/has-children'; +import { HasChildren } from 'src/utils/has-children.shared'; import { useRegisterApproverChild } from '../approver-context.shared'; diff --git a/packages/ui/src/components/collectibles/collectibles.shared.tsx b/packages/ui/src/components/collectibles/collectibles.shared.tsx new file mode 100644 index 000000000..7376bbf95 --- /dev/null +++ b/packages/ui/src/components/collectibles/collectibles.shared.tsx @@ -0,0 +1,319 @@ +/* eslint-disable */ + +export interface Ordinal { + id: string; + number: number; + output: string; + txid: string; + offset: string; + address: string; + preview: string; + title: string; + genesisBlockHeight: number; + genesisBlockHash: string; + genesisTimestamp: number; + value: string; + mimeType: string; + name: string; + src: string; +} + +const mockOrdinals: Ordinal[] = [ + { + id: 'a494e48bf7120c959239e8c544bc821ca4fb5a46e5fff79938943d434f252949i0', + number: 74703951, + output: '0', + txid: 'a494e48bf7120c959239e8c544bc821ca4fb5a46e5fff79938943d434f252949', + offset: '0', + address: 'bc1pwz9n62p9dhjpqcpdmfcrewdnz3nk8jcved242vd2lj9fgvtvwnwscvdyre', + preview: + 'https://ordinals.hiro.so/inscription/a494e48bf7120c959239e8c544bc821ca4fb5a46e5fff79938943d434f252949i0', + title: 'Inscription 74703951', + genesisBlockHeight: 857719, + genesisBlockHash: '00000000000000000002bc6789fc6742da4958d003d3abff740687a863613a46', + genesisTimestamp: 1724219117, + value: '546', + mimeType: 'html', + name: 'inscription', + src: 'https://ordinals.com/preview/a494e48bf7120c959239e8c544bc821ca4fb5a46e5fff79938943d434f252949i0', + }, + { + id: '335209b72c452f52199ae09e8ce586a451ce452c73326f01f958d8aa8417e062i0', + number: 73858867, + output: '0', + txid: '335209b72c452f52199ae09e8ce586a451ce452c73326f01f958d8aa8417e062', + offset: '0', + address: 'bc1pwz9n62p9dhjpqcpdmfcrewdnz3nk8jcved242vd2lj9fgvtvwnwscvdyre', + preview: + 'https://ordinals.hiro.so/inscription/335209b72c452f52199ae09e8ce586a451ce452c73326f01f958d8aa8417e062i0', + title: 'Inscription 73858867', + genesisBlockHeight: 855754, + genesisBlockHash: '000000000000000000021972c2000a8d347dbac1a2540112fadf81219b188796', + genesisTimestamp: 1723027746, + value: '546', + mimeType: 'text', + name: 'inscription', + src: 'https://bis-ord-content.fra1.cdn.digitaloceanspaces.com/ordinals/335209b72c452f52199ae09e8ce586a451ce452c73326f01f958d8aa8417e062i0', + }, + { + id: 'cd27e71f955e021dd0840aa0544067fc92c3608009f2191a405f9f4910712b78i0', + number: 55549412, + output: '0', + txid: 'cd27e71f955e021dd0840aa0544067fc92c3608009f2191a405f9f4910712b78', + offset: '0', + address: 'bc1pwz9n62p9dhjpqcpdmfcrewdnz3nk8jcved242vd2lj9fgvtvwnwscvdyre', + preview: + 'https://ordinals.hiro.so/inscription/cd27e71f955e021dd0840aa0544067fc92c3608009f2191a405f9f4910712b78i0', + title: 'Inscription 55549412', + genesisBlockHeight: 825933, + genesisBlockHash: '00000000000000000002f95317315f9d00b2299eb3499b0f499a707506ad6735', + genesisTimestamp: 1705356588, + value: '600', + mimeType: 'image', + name: 'inscription', + src: 'https://bis-ord-content.fra1.cdn.digitaloceanspaces.com/ordinals/cd27e71f955e021dd0840aa0544067fc92c3608009f2191a405f9f4910712b78i0', + }, + { + id: 'e59434da4436cbdcdcf6b7b31fb734d43b304e981a2e3b69092bd6ca83108009i1286', + number: 64484111, + output: '1287', + txid: 'e59434da4436cbdcdcf6b7b31fb734d43b304e981a2e3b69092bd6ca83108009', + offset: '0', + address: 'bc1pwz9n62p9dhjpqcpdmfcrewdnz3nk8jcved242vd2lj9fgvtvwnwscvdyre', + preview: + 'https://ordinals.hiro.so/inscription/e59434da4436cbdcdcf6b7b31fb734d43b304e981a2e3b69092bd6ca83108009i1286', + title: 'Inscription 64484111', + genesisBlockHeight: 834795, + genesisBlockHash: '00000000000000000000a3f2c9b0459df8eda99abca3c83f0e94a2a224badaba', + genesisTimestamp: 1710504509, + value: '546', + mimeType: 'gltf', + name: 'inscription', + src: 'https://ordinals.com/preview/e59434da4436cbdcdcf6b7b31fb734d43b304e981a2e3b69092bd6ca83108009i1286', + }, +]; + +export interface StacksNft { + token_uri: string; + metadata: { + sip: number; + name: string; + description: string; + image: string; + cached_image: string; + cached_thumbnail_image: string; + attributes?: Array<{ + trait_type: string; + value: string; + display_type?: string; + }>; + properties?: { + collection?: string; + collectionId?: string; + [key: string]: any; + }; + }; +} + +const mockStacksNfts: StacksNft[] = [ + { + token_uri: 'ipfs://ipfs/QmQ63rXC9F7GyLYoYNyqxeiYvbBUvmHmL36PrfYNxpw5sT/90.json', + metadata: { + sip: 16, + name: 'BlockSurvey #90', + description: 'Worlds First Software License as an NFT', + image: 'ipfs://QmZXkLMrN2ejpzGv1wk4HgcuL6XbyLVieW3Zm9wyAoDk18/90.png', + cached_image: + 'https://assets.hiro.so/api/mainnet/token-metadata-api/SPNWZ5V2TPWGQGVDR6T7B6RQ4XMGZ4PXTEE0VQ0S.blocksurvey/90.png', + cached_thumbnail_image: + 'https://assets.hiro.so/api/mainnet/token-metadata-api/SPNWZ5V2TPWGQGVDR6T7B6RQ4XMGZ4PXTEE0VQ0S.blocksurvey/90-thumb.png', + attributes: [ + { + trait_type: 'NightBackground', + value: 'MidnightMoss', + display_type: '', + }, + { + trait_type: 'NightLogo', + value: 'AtomicTangerine', + display_type: '', + }, + { + trait_type: 'SignatureWhite', + value: 'SignatureWhite', + display_type: '', + }, + ], + }, + }, + { + token_uri: 'ipfs://ipfs/QmWRQyaVxUjHGjBUoZqGcNjL37VN99jcFwmoB1wZnpjJEg/', + metadata: { + sip: 16, + name: 'Portals-ALEX-Anniversary-Series', + description: 'From proof-of-concept to bringing Bitcoin value to the multi-chain.\n', + image: 'ipfs://ipfs/QmUgHdbTy5LYi4wijf9YJgGs89SCAKNbXascvzuFgAsMB9', + cached_image: + 'https://assets.hiro.so/api/mainnet/token-metadata-api/SP3N7Y3K01Y24G9JC1XXA13RQXXCY721WAVBMMD38.alex-anniversary-series/1452.png', + cached_thumbnail_image: + 'https://assets.hiro.so/api/mainnet/token-metadata-api/SP3N7Y3K01Y24G9JC1XXA13RQXXCY721WAVBMMD38.alex-anniversary-series/1452-thumb.png', + properties: { + collection: 'ALEX Anniversary Series', + }, + }, + }, + { + token_uri: 'ipfs://QmYTX3u58v2Ero2drdtqhL6rPE5qnv51EJZ6WSu3LKqUBN/crashpunks-5559.json', + metadata: { + sip: 16, + name: 'Crash Punk 5559', + description: '', + image: 'ipfs://Qmb84UcaMr1MUwNbYBnXWHM3kEaDcYrKuPWwyRLVTNKELC/5559.png', + cached_image: + 'https://assets.hiro.so/api/mainnet/token-metadata-api/SP3QSAJQ4EA8WXEDSRRKMZZ29NH91VZ6C5X88FGZQ.crashpunks-v2/5559.png', + cached_thumbnail_image: + 'https://assets.hiro.so/api/mainnet/token-metadata-api/SP3QSAJQ4EA8WXEDSRRKMZZ29NH91VZ6C5X88FGZQ.crashpunks-v2/5559-thumb.png', + attributes: [ + { + trait_type: 'Background', + value: 'Blue', + display_type: 'string', + }, + { + trait_type: 'Outfit Back', + value: 'Stacks Hoodie Back', + display_type: 'string', + }, + { + trait_type: 'Neck', + value: 'Neck Metal', + display_type: 'string', + }, + { + trait_type: 'Outfit Front', + value: 'Stacks Hoodie', + display_type: 'string', + }, + { + trait_type: 'Head', + value: 'Head Tan', + display_type: 'string', + }, + { + trait_type: 'Piercings', + value: 'Piercings', + display_type: 'string', + }, + { + trait_type: 'Mouth', + value: 'Lips Bare', + display_type: 'string', + }, + { + trait_type: 'Eyes', + value: 'RoboEyes Blue', + display_type: 'string', + }, + { + trait_type: 'Hair', + value: 'Bob Silver', + display_type: 'string', + }, + ], + properties: { + collection: 'Crash Punks', + collectionId: 'grace.btc/crash_punks', + dna: '5c2f54662bb494b5e4ebc195070d9ce624c5a849', + total_supply: '9216', + external_url: + 'https://thisisnumberone.com/nfts/SP3QSAJQ4EA8WXEDSRRKMZZ29NH91VZ6C5X88FGZQ.crashpunks-v2/5559', + }, + }, + }, + { + token_uri: 'ipfs://ipfs/QmZYoSr94MKdarScJZSsyBYxBgMJchUQqqbtLxxxR86wZN/', + metadata: { + sip: 16, + name: 'WORRY - NFT - MUSIC', + description: + 'Musical NFT Collection \nWorry is a self-reflective song done by Brythreesixty also known as 3hunnatheartist. Worry is an emotional state of being anxious and troubled over actual or potential problems. The greatest weapon is positivity. Welcome to my Bullish state of Mind. This collection is a gift to the community. Enjoy \n\nhttps://gamma.io/3hunnatheartist.btc\nhttps://gamma.io/brythreesixty\n\nhttps://twitter.com/brythreesixty\nhttps://twitter.com/3hunnatheartist\n\nhttps://discord.gg/hRqeVRFG', + image: '', + cached_image: '', + cached_thumbnail_image: '', + }, + }, + { + token_uri: 'ipfs://QmbMdASbHZb5XHizZJsFPL9hdmuDgekUHH9Ya1DnuSxfHj/1547.json', + metadata: { + sip: 16, + name: 'StacksMFers #1547', + description: 'Just a bunch of mfers on stacks', + image: 'ipfs://QmUL7yELAmF1wnbqt6yaNLmCVbBa7BSbSNXYKijpku2r45/1547.png', + cached_image: + 'https://assets.hiro.so/api/mainnet/token-metadata-api/SP2N3BAG4GBF8NHRPH6AY4YYH1SP6NK5TGCY7RDFA.stacks-mfers/1547.png', + cached_thumbnail_image: + 'https://assets.hiro.so/api/mainnet/token-metadata-api/SP2N3BAG4GBF8NHRPH6AY4YYH1SP6NK5TGCY7RDFA.stacks-mfers/1547-thumb.png', + attributes: [ + { + trait_type: 'BG', + value: 'Pixels', + display_type: '', + }, + { + trait_type: 'Type', + value: 'Plain', + display_type: '', + }, + { + trait_type: 'Eyes', + value: 'Greenglasses', + display_type: '', + }, + { + trait_type: 'Mouth', + value: 'Smile', + display_type: '', + }, + { + trait_type: 'Beard', + value: 'None', + display_type: '', + }, + { + trait_type: 'Shirt', + value: 'GreenHoodie', + display_type: '', + }, + { + trait_type: 'Accessory', + value: 'None', + display_type: '', + }, + { + trait_type: 'LongHair', + value: 'LongBlue', + display_type: '', + }, + { + trait_type: 'Hat under', + value: 'RedBandana', + display_type: '', + }, + { + trait_type: 'Headphones', + value: 'Red', + display_type: '', + }, + { + trait_type: 'Smoke', + value: 'None', + display_type: '', + }, + ], + }, + }, +]; + +export type Collectible = Ordinal | StacksNft; + +export const mockCollectibles: (Ordinal | StacksNft)[] = [...mockOrdinals, ...mockStacksNfts]; diff --git a/packages/ui/src/components/collectibles/index.native.ts b/packages/ui/src/components/collectibles/index.native.ts new file mode 100644 index 000000000..b7b2d41da --- /dev/null +++ b/packages/ui/src/components/collectibles/index.native.ts @@ -0,0 +1,6 @@ +export * from './native/collectible-html.native'; +export * from './native/collectible-image.native'; +export * from './native/collectible-text.native'; +export * from './native/collectible-card.native'; +export * from './native/collectible-card-layout.native'; +export * from './collectibles.shared'; diff --git a/packages/ui/src/components/collectibles/index.web.ts b/packages/ui/src/components/collectibles/index.web.ts new file mode 100644 index 000000000..3b6b44330 --- /dev/null +++ b/packages/ui/src/components/collectibles/index.web.ts @@ -0,0 +1,8 @@ +export * from './web/collectible-audio.web'; +export * from './web/collectible-image.web'; +export * from './web/collectible-text.web'; +export * from './web/collectible-other.web'; +export * from './web/collectible-iframe.web'; +export * from './web/image-unavailable.web'; +export * from './web/inscription-preview-card/index.web'; +export * from './web/collectible-item.layout.web'; diff --git a/packages/ui/src/components/collectibles/native/collectible-card-layout.native.tsx b/packages/ui/src/components/collectibles/native/collectible-card-layout.native.tsx new file mode 100644 index 000000000..7eb255006 --- /dev/null +++ b/packages/ui/src/components/collectibles/native/collectible-card-layout.native.tsx @@ -0,0 +1,14 @@ +import { BaseTheme, BoxProps } from '@shopify/restyle'; + +import { Box, Theme } from '../../../../native'; +import { HasChildren } from '../../../utils/has-children.shared'; + +type CollectibleCardLayoutProps = BoxProps & HasChildren; + +export function CollectibleCardLayout({ children, ...props }: CollectibleCardLayoutProps) { + return ( + + {children} + + ); +} diff --git a/packages/ui/src/components/collectibles/native/collectible-card.native.stories.tsx b/packages/ui/src/components/collectibles/native/collectible-card.native.stories.tsx new file mode 100644 index 000000000..c7b124978 --- /dev/null +++ b/packages/ui/src/components/collectibles/native/collectible-card.native.stories.tsx @@ -0,0 +1,64 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { CollectibleCard } from './collectible-card.native'; + +const meta: Meta = { + title: 'Collectibles/CollectibleCard', + component: CollectibleCard, + tags: ['autodocs'], + argTypes: {}, + parameters: {}, + decorators: [Story => ], +}; + +export default meta; + +export const OrdinalHtmlCollectibleCardStory = { + args: { + mimeType: 'html', + name: 'Inscription 74703951', + src: 'https://ordinals.com/preview/a494e48bf7120c959239e8c544bc821ca4fb5a46e5fff79938943d434f252949i0', + type: 'inscription', + }, + argTypes: {}, +} satisfies StoryObj; + +export const OrdinalTextCollectibleCardStory = { + args: { + mimeType: 'text', + name: 'Inscription 73858867', + src: 'https://bis-ord-content.fra1.cdn.digitaloceanspaces.com/ordinals/335209b72c452f52199ae09e8ce586a451ce452c73326f01f958d8aa8417e062i0', + type: 'inscription', + }, + argTypes: {}, +} satisfies StoryObj; + +export const OrdinalImageCollectibleCardStory = { + args: { + mimeType: 'image', + name: 'Inscription 55549412', + src: 'https://bis-ord-content.fra1.cdn.digitaloceanspaces.com/ordinals/cd27e71f955e021dd0840aa0544067fc92c3608009f2191a405f9f4910712b78i0', + type: 'inscription', + }, + argTypes: {}, +} satisfies StoryObj; + +export const OrdinalGltfCollectibleCardStory = { + args: { + mimeType: 'gltf', + name: 'Inscription 64484111', + src: 'https://ordinals.com/preview/e59434da4436cbdcdcf6b7b31fb734d43b304e981a2e3b69092bd6ca83108009i1286', + type: 'inscription', + }, + argTypes: {}, +} satisfies StoryObj; + +export const StxNftCollectibleCardStory = { + args: { + mimeType: null, + name: 'BlockSurvey #90', + src: 'https://assets.hiro.so/api/mainnet/token-metadata-api/SPNWZ5V2TPWGQGVDR6T7B6RQ4XMGZ4PXTEE0VQ0S.blocksurvey/90.png', + type: 'stacks', + }, + argTypes: {}, +} satisfies StoryObj; diff --git a/packages/ui/src/components/collectibles/native/collectible-card.native.tsx b/packages/ui/src/components/collectibles/native/collectible-card.native.tsx new file mode 100644 index 000000000..bd8c9f0f9 --- /dev/null +++ b/packages/ui/src/components/collectibles/native/collectible-card.native.tsx @@ -0,0 +1,31 @@ +import { CollectibleHtml } from './collectible-html.native'; +import { CollectibleImage } from './collectible-image.native'; +import { CollectibleText } from './collectible-text.native'; + +export interface CollectibleCardProps { + name: string; + type: 'inscription' | 'stacks'; + src: string; + mimeType?: string | null; +} + +export function CollectibleCard({ name, type, src, mimeType }: CollectibleCardProps) { + const isOrdinal = type === 'inscription'; + + if (isOrdinal) { + switch (mimeType) { + // TODO: add audio support + // case 'audio': + // return ; + case 'text': + return ; + case 'html': + case 'gltf': + case 'svg': + case 'video': + return ; + } + } + + return ; +} diff --git a/packages/ui/src/components/collectibles/native/collectible-html.native.tsx b/packages/ui/src/components/collectibles/native/collectible-html.native.tsx new file mode 100644 index 000000000..ee763c9fa --- /dev/null +++ b/packages/ui/src/components/collectibles/native/collectible-html.native.tsx @@ -0,0 +1,15 @@ +import { WebView } from 'react-native-webview'; + +import { CollectibleCardLayout } from './collectible-card-layout.native'; + +interface CollectibleHtmlProps { + src: string; +} + +export function CollectibleHtml({ src }: CollectibleHtmlProps) { + return ( + + + + ); +} diff --git a/packages/ui/src/components/collectibles/native/collectible-image.native.tsx b/packages/ui/src/components/collectibles/native/collectible-image.native.tsx new file mode 100644 index 000000000..c8aec4a5e --- /dev/null +++ b/packages/ui/src/components/collectibles/native/collectible-image.native.tsx @@ -0,0 +1,15 @@ +import { Image } from 'react-native'; + +import { Box } from '../../../../native'; + +interface CollectibleImageProps { + alt: string; + source: string; +} +export function CollectibleImage({ alt, source }: CollectibleImageProps) { + return ( + + {alt} + + ); +} diff --git a/packages/ui/src/components/collectibles/native/collectible-text.native.tsx b/packages/ui/src/components/collectibles/native/collectible-text.native.tsx new file mode 100644 index 000000000..45b21940c --- /dev/null +++ b/packages/ui/src/components/collectibles/native/collectible-text.native.tsx @@ -0,0 +1,42 @@ +import { useEffect, useState } from 'react'; + +// import { sanitize } from 'dompurify'; +import { Text } from '../../../../native'; +import { CollectibleCardLayout } from './collectible-card-layout.native'; + +interface CollectibleTextProps { + src: string; +} + +export function CollectibleText({ src }: CollectibleTextProps) { + const [content, setContent] = useState(null); + + useEffect(() => { + const fetchContent = async () => { + try { + const response = await fetch(src); + if (!response.ok) { + throw new Error('Network response was not ok'); + } + const data = await response.json(); + setContent(JSON.stringify(data, null, 2)); + } catch (err) {} + }; + + void fetchContent(); + }, [src]); + + return ( + + + {/* FIXME + - implement alternative for dompurify in native + - I tried using jsdom as a polyfill but then hit an error Can't resolve 'vm' in jsdom + - maybe we should write our own sanitizer? + */} + {/* {content ? sanitize(content) : ''} */} + {content} + + + ); +} diff --git a/packages/ui/src/components/collectibles/web/collectible-audio.web.tsx b/packages/ui/src/components/collectibles/web/collectible-audio.web.tsx new file mode 100644 index 000000000..772b2f10e --- /dev/null +++ b/packages/ui/src/components/collectibles/web/collectible-audio.web.tsx @@ -0,0 +1,18 @@ +import { ReactNode } from 'react'; + +import { HeadsetIcon } from '../../../icons/headset-icon.web'; +import { CollectibleItemLayout, CollectibleItemLayoutProps } from './collectible-item.layout.web'; +import { CollectiblePlaceholderLayout } from './collectible-placeholder.layout.web'; + +interface CollectibleAudioProps extends Omit { + icon: ReactNode; +} +export function CollectibleAudio({ icon, ...props }: CollectibleAudioProps) { + return ( + + + + + + ); +} diff --git a/packages/ui/src/components/collectibles/web/collectible-hover.web.tsx b/packages/ui/src/components/collectibles/web/collectible-hover.web.tsx new file mode 100644 index 000000000..c79f64683 --- /dev/null +++ b/packages/ui/src/components/collectibles/web/collectible-hover.web.tsx @@ -0,0 +1,58 @@ +import { ReactNode } from 'react'; + +import { Box } from 'leather-styles/jsx'; + +import { ExternalLinkIcon } from '../../../icons/external-link-icon.web'; +import { IconButton } from '../../icon-button/icon-button.web'; + +interface CollectibleHoverProps { + collectibleTypeIcon?: ReactNode; + isHovered: boolean; + onClickCallToAction?(): void; +} +export function CollectibleHover({ + collectibleTypeIcon, + isHovered, + onClickCallToAction, +}: CollectibleHoverProps) { + return ( + + + {collectibleTypeIcon} + + {onClickCallToAction && ( + + } + onClick={e => { + e.stopPropagation(); + onClickCallToAction(); + }} + /> + + )} + + ); +} diff --git a/packages/ui/src/components/collectibles/web/collectible-iframe.web.tsx b/packages/ui/src/components/collectibles/web/collectible-iframe.web.tsx new file mode 100644 index 000000000..665b1dc70 --- /dev/null +++ b/packages/ui/src/components/collectibles/web/collectible-iframe.web.tsx @@ -0,0 +1,34 @@ +import { ReactNode, useState } from 'react'; + +import { CollectibleItemLayout, CollectibleItemLayoutProps } from './collectible-item.layout.web'; +import { Iframe } from './iframe.web'; +import { ImageUnavailable } from './image-unavailable.web'; + +interface CollectibleIframeProps extends Omit { + icon: ReactNode; + src: string; +} +export function CollectibleIframe({ icon, src, ...props }: CollectibleIframeProps) { + const [isError, setIsError] = useState(false); + + if (isError) + return ( + + + + ); + + return ( + +