diff --git a/src/app/App.test.tsx b/src/app/App.test.tsx index 9b8ff52814..d0c0adcebb 100644 --- a/src/app/App.test.tsx +++ b/src/app/App.test.tsx @@ -3,6 +3,7 @@ import { translations as errorTranslations } from "domains/error/i18n"; import { translations as profileTranslations } from "domains/profile/i18n"; +import { ipcRenderer } from "electron"; import electron from "electron"; import nock from "nock"; import React from "react"; @@ -18,6 +19,7 @@ import { import { App } from "./App"; jest.mock("electron", () => ({ + ipcRenderer: { on: jest.fn(), send: jest.fn(), removeListener: jest.fn() }, remote: { nativeTheme: { shouldUseDarkColors: true, @@ -42,6 +44,10 @@ describe("App", () => { .persist(); }); + beforeEach(() => { + ipcRenderer.on.mockImplementationOnce((event, callback) => callback(event, null)); + }); + it("should render splash screen", async () => { process.env.REACT_APP_BUILD_MODE = "demo"; diff --git a/src/app/App.tsx b/src/app/App.tsx index ffe2cbcab0..7eaac99fc5 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -28,8 +28,8 @@ import { Theme } from "types"; import { middlewares, RouterView, routes } from "../router"; import { EnvironmentProvider, ThemeProvider, useEnvironmentContext, useThemeContext } from "./contexts"; +import { useDeeplink, useEnvSynchronizer, useNetworkStatus } from "./hooks"; import { useNetworkStatus } from "./hooks"; -import { useEnvSynchronizer } from "./hooks/use-synchronizer"; import { i18n } from "./i18n"; import { httpClient } from "./services"; @@ -37,11 +37,12 @@ const __DEV__ = process.env.NODE_ENV !== "production"; const Main = () => { const [showSplash, setShowSplash] = useState(true); - + const { pathname } = useLocation(); const { theme, setTheme } = useThemeContext(); const { env, persist } = useEnvironmentContext(); const isOnline = useNetworkStatus(); const { start, runAll } = useEnvSynchronizer(); + useDeeplink(); const location = useLocation(); const pathname = (location as any).location?.pathname || location.pathname; diff --git a/src/app/hooks/index.ts b/src/app/hooks/index.ts index 99921fbce6..549aa8cf8d 100644 --- a/src/app/hooks/index.ts +++ b/src/app/hooks/index.ts @@ -4,4 +4,6 @@ export * from "./env"; export * from "./network-status"; export * from "./use-clipboard"; export * from "./use-query-params"; +export * from "./use-deeplink"; +export * from "./use-synchronizer"; export * from "./use-reload-path"; diff --git a/src/app/hooks/use-deeplink.test.tsx b/src/app/hooks/use-deeplink.test.tsx new file mode 100644 index 0000000000..ca6cadab0f --- /dev/null +++ b/src/app/hooks/use-deeplink.test.tsx @@ -0,0 +1,148 @@ +import { translations } from "app/i18n/common/i18n"; +import { toasts } from "app/services"; +import { ipcRenderer } from "electron"; +import { createMemoryHistory } from "history"; +import React from "react"; +import { Route } from "react-router-dom"; +import { getDefaultProfileId, getDefaultWalletId, renderWithRouter } from "testing-library"; + +import { useDeeplink } from "./use-deeplink"; + +const history = createMemoryHistory(); +const walletURL = `/profiles/${getDefaultProfileId()}/wallets/${getDefaultWalletId()}`; + +jest.mock("electron", () => ({ + ipcRenderer: { on: jest.fn(), send: jest.fn(), removeListener: jest.fn() }, +})); + +describe("useDeeplink hook", () => { + const toastSpy = jest.spyOn(toasts, "warning").mockImplementationOnce((subject) => jest.fn(subject)); + + const TestComponent: React.FC = () => { + useDeeplink(); + + return

Deeplink tester

; + }; + + it("should subscribe to deeplink listener", () => { + ipcRenderer.on.mockImplementationOnce((event, callback) => + callback( + event, + "ark:transfer?coin=ark&network=mainnet&recipient=DNjuJEDQkhrJ7cA9FZ2iVXt5anYiM8Jtc9&amount=1.2&memo=ARK", + ), + ); + + const { getByText, history } = renderWithRouter( + + + , + ); + + expect(getByText("Deeplink tester")).toBeTruthy(); + expect(ipcRenderer.on).toBeCalledWith("process-url", expect.any(Function)); + }); + + it("should subscribe to deeplink listener and toast a warning to select a profile", () => { + ipcRenderer.on.mockImplementationOnce((event, callback) => + callback( + event, + "ark:transfer?coin=ark&network=mainnet&recipient=DNjuJEDQkhrJ7cA9FZ2iVXt5anYiM8Jtc9&amount=1.2&memo=ARK", + ), + ); + + const { getByText, history } = renderWithRouter( + + + , + { + routes: ["/"], + }, + ); + + expect(getByText("Deeplink tester")).toBeTruthy(); + expect(toastSpy).toHaveBeenCalledWith(translations.SELECT_A_PROFILE); + expect(ipcRenderer.on).toBeCalledWith("process-url", expect.any(Function)); + }); + + it("should subscribe to deeplink listener and toast a warning to select a wallet", () => { + ipcRenderer.on.mockImplementationOnce((event, callback) => + callback( + event, + "ark:transfer?coin=ark&network=mainnet&recipient=DNjuJEDQkhrJ7cA9FZ2iVXt5anYiM8Jtc9&amount=1.2&memo=ARK", + ), + ); + + window.history.pushState({}, "Deeplink Test", `/profiles/${getDefaultProfileId()}/dashboard`); + + const { getByText, history } = renderWithRouter( + + + , + { + routes: [`/profiles/${getDefaultProfileId()}/dashboard`], + }, + ); + + expect(getByText("Deeplink tester")).toBeTruthy(); + expect(toastSpy).toHaveBeenCalledWith(translations.SELECT_A_WALLET); + expect(ipcRenderer.on).toBeCalledWith("process-url", expect.any(Function)); + }); + + it("should subscribe to deeplink listener and navigate", () => { + ipcRenderer.on.mockImplementationOnce((event, callback) => + callback( + event, + "ark:transfer?coin=ark&network=mainnet&recipient=DNjuJEDQkhrJ7cA9FZ2iVXt5anYiM8Jtc9&amount=1.2&memo=ARK", + ), + ); + + window.history.pushState( + {}, + "Deeplink Test", + `/profiles/${getDefaultProfileId()}/wallets/${getDefaultWalletId()}`, + ); + + const { getByText, history } = renderWithRouter( + + + , + { + routes: [walletURL], + }, + ); + + expect(getByText("Deeplink tester")).toBeTruthy(); + expect(history.location.pathname).toEqual( + `/profiles/${getDefaultProfileId()}/wallets/${getDefaultWalletId()}/send-transfer`, + ); + expect(ipcRenderer.on).toBeCalledWith("process-url", expect.any(Function)); + }); + + it("should subscribe to deeplink listener and navigate when no method found", () => { + ipcRenderer.on.mockImplementationOnce((event, callback) => + callback( + event, + "ark:vote?coin=ark&network=mainnet&recipient=DNjuJEDQkhrJ7cA9FZ2iVXt5anYiM8Jtc9&amount=1.2&memo=ARK", + ), + ); + + window.history.pushState( + {}, + "Deeplink Test", + `/profiles/${getDefaultProfileId()}/wallets/${getDefaultWalletId()}`, + ); + + const { getByText, history } = renderWithRouter( + + + , + { + routes: [walletURL], + }, + ); + + expect(getByText("Deeplink tester")).toBeTruthy(); + expect(history.location.pathname).toEqual("/"); + expect(ipcRenderer.on).toBeCalledWith("process-url", expect.any(Function)); + }); +}); diff --git a/src/app/hooks/use-deeplink.ts b/src/app/hooks/use-deeplink.ts new file mode 100644 index 0000000000..b009b841ec --- /dev/null +++ b/src/app/hooks/use-deeplink.ts @@ -0,0 +1,63 @@ +import { URI } from "@arkecosystem/platform-sdk-support/dist/uri"; +import { toasts } from "app/services"; +import { ipcRenderer } from "electron"; +import { useCallback, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { useHistory } from "react-router-dom"; + +const useDeepLinkHandler = () => { + const history = useHistory(); + const { t } = useTranslation(); + const uriService = new URI(); + + const navigate = useCallback((url: string, deeplinkSchema?: any) => history.push(url, deeplinkSchema), [history]); + + const handler = useCallback( + (event: any, deeplink: string) => { + if (deeplink) { + if (window.location.pathname === "/") return toasts.warning(t("COMMON.SELECT_A_PROFILE")); + + if (window.location.pathname.includes("/dashboard")) return toasts.warning(t("COMMON.SELECT_A_WALLET")); + + const deeplinkSchema = uriService.deserialize(deeplink); + const urlParts = window.location.pathname.split("/"); + const activeSession = { + profileId: urlParts[2], + walletId: urlParts[4], + }; + + switch (deeplinkSchema.method) { + case "transfer": + return navigate( + `/profiles/${activeSession.profileId}/wallets/${activeSession.walletId}/send-transfer`, + deeplinkSchema, + ); + + default: + return navigate("/"); + } + } + }, + [t, uriService, navigate], + ); + + useEffect((): any => { + ipcRenderer.on("process-url", handler); + + return () => ipcRenderer.removeListener("process-url", handler); + }, [handler]); + + return { + handler, + }; +}; + +export const useDeeplink = () => { + const { handler } = useDeepLinkHandler(); + + useEffect(() => { + handler(null, ""); + }, [handler]); +}; + +export default useDeeplink; diff --git a/src/app/i18n/common/i18n.ts b/src/app/i18n/common/i18n.ts index 8d1f29d1d5..c0e7438cdd 100644 --- a/src/app/i18n/common/i18n.ts +++ b/src/app/i18n/common/i18n.ts @@ -139,6 +139,8 @@ export const translations: { [key: string]: any } = { SELECTED: "Selected", SELECT_ALL: "Select All", SELECT_OPTION: "Select {{option}}", + SELECT_A_PROFILE: "You should select a profile to access this URL", + SELECT_A_WALLET: "You should select a wallet to deeplink transactions", SEND: "Send", SETTINGS: "Settings", SHOW: "Show", diff --git a/src/domains/transaction/components/AddRecipient/AddRecipient.tsx b/src/domains/transaction/components/AddRecipient/AddRecipient.tsx index 6f03c7258a..e78e52ea8c 100644 --- a/src/domains/transaction/components/AddRecipient/AddRecipient.tsx +++ b/src/domains/transaction/components/AddRecipient/AddRecipient.tsx @@ -65,6 +65,7 @@ export const AddRecipient = ({ const [addedRecipients, setAddressRecipients] = useState(recipients!); const [isSingle, setIsSingle] = useState(isSingleRecipient); const [displayAmount, setDisplayAmount] = useState(); + const [recipientsAmount, setRecipientsAmount] = useState(); const { t } = useTranslation(); @@ -84,6 +85,14 @@ export const AddRecipient = ({ register("amount"); }, [register]); + useEffect(() => { + setRecipientsAmount( + recipients + ?.reduce((accumulator, currentValue) => Number(accumulator) + Number(currentValue.amount), 0) + .toString(), + ); + }, [recipients, displayAmount]); + const availableAmount = useMemo( () => addedRecipients.reduce((sum, item) => sum.minus(item.amount), maxAvailableAmount), [maxAvailableAmount, addedRecipients], @@ -174,7 +183,7 @@ export const AddRecipient = ({ name="amount" placeholder={t("COMMON.AMOUNT")} className="pr-20" - value={displayAmount} + value={displayAmount || recipientsAmount} onChange={(currency) => { setDisplayAmount(currency.display); setValue("amount", currency.value, { shouldValidate: true, shouldDirty: true }); diff --git a/src/domains/transaction/components/AddRecipient/__snapshots__/AddRecipient.test.tsx.snap b/src/domains/transaction/components/AddRecipient/__snapshots__/AddRecipient.test.tsx.snap index e35b2b6b41..23e93ad8ec 100644 --- a/src/domains/transaction/components/AddRecipient/__snapshots__/AddRecipient.test.tsx.snap +++ b/src/domains/transaction/components/AddRecipient/__snapshots__/AddRecipient.test.tsx.snap @@ -140,7 +140,7 @@ exports[`AddRecipient should render 1`] = ` name="amount" placeholder="Amount" type="text" - value="" + value="0" />
// @ts-ignore @@ -81,6 +83,27 @@ describe("SendTransfer", () => { expect(asFragment()).toMatchSnapshot(); }); + it("should render 1st step with deeplink values and use them", async () => { + const { result: form } = renderHook(() => useForm()); + const deeplinkProps: any = { + amount: "1.2", + coin: "ark", + memo: "ARK", + method: "transfer", + network: "mainnet", + recipient: "DNjuJEDQkhrJ7cA9FZ2iVXt5anYiM8Jtc9", + }; + + const { getByTestId, asFragment } = render( + + + , + ); + + expect(getByTestId("SendTransfer__step--first")).toBeTruthy(); + expect(asFragment()).toMatchSnapshot(); + }); + it("should render 2nd step (review)", async () => { const { result: form } = renderHook(() => useForm({ @@ -153,6 +176,30 @@ describe("SendTransfer", () => { expect(rendered.asFragment()).toMatchSnapshot(); }); + it("should render form and use location state", async () => { + const history = createMemoryHistory(); + let rendered: RenderResult; + + const transferURL = `/profiles/${fixtureProfileId}/wallets/${fixtureWalletId}/send-transfer`; + history.push(transferURL, { memo: "ARK" }); + + await act(async () => { + rendered = renderWithRouter( + + + , + { + routes: [transferURL], + history, + }, + ); + + await waitFor(() => expect(rendered.getByTestId("SendTransfer__step--first")).toBeTruthy()); + }); + + expect(rendered.asFragment()).toMatchSnapshot(); + }); + it("should select cryptoasset first and see select address input clickable", async () => { const history = createMemoryHistory(); let rendered: RenderResult; diff --git a/src/domains/transaction/pages/SendTransfer/SendTransfer.tsx b/src/domains/transaction/pages/SendTransfer/SendTransfer.tsx index 51312f10f2..4b48c0042e 100644 --- a/src/domains/transaction/pages/SendTransfer/SendTransfer.tsx +++ b/src/domains/transaction/pages/SendTransfer/SendTransfer.tsx @@ -13,7 +13,7 @@ import { AuthenticationStep } from "domains/transaction/components/Authenticatio import React, { useEffect, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; -import { useHistory } from "react-router-dom"; +import { useHistory, useLocation } from "react-router-dom"; import { FormStep } from "./Step1"; import { ReviewStep } from "./Step2"; @@ -22,6 +22,8 @@ import { SummaryStep } from "./Step4"; export const SendTransfer = () => { const { t } = useTranslation(); const history = useHistory(); + const location = useLocation(); + const { state } = location; const [activeTab, setActiveTab] = useState(1); const [transaction, setTransaction] = useState((null as unknown) as Contracts.SignedTransactionData); @@ -61,6 +63,10 @@ export const SendTransfer = () => { } }, [activeWallet, networks, setValue]); + useEffect(() => { + if (state?.memo) setValue("smartbridge", state.memo); + }, [state, setValue]); + const submitForm = async () => { clearErrors("mnemonic"); @@ -144,7 +150,7 @@ export const SendTransfer = () => {
- + diff --git a/src/domains/transaction/pages/SendTransfer/Step1.tsx b/src/domains/transaction/pages/SendTransfer/Step1.tsx index d1e0105fe2..bc3fad7b2e 100644 --- a/src/domains/transaction/pages/SendTransfer/Step1.tsx +++ b/src/domains/transaction/pages/SendTransfer/Step1.tsx @@ -10,7 +10,15 @@ import React, { ChangeEvent } from "react"; import { useFormContext } from "react-hook-form"; import { useTranslation } from "react-i18next"; -export const FormStep = ({ networks, profile }: { networks: Coins.Network[]; profile: Profile }) => { +export const FormStep = ({ + networks, + profile, + deeplinkProps, +}: { + networks: Coins.Network[]; + profile: Profile; + deeplinkProps: any; +}) => { const { t } = useTranslation(); const { getValues, setValue, watch } = useFormContext(); const { recipients, smartbridge } = getValues(); @@ -20,6 +28,19 @@ export const FormStep = ({ networks, profile }: { networks: Coins.Network[]; pro const senderWallet = profile.wallets().findByAddress(senderAddress); const maxAmount = senderWallet ? BigNumber.make(senderWallet.balance()).minus(feeSatoshi) : BigNumber.ZERO; + const getRecipients = () => { + if (deeplinkProps?.recipient && deeplinkProps?.amount) { + return [ + { + address: deeplinkProps.recipient, + amount: BigNumber.make(deeplinkProps.amount), + }, + ]; + } + + return recipients; + }; + return (
@@ -39,7 +60,7 @@ export const FormStep = ({ networks, profile }: { networks: Coins.Network[]; pro onChange={(recipients: RecipientListItem[]) => setValue("recipients", recipients, { shouldValidate: true, shouldDirty: true }) } - recipients={recipients} + recipients={getRecipients()} />
diff --git a/src/domains/transaction/pages/SendTransfer/__snapshots__/SendTransfer.test.tsx.snap b/src/domains/transaction/pages/SendTransfer/__snapshots__/SendTransfer.test.tsx.snap index 58cce366a4..af1128e1d0 100644 --- a/src/domains/transaction/pages/SendTransfer/__snapshots__/SendTransfer.test.tsx.snap +++ b/src/domains/transaction/pages/SendTransfer/__snapshots__/SendTransfer.test.tsx.snap @@ -661,7 +661,7 @@ exports[`SendTransfer should display disabled address selection input if selecte name="amount" placeholder="Amount" type="text" - value="" + value="0" />
`; -exports[`SendTransfer should render 2nd step (review) 1`] = ` +exports[`SendTransfer should render 1st step with deeplink values and use them 1`] = `

- Transaction Review + Send

-
+
- - D8rr7B…s6YUYD - + Sender +
+
@@ -1918,13 +1942,13 @@ exports[`SendTransfer should render 2nd step (review) 1`] = ` class="relative p-4" >
-
- + + +
@@ -2123,95 +2171,188 @@ exports[`SendTransfer should render 4th step (summary) 1`] = ` Sender
- - Your address - -
-
-
-
- - ARK Wallet 1 - - - D8rr7B…s6YUYD - -
-
-
-
-
-
- D8rr7B1d6TL6pf14LgMz4sKp1VBMs6YUYD" - title="D8rr7B1d6TL6pf14LgMz4sKp1VBMs6YUYD" - /> -
-
-
-
-
-
-
- Recipient -
-
-
- - D5pVkh…xfKwSv - -
-
-
-
-
+
+
+
+
+
+
+
- D5pVkhZbSb4UNXvfmF6j7zdau8yGxfKwSv" - title="D5pVkhZbSb4UNXvfmF6j7zdau8yGxfKwSv" +
+
+
+
+
+
+ Recipient +
+
+ +
+ 0/255 +
+
+ +
+
@@ -2240,26 +2381,1462 @@ exports[`SendTransfer should render 4th step (summary) 1`] = `
+
- - sent.svg - +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+ + + +
-
+
`; +exports[`SendTransfer should render 2nd step (review) 1`] = ` + +
+
+

+ Transaction Review +

+
+ Check the information again before voting +
+
+
+
+
+ Cryptoasset +
+
+ ARK Devnet +
+
+
+
+ + ark.svg + +
+
+
+
+
+
+
+
+ + Sender + +
+ + Your address + +
+
+
+ + ARK Wallet 1 + + + D8rr7B…s6YUYD + +
+
+
+
+
+
+ D8rr7B1d6TL6pf14LgMz4sKp1VBMs6YUYD" + title="D8rr7B1d6TL6pf14LgMz4sKp1VBMs6YUYD" + /> +
+
+
+
+
+
+ Recipient +
+
+
+ + D8rr7B…s6YUYD + +
+
+
+
+
+ D8rr7B1d6TL6pf14LgMz4sKp1VBMs6YUYD" + title="D8rr7B1d6TL6pf14LgMz4sKp1VBMs6YUYD" + /> +
+
+
+
+
+
+ Smartbridge +
+
+

+ test smartbridge +

+
+
+
+
+ + smartbridge.svg + +
+
+
+
+
+
+
+
+ + Transaction Amount + + + 1 DARK + +
+
+ + Transaction Fee + + + 0.1 DARK + +
+
+
+
+
+ + plus.svg + +
+
+
+
+
+ + Total Amount + + + 1.1 DARK + +
+
+
+
+
+`; + +exports[`SendTransfer should render 4th step (summary) 1`] = ` + +
+
+
+

+ Transaction Sent +

+
+
+ + transaction_successful.svg + +

+ Your transaction was successfully sent. Please monitor the blockchain to ensure your transaction is confirmed and processed. The following is relevant information for your transaction: +

+
+ +
+
+
+ Cryptoasset +
+
+ ARK Devnet +
+
+
+
+ + ark.svg + +
+
+
+
+
+
+ + Sender + +
+ + Your address + +
+
+
+
+ + ARK Wallet 1 + + + D8rr7B…s6YUYD + +
+
+
+
+
+
+ D8rr7B1d6TL6pf14LgMz4sKp1VBMs6YUYD" + title="D8rr7B1d6TL6pf14LgMz4sKp1VBMs6YUYD" + /> +
+
+
+
+
+
+
+ Recipient +
+
+
+ + D5pVkh…xfKwSv + +
+
+
+
+
+ D5pVkhZbSb4UNXvfmF6j7zdau8yGxfKwSv" + title="D5pVkhZbSb4UNXvfmF6j7zdau8yGxfKwSv" + /> +
+
+
+
+
+
+ Total Amount +
+
+ + 400,000.1 DARK + +
+
+
+
+
+ + sent.svg + +
+
+
+
+
+
+ +`; + +exports[`SendTransfer should render form and use location state 1`] = ` + +
+ +
+
+ + arrow-left.svg + +
+ +
+
+
+
+
+
+
    +
  • +
  • +
  • +
  • +
+
+
+
+
+

+ Send +

+
+ Enter details to send your money +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Select a Single or Multiple Recipient Transaction +
+
+
+
+ + questionmark.svg + +
+
+
+
+
+ + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+
+
+
+
+
+
+
+`; + exports[`SendTransfer should render registration form without selected wallet 1`] = `
{ const location = useLocation(); const history = useHistory(); - const { env } = useEnvironmentContext(); const [redirectUrl, setRedirectUrl] = React.useState(undefined); const [previousPathname, setPreviousPathname] = React.useState(""); @@ -32,7 +31,7 @@ export const RouterView = ({ routes, wrapper, middlewares }: Props) => { setPreviousPathname(pathname); }, [location, previousPathname]); - const canActivate = React.useMemo( + const canActivate = useMemo( () => middlewares!.every((middleware) => middleware.handler({ location, env, redirect: setRedirectUrl, history }),