diff --git a/android/build.gradle b/android/build.gradle index 3cec4d31..ae718c5f 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -50,6 +50,5 @@ allprojects { google() maven { url 'https://www.jitpack.io' } - jcenter() } } diff --git a/package.json b/package.json index bfd6df41..903df972 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@dfinity/principal": "^0.9.3", "@hookform/error-message": "^2.0.0", "@psychedelic/dab-js": "1.4.12", - "@psychedelic/plug-controller": "0.24.5", + "@psychedelic/plug-controller": "0.24.9", "@react-native-async-storage/async-storage": "^1.17.10", "@react-native-community/blur": "^4.2.0", "@react-native-community/clipboard": "^1.5.1", diff --git a/src/components/buttons/ScrollableButton/hooks/useScrollHandler.ts b/src/components/buttons/ScrollableButton/hooks/useScrollHandler.ts new file mode 100644 index 00000000..cd22c97a --- /dev/null +++ b/src/components/buttons/ScrollableButton/hooks/useScrollHandler.ts @@ -0,0 +1,19 @@ +import { useState } from 'react'; +import { NativeScrollEvent, NativeSyntheticEvent } from 'react-native'; + +function useScrollHanlder() { + const [scrollPosition, setScrollPosition] = useState(0); + + const handleOnScroll = ( + scrollEvent: NativeSyntheticEvent + ) => { + setScrollPosition(scrollEvent?.nativeEvent?.contentOffset.y); + }; + + return { + scrollPosition, + handleOnScroll, + }; +} + +export default useScrollHanlder; diff --git a/src/components/buttons/ScrollableButton/index.tsx b/src/components/buttons/ScrollableButton/index.tsx new file mode 100644 index 00000000..f3cd760d --- /dev/null +++ b/src/components/buttons/ScrollableButton/index.tsx @@ -0,0 +1,84 @@ +import React, { useEffect, useState } from 'react'; +import { StyleProp, TextStyle, View, ViewStyle } from 'react-native'; +import Animated, { + Easing, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; + +import { Touchable } from '@/components/common'; +import AddGradient from '@/icons/svg/AddGradient.svg'; + +import styles from './styles'; + +interface Props { + onPress: () => void; + text: string; + textWidth: number; + buttonStyle?: StyleProp; + imageStyle?: StyleProp; + textStyle?: StyleProp; + scrollPosition: number; +} + +function ScrollableButton({ + onPress, + text, + textWidth, + textStyle, + buttonStyle, + imageStyle, + scrollPosition, +}: Props) { + const [showFullButton, setShowFullButton] = useState(true); + const [currentScrollPosition, setCurrentScrollPosition] = useState(0); + + const animatedWidth = useSharedValue(textWidth); + + const animatedStyle = useAnimatedStyle(() => { + return { + width: withTiming(animatedWidth.value, { + duration: 200, + easing: Easing.bezier(0.25, 0.1, 0.25, 1), + }), + opacity: withTiming(animatedWidth.value > 0 ? 1 : 0, { + duration: 450, + }), + }; + }); + + useEffect(() => { + if (currentScrollPosition < scrollPosition) { + // Scroll down. + setShowFullButton(false); + animatedWidth.value = 0; + } else if (scrollPosition < currentScrollPosition || scrollPosition === 0) { + // Scroll up or start position. + setShowFullButton(true); + animatedWidth.value = textWidth; + } + setCurrentScrollPosition(scrollPosition); + }, [scrollPosition]); + + return ( + + + + + {text} + + + + ); +} + +export default ScrollableButton; diff --git a/src/components/buttons/ScrollableButton/styles.ts b/src/components/buttons/ScrollableButton/styles.ts new file mode 100644 index 00000000..922dc226 --- /dev/null +++ b/src/components/buttons/ScrollableButton/styles.ts @@ -0,0 +1,30 @@ +import { StyleSheet } from 'react-native'; + +import { Colors, FontStyles } from '@/constants/theme'; +import { fontMaker } from '@/utils/fonts'; + +export default StyleSheet.create({ + button: { + minWidth: 48, + height: 48, + flexDirection: 'row', + alignItems: 'center', + backgroundColor: Colors.ActionBlue, + borderRadius: 100, + paddingHorizontal: 16, + shadowColor: Colors.White.Pure, + shadowOffset: { width: 2, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 12, + elevation: 4, + }, + disabled: { + opacity: 0.2, + }, + text: { + ...fontMaker({ ...FontStyles.Body2, color: Colors.White.Pure }), + }, + marginText: { + marginLeft: 8, + }, +}); diff --git a/src/components/icons/svg/AddGradient.svg b/src/components/icons/svg/AddGradient.svg new file mode 100644 index 00000000..0ea68c87 --- /dev/null +++ b/src/components/icons/svg/AddGradient.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/constants/nfts.ts b/src/constants/nfts.ts index c3a5e2f4..1ca27871 100644 --- a/src/constants/nfts.ts +++ b/src/constants/nfts.ts @@ -1,5 +1,2 @@ -import { isAndroid } from './platform'; - -// Since we're having problems with Apple's approval we're disabling NFTs until we find a solution - -export const ENABLE_NFTS = isAndroid; +// TODO: Delete ENABLE_NFTS everywhere. +export const ENABLE_NFTS = true; diff --git a/src/constants/urls.ts b/src/constants/urls.ts index 9b9784b9..b8a0b046 100644 --- a/src/constants/urls.ts +++ b/src/constants/urls.ts @@ -1,6 +1,8 @@ export const dabFormUrl = 'https://dab-ooo.typeform.com/token-list'; export const customTokensUrl = 'https://docs.plugwallet.ooo/getting-started/custom-tokens/'; +export const custonNFTsUrl = + 'https://docs.plugwallet.ooo/getting-started/nfts/'; export const docsUrl = 'https://docs.plugwallet.ooo'; export const blogUrl = 'https://medium.com/plugwallet'; diff --git a/src/interfaces/keyring.ts b/src/interfaces/keyring.ts index ad09f566..4e802da8 100644 --- a/src/interfaces/keyring.ts +++ b/src/interfaces/keyring.ts @@ -1,4 +1,7 @@ +import { WalletNFTInfo } from '@psychedelic/plug-controller/dist/interfaces/plug_wallet'; + export type FungibleStandard = 'DIP20' | 'EXT'; +export type NonFungibleStandard = 'DIP721'; export type Standard = 'DIP20' | 'XTC' | 'WICP' | 'EXT' | 'ICP' | string; export interface StandardToken { @@ -27,3 +30,5 @@ export interface InferredTransaction { }; caller: string; } + +export interface CollectionInfo extends WalletNFTInfo {} diff --git a/src/redux/slices/user.ts b/src/redux/slices/user.ts index 165f2507..f7861557 100644 --- a/src/redux/slices/user.ts +++ b/src/redux/slices/user.ts @@ -4,7 +4,12 @@ import { createAsyncThunk, createSlice, isAnyOf } from '@reduxjs/toolkit'; import { JELLY_CANISTER_ID } from '@/constants/canister'; import { ENABLE_NFTS } from '@/constants/nfts'; -import { FungibleStandard, TokenBalance } from '@/interfaces/keyring'; +import { + CollectionInfo, + FungibleStandard, + NonFungibleStandard, + TokenBalance, +} from '@/interfaces/keyring'; import { Asset, Collection, @@ -301,7 +306,7 @@ export const editContact = createAsyncThunk< 'user/editContact', async ({ contact, newContact }, { getState, rejectWithValue }) => { try { - const state = getState(); + const state = getState() as State; const instance = KeyRing.getInstance(); const removeContactRes = await instance?.deleteContact({ addressName: contact.name, @@ -351,7 +356,6 @@ export const addCustomToken = createAsyncThunk< onSuccess?.(); return user.assets; } - const registeredToken = await instance?.registerToken({ canisterId: canisterId.toString(), standard, @@ -421,6 +425,66 @@ export const getTokenInfo = createAsyncThunk( } ); +export const getCollectionInfo = createAsyncThunk( + 'user/getCollectionInfo', + async ( + { + collection, + onSuccess, + onFailure, + }: { + collection: { canisterId: string; standard: NonFungibleStandard }; + onSuccess: (collectionInfo: CollectionInfo) => void; + onFailure: () => void; + }, + { rejectWithValue } + ) => { + const instance = KeyRing.getInstance(); + try { + const collectionInfo = await instance?.getNFTInfo(collection); + onSuccess(collectionInfo); + } catch (e: any) { + console.log('Error while fetching Collection info', e); + onFailure?.(); + return rejectWithValue(e.message); + } + } +); + +// eslint-disable-next-line no-spaced-func +export const addCustomCollection = createAsyncThunk< + Collection[], + { + nft: { canisterId: string; standard: NonFungibleStandard }; + onSuccess: () => void; + onFailure: (e: string) => void; + }, + { rejectValue: string; state: State } +>( + 'user/addCustomCollection', + async ({ nft, onSuccess, onFailure }, { rejectWithValue, getState }) => { + const state = getState(); + const instance = KeyRing.getInstance(); + try { + const registeredCollection = recursiveParseBigint( + await instance?.registerNFT(nft) + ); + + const totalCollections = [ + ...state.user.collections, + registeredCollection, + ] as Collection[]; + + onSuccess(); + return totalCollections; + } catch (e: any) { + onFailure(e.message); + console.log('Error while adding custom collection:', e); + return rejectWithValue(e.message); + } + } +); + export const addConnectedApp = createAsyncThunk( 'user/addConnectedApp', async ( @@ -537,6 +601,16 @@ export const userSlice = createSlice({ state.collectionsError = action.payload; state.collectionsLoading = false; }) + .addCase(addCustomCollection.fulfilled, (state, action) => { + state.collections = action.payload ?? []; + state.collectionsLoading = false; + }) + .addCase(addCustomCollection.pending, state => { + state.collectionsLoading = true; + }) + .addCase(addCustomCollection.rejected, state => { + state.collectionsLoading = false; + }) .addCase(getTransactions.pending, state => { state.transactionsError = undefined; state.transactionsLoading = true; diff --git a/src/screens/flows/WalletConnect/screens/Flows/components/RequestCall/index.tsx b/src/screens/flows/WalletConnect/screens/Flows/components/RequestCall/index.tsx index 67d4e09b..580d6323 100644 --- a/src/screens/flows/WalletConnect/screens/Flows/components/RequestCall/index.tsx +++ b/src/screens/flows/WalletConnect/screens/Flows/components/RequestCall/index.tsx @@ -116,7 +116,7 @@ function RequestCall(props: Props) { - {t('walletConnect.learnMore')} + {t('common.learnMore')} )} diff --git a/src/screens/tabs/NFTs/components/AddCollection/index.tsx b/src/screens/tabs/NFTs/components/AddCollection/index.tsx new file mode 100644 index 00000000..b5e11a1c --- /dev/null +++ b/src/screens/tabs/NFTs/components/AddCollection/index.tsx @@ -0,0 +1,76 @@ +import { t } from 'i18next'; +import React, { useRef, useState } from 'react'; +import { Modalize } from 'react-native-modalize'; + +import ScrollableButton from '@/components/buttons/ScrollableButton'; +import { ActionButton, Header, Modal, Text } from '@/components/common'; +import { CollectionInfo } from '@/interfaces/keyring'; + +import CustomCollection from '../CustomCollection'; +import ReviewCollection from '../ReviewCollection'; +import styles from './styles'; + +interface Props { + scrollPosition: number; +} + +function AddCollection({ scrollPosition }: Props) { + const modalRef = useRef(null); + const [selectedCollection, setSelectedCollection] = + useState(); + const showReviewCollection = !!selectedCollection; + + const handleModalClose = () => { + modalRef.current?.close(); + cleanState(); + }; + + const cleanState = () => setSelectedCollection(undefined); + + return ( + <> + modalRef?.current?.open()} + buttonStyle={styles.buttonContainer} + /> + + ) + } + center={ + + {t('addCollection.customCollection')} + + } + right={ + + } + /> + }> + {showReviewCollection ? ( + + ) : ( + + )} + + + ); +} +export default AddCollection; diff --git a/src/screens/tabs/NFTs/components/AddCollection/styles.ts b/src/screens/tabs/NFTs/components/AddCollection/styles.ts new file mode 100644 index 00000000..41b92be2 --- /dev/null +++ b/src/screens/tabs/NFTs/components/AddCollection/styles.ts @@ -0,0 +1,15 @@ +import { StyleSheet } from 'react-native'; + +import { Colors } from '@/constants/theme'; + +export default StyleSheet.create({ + buttonContainer: { + position: 'absolute', + bottom: 16, + right: 14, + borderRadius: 100, + }, + title: { + color: Colors.White.Primary, + }, +}); diff --git a/src/screens/tabs/NFTs/components/CustomCollection/index.tsx b/src/screens/tabs/NFTs/components/CustomCollection/index.tsx new file mode 100644 index 00000000..355d419c --- /dev/null +++ b/src/screens/tabs/NFTs/components/CustomCollection/index.tsx @@ -0,0 +1,158 @@ +import { t } from 'i18next'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { Keyboard, Linking, View } from 'react-native'; +import { Modalize } from 'react-native-modalize'; + +import Button from '@/components/buttons/Button'; +import RainbowButton from '@/components/buttons/RainbowButton'; +import { ActionSheet, Text, TextInput } from '@/components/common'; +import Icon from '@/components/icons'; +import { Colors } from '@/constants/theme'; +import { custonNFTsUrl } from '@/constants/urls'; +import { CollectionInfo, NonFungibleStandard } from '@/interfaces/keyring'; +import { useAppDispatch } from '@/redux/hooks'; +import { getCollectionInfo } from '@/redux/slices/user'; +import { validateCanisterId } from '@/utils/ids'; + +import styles, { iconColor } from './styles'; +interface Props { + setSelectedCollection: (collectionInfo: CollectionInfo) => void; +} + +function CustomCollection({ setSelectedCollection }: Props) { + const dispatch = useAppDispatch(); + const optionsRef = useRef(null); + + const [loading, setLoading] = useState(false); + const [collectionError, setCollectionError] = useState(false); + const [canisterId, setCanisterId] = useState(''); + const [canisterIdError, setCanisterIdError] = useState(false); + const [standard, setStandard] = useState('DIP721'); + + const error = canisterIdError || collectionError; + + const handleIdChange = (text: string) => { + setCanisterId(text); + setCanisterIdError(!validateCanisterId(text)); + setCollectionError(false); + }; + + const clearValues = () => { + setLoading(false); + setCanisterId(''); + setStandard('DIP721'); + setCollectionError(false); + setCanisterIdError(false); + }; + + const handleStandardChange = useCallback((selected: NonFungibleStandard) => { + setStandard(selected); + setCollectionError(false); + }, []); + + const handleStandardPress = () => { + Keyboard.dismiss(); + optionsRef?.current?.open(); + }; + + const standardList = useMemo( + () => [ + { + id: 1, + label: 'DIP721', + onPress: () => handleStandardChange('DIP721'), + }, + ], + [handleStandardChange] + ); + + const handleSubmit = () => { + Keyboard.dismiss(); + if (validateCanisterId(canisterId)) { + setLoading(true); + setCanisterIdError(false); + dispatch( + getCollectionInfo({ + collection: { canisterId, standard }, + onSuccess: collection => { + setSelectedCollection(collection); + clearValues(); + }, + onFailure: () => { + setCollectionError(true); + setLoading(false); + }, + }) + ); + } else { + setCanisterIdError(true); + } + }; + + const handleLinkPress = () => { + Linking.canOpenURL(custonNFTsUrl).then(() => + Linking.openURL(custonNFTsUrl) + ); + }; + + return ( + + + {error && ( + + + + {collectionError + ? t('addCollection.canisterNotCompatible', { standard }) + : t('addCollection.invalidCanisterId')} + {canisterIdError && ( + + {t('common.learnMore')} + + )} + + + )} +