From a72756cf31dcc01d9dea966991e7d8a4d8bd0ffc Mon Sep 17 00:00:00 2001 From: Oleg Chendighelean Date: Tue, 4 Feb 2025 17:50:10 +0000 Subject: [PATCH] feat: add draft send tez signing --- apps/mobile/app/(auth)/Home.tsx | 4 + apps/mobile/app/_layout.tsx | 31 +- .../ModalCloseButton/ModalCloseButton.tsx | 21 + .../components/ModalCloseButton/index.ts | 1 + .../mobile/components/SendFlow/SignButton.tsx | 156 +++++++ .../SendFlow/SuccessStep/SuccessStep.tsx | 67 +++ .../components/SendFlow/SuccessStep/index.ts | 1 + .../components/SendFlow/Tez/FormPage.tsx | 111 +++++ .../components/SendFlow/Tez/SignPage.tsx | 58 +++ apps/mobile/components/SendFlow/Tez/index.ts | 2 + .../SendFlow/onSubmitFormActionHooks.tsx | 148 +++++++ apps/mobile/components/SendFlow/types.ts | 9 + apps/mobile/components/SendFlow/utils.tsx | 241 +++++++++++ apps/mobile/ios/Podfile.lock | 47 ++ .../ios/mobile.xcodeproj/project.pbxproj | 2 + apps/mobile/package.json | 5 +- apps/mobile/providers/ModalProvider.tsx | 75 ++++ apps/mobile/screens/Home/Home.tsx | 25 +- .../components/ActionButton/ActionButton.tsx | 16 +- .../BalanceDisplay/BalanceDisplay.tsx | 8 +- apps/mobile/store/index.ts | 3 +- .../src/components/SendFlow/Tez/FormPage.tsx | 1 - pnpm-lock.yaml | 404 ++++++++++++++++-- 23 files changed, 1369 insertions(+), 67 deletions(-) create mode 100644 apps/mobile/components/ModalCloseButton/ModalCloseButton.tsx create mode 100644 apps/mobile/components/ModalCloseButton/index.ts create mode 100644 apps/mobile/components/SendFlow/SignButton.tsx create mode 100644 apps/mobile/components/SendFlow/SuccessStep/SuccessStep.tsx create mode 100644 apps/mobile/components/SendFlow/SuccessStep/index.ts create mode 100644 apps/mobile/components/SendFlow/Tez/FormPage.tsx create mode 100644 apps/mobile/components/SendFlow/Tez/SignPage.tsx create mode 100644 apps/mobile/components/SendFlow/Tez/index.ts create mode 100644 apps/mobile/components/SendFlow/onSubmitFormActionHooks.tsx create mode 100644 apps/mobile/components/SendFlow/types.ts create mode 100644 apps/mobile/components/SendFlow/utils.tsx create mode 100644 apps/mobile/providers/ModalProvider.tsx diff --git a/apps/mobile/app/(auth)/Home.tsx b/apps/mobile/app/(auth)/Home.tsx index 1b900f8230..5d720cd429 100644 --- a/apps/mobile/app/(auth)/Home.tsx +++ b/apps/mobile/app/(auth)/Home.tsx @@ -1,5 +1,9 @@ +import { useDataPolling } from "@umami/data-polling"; + import { Home as HomeScreen } from "../../screens/Home"; export default function Home() { + useDataPolling(); + return ; } diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index 64f4398e9c..fad3f51db3 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -8,7 +8,8 @@ import { TamaguiProvider } from "tamagui"; import { PersistorLoader } from "../components/PersistorLoader"; import { AuthProvider, ReactQueryProvider } from "../providers"; -import store, { persistor } from "../store/store"; +import { ModalProvider } from "../providers/ModalProvider"; +import { persistor, store } from "../store"; import { tamaguiConfig } from "../tamagui.config"; export default function RootLayout() { @@ -19,18 +20,20 @@ export default function RootLayout() { }, []); return ( - - - - - } persistor={persistor}> - - - - - - - - + + } persistor={persistor}> + + + + + + + + + + + + + ); } diff --git a/apps/mobile/components/ModalCloseButton/ModalCloseButton.tsx b/apps/mobile/components/ModalCloseButton/ModalCloseButton.tsx new file mode 100644 index 0000000000..89a6b676a6 --- /dev/null +++ b/apps/mobile/components/ModalCloseButton/ModalCloseButton.tsx @@ -0,0 +1,21 @@ +import { X } from "@tamagui/lucide-icons"; +import { Button, styled } from "tamagui"; + +import { useModal } from "../../providers/ModalProvider"; + +export const ModalCloseButton = () => { + const { hideModal } = useModal(); + + return } onPress={hideModal} />; +}; + +const CloseButton = styled(Button, { + position: "absolute", + top: 0, + right: 0, + zIndex: 1000, + borderRadius: 100, + width: "auto", + height: "auto", + padding: 10, +}); diff --git a/apps/mobile/components/ModalCloseButton/index.ts b/apps/mobile/components/ModalCloseButton/index.ts new file mode 100644 index 0000000000..9bdd43a704 --- /dev/null +++ b/apps/mobile/components/ModalCloseButton/index.ts @@ -0,0 +1 @@ +export * from "./ModalCloseButton"; diff --git a/apps/mobile/components/SendFlow/SignButton.tsx b/apps/mobile/components/SendFlow/SignButton.tsx new file mode 100644 index 0000000000..6cdafb66c6 --- /dev/null +++ b/apps/mobile/components/SendFlow/SignButton.tsx @@ -0,0 +1,156 @@ +import { type TezosToolkit } from "@taquito/taquito"; +import type { BatchWalletOperation } from "@taquito/taquito/dist/types/wallet/batch-operation"; +import { + type ImplicitAccount, + type LedgerAccount, + type MnemonicAccount, + type SecretKeyAccount, + type SocialAccount, +} from "@umami/core"; +import { useAsyncActionHandler, useGetSecretKey, useSelectedNetwork } from "@umami/state"; +import { type Network, makeToolkit } from "@umami/tezos"; +import { useCustomToast } from "@umami/utils"; +import { FormProvider, useForm, useFormContext } from "react-hook-form"; +import { Button, Input, Label, Text, View, YStack } from "tamagui"; + +import { forIDP } from "../../services/auth"; + +export const SignButton = ({ + signer, + onSubmit, + isLoading: externalIsLoading, + isDisabled, + text = "Confirm Transaction", + network: preferredNetwork, +}: { + onSubmit: (tezosToolkit: TezosToolkit) => Promise; + signer: ImplicitAccount; + isLoading?: boolean; + isDisabled?: boolean; + text?: string; // TODO: after FillStep migration change to the header value from SignPage + network?: Network; +}) => { + const form = useForm<{ password: string }>({ mode: "onBlur", defaultValues: { password: "" } }); + const { + handleSubmit, + formState: { errors, isValid: isPasswordValid }, + } = form; + let network = useSelectedNetwork(); + if (preferredNetwork) { + network = preferredNetwork; + } + + const { + formState: { isValid: isOuterFormValid }, + } = useFormContext(); + + const isButtonDisabled = isDisabled || !isPasswordValid || !isOuterFormValid; + + const getSecretKey = useGetSecretKey(); + const toast = useCustomToast(); + const { isLoading: internalIsLoading, handleAsyncAction } = useAsyncActionHandler(); + const isLoading = internalIsLoading || externalIsLoading; + + const onMnemonicSign = async ({ password }: { password: string }) => + handleAsyncAction(async () => { + const secretKey = await getSecretKey(signer as MnemonicAccount, password); + return onSubmit(await makeToolkit({ type: "mnemonic", secretKey, network })); + }); + + const onSecretKeySign = async ({ password }: { password: string }) => + handleAsyncAction(async () => { + const secretKey = await getSecretKey(signer as SecretKeyAccount, password); + return onSubmit(await makeToolkit({ type: "secret_key", secretKey, network })); + }); + + const onSocialSign = async () => + handleAsyncAction(async () => { + const { secretKey } = await forIDP((signer as SocialAccount).idp).getCredentials(); + return onSubmit(await makeToolkit({ type: "social", secretKey, network })); + }); + + const onLedgerSign = async () => + handleAsyncAction( + async () => { + toast({ + id: "ledger-sign-toast", + description: "Please approve the operation on your Ledger", + status: "info", + duration: 60000, + isClosable: true, + }); + return onSubmit( + await makeToolkit({ + type: "ledger", + account: signer as LedgerAccount, + network, + }) + ); + }, + (error: any) => ({ + description: `${error.message} Please connect your ledger, open Tezos app and try submitting transaction again`, + status: "error", + }) + ).finally(() => toast.close("ledger-sign-toast")); + + switch (signer.type) { + case "secret_key": + case "mnemonic": + return ( + + + + + + + {errors.password && {errors.password.message}} + + + + + + ); + case "social": + return ( + + ); + case "ledger": + return ( + + ); + } +}; diff --git a/apps/mobile/components/SendFlow/SuccessStep/SuccessStep.tsx b/apps/mobile/components/SendFlow/SuccessStep/SuccessStep.tsx new file mode 100644 index 0000000000..751d740cca --- /dev/null +++ b/apps/mobile/components/SendFlow/SuccessStep/SuccessStep.tsx @@ -0,0 +1,67 @@ +import { Check, ExternalLink } from "@tamagui/lucide-icons"; +import { useSelectedNetwork } from "@umami/state"; +import * as Linking from "expo-linking"; +import { useRouter } from "expo-router"; +import { Button, Dialog, Text, YStack } from "tamagui"; + +type SuccessStepProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + hash: string; +}; + +export const SuccessStep = ({ open, onOpenChange, hash }: SuccessStepProps) => { + const network = useSelectedNetwork(); + const router = useRouter(); + const tzktUrl = `${network.tzktExplorerUrl}/${hash}`; + + const handleViewOperations = () => { + onOpenChange(false); + router.push("/home"); + }; + + const handleViewInTzkt = async () => { + await Linking.openURL(tzktUrl); + }; + + return ( + + + + + + + + + + Operation Submitted + + + + You can follow this operation's progress in the Operations section.{"\n"} + It may take up to 30 seconds to appear. + + + + + + + + + + + + ); +}; diff --git a/apps/mobile/components/SendFlow/SuccessStep/index.ts b/apps/mobile/components/SendFlow/SuccessStep/index.ts new file mode 100644 index 0000000000..d1b42a3c70 --- /dev/null +++ b/apps/mobile/components/SendFlow/SuccessStep/index.ts @@ -0,0 +1 @@ +export * from "./SuccessStep"; diff --git a/apps/mobile/components/SendFlow/Tez/FormPage.tsx b/apps/mobile/components/SendFlow/Tez/FormPage.tsx new file mode 100644 index 0000000000..1e3a894523 --- /dev/null +++ b/apps/mobile/components/SendFlow/Tez/FormPage.tsx @@ -0,0 +1,111 @@ +import { type TezTransfer, getSmallestUnit } from "@umami/core"; +import { type RawPkh, TEZ_DECIMALS, parsePkh, tezToMutez } from "@umami/tezos"; +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { Form, Input, Label, Text, XStack, YStack } from "tamagui"; + +import { SignPage } from "./SignPage"; +import { ModalCloseButton } from "../../ModalCloseButton"; +import { + useAddToBatchFormAction, + useHandleOnSubmitFormActions, + useOpenSignPageFormAction, +} from "../onSubmitFormActionHooks"; +import { + type FormPageProps, + FormSubmitButton, + formDefaultValues, + makeValidateDecimals, +} from "../utils"; + +type FormValues = { + sender: RawPkh; + recipient: RawPkh; + prettyAmount: string; +}; + +export const FormPage = ({ ...props }: FormPageProps) => { + const openSignPage = useOpenSignPageFormAction({ + SignPage, + FormPage, + defaultFormPageProps: props, + toOperation, + }); + + const addToBatch = useAddToBatchFormAction(toOperation); + + const { + onFormSubmitActionHandlers: [onSingleSubmit], + isLoading, + } = useHandleOnSubmitFormActions([openSignPage, addToBatch]); + + const form = useForm({ + defaultValues: formDefaultValues(props), + mode: "onBlur", + }); + + const { + formState: { errors }, + handleSubmit, + } = form; + + return ( + +
+ + + + {props.sender?.address.pkh} + + + + ( + + )} + rules={{ + required: "Amount is required", + validate: makeValidateDecimals(TEZ_DECIMALS), + }} + /> + {errors.prettyAmount && ( + {errors.prettyAmount.message} + )} + + + + ( + + )} + rules={{ + required: "Recipient is required", + }} + /> + {errors.recipient && ( + {errors.recipient.message} + )} + + + + + +
+ ); +}; + +const toOperation = (formValues: FormValues): TezTransfer => ({ + type: "tez", + amount: tezToMutez(formValues.prettyAmount).toFixed(), + recipient: parsePkh(formValues.recipient), +}); diff --git a/apps/mobile/components/SendFlow/Tez/SignPage.tsx b/apps/mobile/components/SendFlow/Tez/SignPage.tsx new file mode 100644 index 0000000000..4679511cf3 --- /dev/null +++ b/apps/mobile/components/SendFlow/Tez/SignPage.tsx @@ -0,0 +1,58 @@ +import { type TezTransfer } from "@umami/core"; +import { prettyTezAmount } from "@umami/tezos"; +import { FormProvider } from "react-hook-form"; +import { Form, Text, XStack, YStack } from "tamagui"; + +import { ModalCloseButton } from "../../ModalCloseButton"; +import { SignButton } from "../SignButton"; +import { type SignPageProps, useSignPageHelpers } from "../utils"; + +export const SignPage = (props: SignPageProps) => { + const { fee, operations, estimationFailed, isLoading, form, signer, onSign } = useSignPageHelpers( + props.operations + ); + + const { amount: mutezAmount, recipient } = operations.operations[0] as TezTransfer; + + return ( + +
+ + + + Sign Transaction + + + + + Amount + {prettyTezAmount(mutezAmount)} + + Fee: {prettyTezAmount(fee)} + + + + + From + {operations.sender.address.pkh} + + + + To + {recipient.pkh} + + + + + + + + +
+ ); +}; diff --git a/apps/mobile/components/SendFlow/Tez/index.ts b/apps/mobile/components/SendFlow/Tez/index.ts new file mode 100644 index 0000000000..297f1b36d8 --- /dev/null +++ b/apps/mobile/components/SendFlow/Tez/index.ts @@ -0,0 +1,2 @@ +export * from "./FormPage"; +export * from "./SignPage"; diff --git a/apps/mobile/components/SendFlow/onSubmitFormActionHooks.tsx b/apps/mobile/components/SendFlow/onSubmitFormActionHooks.tsx new file mode 100644 index 0000000000..03468d2b18 --- /dev/null +++ b/apps/mobile/components/SendFlow/onSubmitFormActionHooks.tsx @@ -0,0 +1,148 @@ +import { useToast } from "@chakra-ui/react"; +import { type EstimatedAccountOperations, type Operation, estimate } from "@umami/core"; +import { + estimateAndUpdateBatch, + useAppDispatch, + useAsyncActionHandler, + useSelectedNetwork, +} from "@umami/state"; +import { type FunctionComponent } from "react"; + +import { + type BaseFormValues, + type FormPageProps, + type SignPageProps, + useMakeFormOperations, +} from "./utils"; +import { useModal } from "../../providers/ModalProvider"; + +// This file defines hooks to create actions when form is submitted. + +type OnSubmitFormAction = ( + formValues: FormValues +) => Promise; + +type UseOpenSignPageArgs< + ExtraData, + FormValues extends BaseFormValues, + FormProps extends FormPageProps, +> = { + // Sign page component to render. + SignPage: FunctionComponent>; + // Extra data to pass to the Sign page component (e.g. NFT or Token) + signPageExtraData?: ExtraData; + // Form page component to render when the user goes back from the sign page. + FormPage: FunctionComponent; + // Form page props, used to render the form page again when the user goes back from the sign page + defaultFormPageProps: FormProps; + // Function to convert raw form values to the Operation type we can work with + // to submit operations. + toOperation: (formValues: FormValues) => Operation; +}; + +// Hook to open the sign page that knows how to get back to the form page. +export const useOpenSignPageFormAction = < + SignPageData, + FormValues extends BaseFormValues, + FormProps extends FormPageProps, +>({ + SignPage, + signPageExtraData, + FormPage, + defaultFormPageProps, + toOperation, +}: UseOpenSignPageArgs): OnSubmitFormAction => { + const makeFormOperations = useMakeFormOperations(toOperation); + const network = useSelectedNetwork(); + const { showModal } = useModal(); + + return async (formValues: FormValues) => { + try { + console.log(formValues, network); + const operations = makeFormOperations(formValues); + const estimatedOperations = await estimate(operations, network); + return showModal( + + showModal( + + ) + } + mode="single" + operations={estimatedOperations} + /> + ); + } catch (e) { + console.log(e); + } + }; +}; + +export const useAddToBatchFormAction = ( + toOperation: (formValues: FormValues) => Operation +): OnSubmitFormAction => { + const network = useSelectedNetwork(); + const makeFormOperations = useMakeFormOperations(toOperation); + const dispatch = useAppDispatch(); + const toast = useToast(); + + const onAddToBatchAction = async (formValues: FormValues) => { + const operations = makeFormOperations(formValues); + await dispatch(estimateAndUpdateBatch(operations, network)); + toast({ description: "Transaction added to batch!", status: "success" }); + // onClose(); + }; + + return onAddToBatchAction; +}; + +// Wraps the OnSubmitFormActions in a async action handler that shows a toast if the action fails. +// If any of the actions is loading then isLoading will be true. +export const useHandleOnSubmitFormActions = ( + onSubmitFormActions: OnSubmitFormAction[] +) => { + const { handleAsyncAction, isLoading } = useAsyncActionHandler(); + + const onFormSubmitActionHandlers = onSubmitFormActions.map( + action => async (formValues: FormValues) => handleAsyncAction(() => action(formValues)) + ); + + return { + onFormSubmitActionHandlers, + isLoading, + }; +}; + +export function usePreviewOperations< + FormValues extends BaseFormValues, + SignPageProps extends { operations: EstimatedAccountOperations } = { + operations: EstimatedAccountOperations; + }, +>( + toOperation: (formValues: FormValues) => Operation | Operation[], + SignPage: FunctionComponent, + props: Omit +) { + const network = useSelectedNetwork(); + const makeFormOperations = useMakeFormOperations(toOperation); + const { handleAsyncAction, isLoading } = useAsyncActionHandler(); + const { showModal } = useModal(); + + return { + isLoading, + previewOperation: (formValues: FormValues) => + handleAsyncAction(async () => { + const operations = makeFormOperations(formValues); + const estimatedOperations = await estimate(operations, network); + + return showModal( + + ); + }), + }; +} +export const usePreviewOperation = usePreviewOperations; diff --git a/apps/mobile/components/SendFlow/types.ts b/apps/mobile/components/SendFlow/types.ts new file mode 100644 index 0000000000..bdd8acb89e --- /dev/null +++ b/apps/mobile/components/SendFlow/types.ts @@ -0,0 +1,9 @@ +import { type Operations } from "@umami/core"; + +export interface SignDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + operations: Operations; + mode: "single" | "batch"; + data?: unknown; +} diff --git a/apps/mobile/components/SendFlow/utils.tsx b/apps/mobile/components/SendFlow/utils.tsx new file mode 100644 index 0000000000..4bb832871e --- /dev/null +++ b/apps/mobile/components/SendFlow/utils.tsx @@ -0,0 +1,241 @@ +import { type SigningType } from "@airgap/beacon-wallet"; +import { type TezosToolkit } from "@taquito/taquito"; +import { + type Account, + type AccountOperations, + type EstimatedAccountOperations, + type ImplicitAccount, + type Operation, + estimate, + executeOperations, + makeAccountOperations, + totalFee, +} from "@umami/core"; +import { + useAsyncActionHandler, + useGetBestSignerForAccount, + useGetImplicitAccount, + useGetOwnedAccount, + useSelectedNetwork, +} from "@umami/state"; +import { type ExecuteParams, type Network, type RawPkh } from "@umami/tezos"; +import * as Linking from "expo-linking"; +import { repeat } from "lodash"; +import { useState } from "react"; +import { useForm, useFormContext } from "react-hook-form"; +import { Alert } from "react-native"; +import { Button, type ButtonProps } from "tamagui"; + +import { useModal } from "../../providers/ModalProvider"; + +// Convert given optional fields to required +// For example: +// type A = {a?:number, b:string} +// RequiredFields === {a:number, b:string} +type RequiredFields = Omit & Required>; + +export type FormPageProps = { sender?: Account; form?: T }; + +// FormPagePropsWithSender is the same as FormPageProps but with sender required, +// Use this when we don't want to give the users options to select the sender +// (e.g. the nft and token form) +export type FormPagePropsWithSender = RequiredFields, "sender">; + +// Form values should always have a sender field. +export type BaseFormValues = { sender: RawPkh }; + +export type SignPageMode = "single" | "batch"; + +export type SignPageProps = { + goBack?: () => void; + operations: EstimatedAccountOperations; + mode: SignPageMode; + data?: T; +}; + +export type CalculatedSignProps = { + fee: number; + isSigning: boolean; + onSign: (tezosToolkit: TezosToolkit) => Promise; + network: any; +}; + +export type sdkType = "beacon" | "walletconnect"; + +export type SignRequestId = + | { + sdkType: "beacon"; + id: string; + } + | { + sdkType: "walletconnect"; + id: number; + topic: string; + }; + +export type SignHeaderProps = { + network: Network; + appName: string; + appIcon?: string; + isScam?: boolean; + validationStatus?: "VALID" | "INVALID" | "UNKNOWN"; + requestId: SignRequestId; +}; + +export type SdkSignPageProps = { + operation: EstimatedAccountOperations; + headerProps: SignHeaderProps; +}; + +export type SignPayloadProps = { + requestId: SignRequestId; + appName: string; + appIcon?: string; + payload: string; + isScam?: boolean; + validationStatus?: "VALID" | "INVALID" | "UNKNOWN"; + signer: ImplicitAccount; + signingType: SigningType; +}; + +export const FormSubmitButton = ({ title = "Preview", ...props }: ButtonProps) => { + const { + formState: { isValid }, + } = useFormContext(); + + return ( + + ); +}; + +export const formDefaultValues = ({ sender, form }: FormPageProps) => { + if (form) { + return form; + } else if (sender) { + return { sender: sender.address.pkh }; + } else { + return {}; + } +}; + +// TODO: test this +export const useSignPageHelpers = ( + // the fee & operations you've got from the form + initialOperations: EstimatedAccountOperations +) => { + const [estimationFailed, setEstimationFailed] = useState(false); + const getSigner = useGetImplicitAccount(); + const [operations, setOperations] = useState(initialOperations); + const network = useSelectedNetwork(); + const { isLoading, handleAsyncAction, handleAsyncActionUnsafe } = useAsyncActionHandler(); + // const { openWith } = useDynamicModalContext(); + const { hideModal } = useModal(); + + const form = useForm<{ + sender: string; + signer: string; + executeParams: ExecuteParams[]; + }>({ + mode: "onBlur", + defaultValues: { + signer: operations.signer.address.pkh, + sender: operations.sender.address.pkh, + executeParams: operations.estimates, + }, + }); + + const signer = form.watch("signer"); + + // if it fails then the sign button must be disabled + // and the user is supposed to either come back to the form and amend it + // or choose another signer + const reEstimate = async (newSigner: RawPkh) => + handleAsyncActionUnsafe( + async () => { + const newOperations = await estimate( + { + ...operations, + signer: getSigner(newSigner), + }, + network + ); + form.setValue("executeParams", newOperations.estimates); + setOperations(newOperations); + setEstimationFailed(false); + }, + { + isClosable: true, + duration: null, // it makes the toast stick until the user closes it + } + ).catch(() => setEstimationFailed(true)); + + const onSign = async (tezosToolkit: TezosToolkit) => + handleAsyncAction(async () => { + const operation = await executeOperations( + { ...operations, estimates: form.watch("executeParams") }, + tezosToolkit + ); + // await openWith(); + Alert.alert("Success", operation.opHash, [ + { + text: "Close", + onPress: () => { + hideModal(); + }, + }, + { + text: "Open in Explorer", + onPress: () => { + void Linking.openURL(`${network.tzktExplorerUrl}/${operation.opHash}`); + }, + }, + ]); + + return operation; + }); + + return { + fee: totalFee(form.watch("executeParams")), + estimationFailed, + operations, + isLoading, + form, + signer: getSigner(signer), + reEstimate, + onSign, + }; +}; + +export const useMakeFormOperations = ( + toOperation: (formValues: FormValues) => Operation | Operation[] +): ((formValues: FormValues) => AccountOperations) => { + const getAccount = useGetOwnedAccount(); + const getSigner = useGetBestSignerForAccount(); + + return (formValues: FormValues) => { + const sender = getAccount(formValues.sender); + return makeAccountOperations(sender, getSigner(sender), [toOperation(formValues)].flat()); + }; +}; + +export const getSmallestUnit = (decimals: number): string => { + if (decimals < 0) { + console.warn("Decimals cannot be negative"); + decimals = 0; + } + + const leadingZeroes = decimals === 0 ? "" : "0." + repeat("0", decimals - 1); + return `${leadingZeroes}1`; +}; + +export const makeValidateDecimals = (decimals: number) => (val: string) => { + if (val.includes(".")) { + const decimalPart = val.split(".")[1]; + if (decimalPart.length > decimals) { + return `Please enter a value with up to ${decimals} decimal places`; + } + } + return true; +}; diff --git a/apps/mobile/ios/Podfile.lock b/apps/mobile/ios/Podfile.lock index bedc8090d6..59963bee3a 100644 --- a/apps/mobile/ios/Podfile.lock +++ b/apps/mobile/ios/Podfile.lock @@ -2125,6 +2125,49 @@ PODS: - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core - Yoga + - RNSVG (15.11.1): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.01.01.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-NativeModulesApple + - React-RCTFabric + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - RNSVG/common (= 15.11.1) + - Yoga + - RNSVG/common (15.11.1): + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.01.01.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-NativeModulesApple + - React-RCTFabric + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga - SimpleKeychain (1.1.0) - SocketRocket (0.7.1) - Yoga (0.0.0) @@ -2234,6 +2277,7 @@ DEPENDENCIES: - "RNGoogleSignin (from `../../../node_modules/@react-native-google-signin/google-signin`)" - RNReanimated (from `../../../node_modules/react-native-reanimated`) - RNScreens (from `../../../node_modules/react-native-screens`) + - RNSVG (from `../../../node_modules/react-native-svg`) - Yoga (from `../../../node_modules/react-native/ReactCommon/yoga`) SPEC REPOS: @@ -2454,6 +2498,8 @@ EXTERNAL SOURCES: :path: "../../../node_modules/react-native-reanimated" RNScreens: :path: "../../../node_modules/react-native-screens" + RNSVG: + :path: "../../../node_modules/react-native-svg" Yoga: :path: "../../../node_modules/react-native/ReactCommon/yoga" @@ -2567,6 +2613,7 @@ SPEC CHECKSUMS: RNGoogleSignin: edec18754ff4af2cfee46072609ec5a7a754291a RNReanimated: 5bc01f4a152370c333d50eef11a4169f7db81a91 RNScreens: 27587018b2e6082f5172b1ecf158c14a0e8842d6 + RNSVG: ea3e35f0375ac20449384fa89ce056ee0e0690ee SimpleKeychain: f8707c8e97b38c6a6e687b17732afc9bcef06439 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 Yoga: 3deb2471faa9916c8a82dda2a22d3fba2620ad37 diff --git a/apps/mobile/ios/mobile.xcodeproj/project.pbxproj b/apps/mobile/ios/mobile.xcodeproj/project.pbxproj index 5ecf744720..355fbc296a 100644 --- a/apps/mobile/ios/mobile.xcodeproj/project.pbxproj +++ b/apps/mobile/ios/mobile.xcodeproj/project.pbxproj @@ -286,6 +286,7 @@ "${PODS_CONFIGURATION_BUILD_DIR}/GoogleSignIn/GoogleSignIn.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/RCT-Folly/RCT-Folly_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/RNSVG/RNSVGFilters.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/ReachabilitySwift/ReachabilitySwift.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle", "${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle", @@ -309,6 +310,7 @@ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleSignIn.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RCT-Folly_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNCAsyncStorage_resources.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNSVGFilters.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ReachabilitySwift.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle", "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle", diff --git a/apps/mobile/package.json b/apps/mobile/package.json index bdde89d199..f759088fe9 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -31,9 +31,11 @@ "@react-navigation/native": "^7.0.0", "@tamagui/babel-plugin": "1.123.0", "@tamagui/config": "1.123.0", + "@tamagui/lucide-icons": "^1.123.14", "@taquito/utils": "^21.0.0", "@umami/core": "workspace:^", "@umami/crypto": "workspace:^", + "@umami/data-polling": "workspace:^", "@umami/multisig": "workspace:^", "@umami/social-auth": "workspace:^", "@umami/state": "workspace:^", @@ -41,7 +43,6 @@ "@umami/tezos": "workspace:^", "@umami/tzkt": "workspace:^", "@umami/utils": "workspace:^", - "@umami/data-polling": "workspace:^", "@web3auth/base": "^9.5.0", "@web3auth/base-provider": "^9.5.0", "@web3auth/react-native-sdk": "^8.1.0", @@ -70,6 +71,7 @@ "process": "^0.11.10", "react": "18.3.1", "react-dom": "18.3.1", + "react-hook-form": "^7.54.2", "react-native": "0.76.3", "react-native-auth0": "^4.0.0", "react-native-crypto": "^2.2.0", @@ -79,6 +81,7 @@ "react-native-reanimated": "~3.16.1", "react-native-safe-area-context": "4.12.0", "react-native-screens": "~4.1.0", + "react-native-svg": "^15.11.1", "react-native-web": "~0.19.13", "react-native-webview": "13.12.2", "stream-browserify": "^3.0.0", diff --git a/apps/mobile/providers/ModalProvider.tsx b/apps/mobile/providers/ModalProvider.tsx new file mode 100644 index 0000000000..3697bf8c03 --- /dev/null +++ b/apps/mobile/providers/ModalProvider.tsx @@ -0,0 +1,75 @@ +import { type ReactNode, createContext, useContext, useState } from "react"; +import { Dialog } from "tamagui"; + +type ModalContextType = { + showModal: (content: ReactNode) => void; + hideModal: () => void; +}; + +const ModalContext = createContext(undefined); + +export const useModal = () => { + const context = useContext(ModalContext); + if (!context) { + throw new Error("useModal must be used within a ModalProvider"); + } + return context; +}; + +type ModalProviderProps = { + children: ReactNode; +}; + +export const ModalProvider: React.FC = ({ children }) => { + const [modalContent, setModalContent] = useState(null); + const [isVisible, setIsVisible] = useState(false); + + const showModal = (content: ReactNode) => { + console.log("showModal", content); + setModalContent(content); + + if (!isVisible) { + setIsVisible(true); + } + }; + + const hideModal = () => { + setIsVisible(false); + setTimeout(() => { + setModalContent(null); + }, 300); + }; + + return ( + + {children} + !open && hideModal()} open={isVisible}> + + + {modalContent} + + + + + + + + + + + + + ); +}; diff --git a/apps/mobile/screens/Home/Home.tsx b/apps/mobile/screens/Home/Home.tsx index 96cfc59853..215438c034 100644 --- a/apps/mobile/screens/Home/Home.tsx +++ b/apps/mobile/screens/Home/Home.tsx @@ -1,5 +1,5 @@ +import { ArrowDown, ArrowUpRight, Repeat, Wallet } from "@tamagui/lucide-icons"; import { type SocialAccount } from "@umami/core"; -import { useDataPolling } from "@umami/data-polling"; import { useCurrentAccount, useGetAccountBalance, @@ -11,28 +11,33 @@ import { prettyTezAmount } from "@umami/tezos"; import { Button, Text, XStack, YStack } from "tamagui"; import { ActionButton, BalanceDisplay, NetworkSwitch } from "./components"; +import { FormPage } from "../../components/SendFlow/Tez"; +import { useModal } from "../../providers/ModalProvider"; import { useSocialOnboarding } from "../../services/auth"; export const Home = () => { - useDataPolling(); - const currentAccount = useCurrentAccount(); const network = useSelectedNetwork(); const selectNetwork = useSelectNetwork(); const { logout } = useSocialOnboarding(); + const { showModal } = useModal(); const address = currentAccount ? currentAccount.address.pkh : ""; - const balance = useGetAccountBalance()(address); + const balance = prettyTezAmount(useGetAccountBalance()(address) ?? 0); const balanceInUsd = useGetDollarBalance()(address); return ( - + - - - - + } title="Buy" /> + } title="Swap" /> + } title="Receive" /> + } + onPress={() => showModal()} + title="Send" + /> @@ -46,8 +51,6 @@ export const Home = () => { Current network: {network.name} Label: {currentAccount?.label} Address: {currentAccount?.address.pkh} - Balance: {prettyTezAmount(balance ?? 0)} - Balance in USD: {balanceInUsd?.toString() ?? "0"}