From 6c1bf5757295ae8094023dddd7731d5cd51cc0b2 Mon Sep 17 00:00:00 2001 From: thekiba Date: Tue, 9 Jan 2024 01:35:28 +0400 Subject: [PATCH] feat(ui): add experimental modal for single wallet --- packages/ui/src/app/App.tsx | 2 + packages/ui/src/app/state/modals-state.ts | 20 +++ .../desktop-connection-modal/index.tsx | 5 +- .../mobile-connection-modal/index.tsx | 27 ++- .../wallets-modal/single-wallet-modal.tsx | 101 +++++++++++ packages/ui/src/app/widget-controller.tsx | 18 ++ .../managers/single-wallet-modal-manager.ts | 159 ++++++++++++++++++ .../ui/src/managers/wallets-modal-manager.ts | 4 +- packages/ui/src/models/single-wallet-modal.ts | 68 ++++++++ packages/ui/src/ton-connect-ui.ts | 51 ++++++ 10 files changed, 449 insertions(+), 6 deletions(-) create mode 100644 packages/ui/src/app/views/modals/wallets-modal/single-wallet-modal.tsx create mode 100644 packages/ui/src/managers/single-wallet-modal-manager.ts create mode 100644 packages/ui/src/models/single-wallet-modal.ts diff --git a/packages/ui/src/app/App.tsx b/packages/ui/src/app/App.tsx index eabbc7d0..4feef6c3 100644 --- a/packages/ui/src/app/App.tsx +++ b/packages/ui/src/app/App.tsx @@ -15,6 +15,7 @@ import { TonConnectUiContext } from 'src/app/state/ton-connect-ui.context'; import { createI18nContext, I18nContext } from '@solid-primitives/i18n'; import { appState } from 'src/app/state/app.state'; import { defineStylesRoot, fixMobileSafariActiveTransition } from 'src/app/utils/web-api'; +import { SingleWalletModal } from 'src/app/views/modals/wallets-modal/single-wallet-modal'; export type AppProps = { tonConnectUI: TonConnectUI; @@ -39,6 +40,7 @@ const App: Component = props => { + diff --git a/packages/ui/src/app/state/modals-state.ts b/packages/ui/src/app/state/modals-state.ts index a2a3a606..73635f08 100644 --- a/packages/ui/src/app/state/modals-state.ts +++ b/packages/ui/src/app/state/modals-state.ts @@ -3,6 +3,7 @@ import { WalletInfoWithOpenMethod, WalletOpenMethod } from 'src/models/connected import { LastSelectedWalletInfoStorage } from 'src/storage'; import { ReturnStrategy } from 'src/models'; import { WalletsModalState } from 'src/models/wallets-modal'; +import { SingleWalletModalState } from 'src/models/single-wallet-modal'; export type ActionName = 'confirm-transaction' | 'transaction-sent' | 'transaction-canceled'; @@ -27,6 +28,25 @@ export const [walletsModalState, setWalletsModalState] = createSignal walletsModalState().status === 'opened'); +export const [singleWalletModalState, setSingleWalletModalState] = + createSignal({ + status: 'closed', + closeReason: null + }); + +export const getSingleWalletModalIsOpened = createMemo( + () => singleWalletModalState().status === 'opened' +); + +export const getSingleWalletModalWalletInfo = createMemo(() => { + const state = singleWalletModalState(); + if (state.status === 'opened') { + return state.walletInfo; + } + + return null; +}); + let lastSelectedWalletInfoStorage = typeof window !== 'undefined' ? new LastSelectedWalletInfoStorage() : undefined; export const [lastSelectedWalletInfo, _setLastSelectedWalletInfo] = createSignal< diff --git a/packages/ui/src/app/views/modals/wallets-modal/desktop-connection-modal/index.tsx b/packages/ui/src/app/views/modals/wallets-modal/desktop-connection-modal/index.tsx index 6f7e8975..8904ff60 100644 --- a/packages/ui/src/app/views/modals/wallets-modal/desktop-connection-modal/index.tsx +++ b/packages/ui/src/app/views/modals/wallets-modal/desktop-connection-modal/index.tsx @@ -60,6 +60,7 @@ export interface DesktopConnectionProps { additionalRequest?: ConnectAdditionalRequest; wallet: WalletInfoRemote | (WalletInfoRemote & WalletInfoInjectable); onBackClick: () => void; + backDisabled?: boolean; } let openDesktopDeeplinkAttempts = 0; @@ -188,7 +189,9 @@ export const DesktopConnectionModal: Component = props = return ( - props.onBackClick()} /> + + props.onBackClick()} /> + {props.wallet.name} void; + backDisabled?: boolean; } export const MobileConnectionModal: Component = props => { @@ -52,7 +53,25 @@ export const MobileConnectionModal: Component = props => ) ); + const onClickTelegram = (): void => { + const alwaysForceRedirect = true; + setLastSelectedWalletInfo({ + ...props.wallet, + openMethod: 'universal-link' + }); + redirectToTelegram(universalLink()!, { + returnStrategy: appState.returnStrategy, + twaReturnUrl: appState.twaReturnUrl, + forceRedirect: alwaysForceRedirect + }); + }; + const onRetry = (): void => { + const currentUniversalLink = universalLink(); + if (isTelegramUrl(currentUniversalLink)) { + return onClickTelegram(); + } + setConnectionErrored(false); setLastSelectedWalletInfo({ ...props.wallet, @@ -91,7 +110,9 @@ export const MobileConnectionModal: Component = props => return ( - + + + diff --git a/packages/ui/src/app/views/modals/wallets-modal/single-wallet-modal.tsx b/packages/ui/src/app/views/modals/wallets-modal/single-wallet-modal.tsx new file mode 100644 index 00000000..487bd874 --- /dev/null +++ b/packages/ui/src/app/views/modals/wallets-modal/single-wallet-modal.tsx @@ -0,0 +1,101 @@ +import { ConnectAdditionalRequest } from '@tonconnect/sdk'; +import { + Component, + createEffect, + createMemo, + createSignal, + onCleanup, + Show, + useContext +} from 'solid-js'; +import { ConnectorContext } from 'src/app/state/connector.context'; +import { + getSingleWalletModalIsOpened, + getSingleWalletModalWalletInfo, + setSingleWalletModalState +} from 'src/app/state/modals-state'; +import { H1Styled, LoaderContainerStyled, StyledModal } from './style'; +import { useI18n } from '@solid-primitives/i18n'; +import { appState } from 'src/app/state/app.state'; +import { isMobile, updateIsMobile } from 'src/app/hooks/isMobile'; +import { LoaderIcon } from 'src/app/components'; +import { LoadableReady } from 'src/models/loadable'; +import { DesktopConnectionModal } from 'src/app/views/modals/wallets-modal/desktop-connection-modal'; +import { InfoModal } from 'src/app/views/modals/wallets-modal/info-modal'; +import { MobileConnectionModal } from 'src/app/views/modals/wallets-modal/mobile-connection-modal'; +import { Dynamic } from 'solid-js/web'; +import { WalletsModalCloseReason } from 'src/models'; + +export const SingleWalletModal: Component = () => { + const { locale } = useI18n()[1]; + createEffect(() => locale(appState.language)); + + createEffect(() => { + if (getSingleWalletModalIsOpened()) { + updateIsMobile(); + } + }); + + const connector = useContext(ConnectorContext)!; + const [infoTab, setInfoTab] = createSignal(false); + + const additionalRequestLoading = (): boolean => + appState.connectRequestParameters?.state === 'loading'; + + const additionalRequest = createMemo(() => { + if (additionalRequestLoading()) { + return undefined; + } + + return (appState.connectRequestParameters as LoadableReady) + ?.value; + }); + + const onClose = (closeReason: WalletsModalCloseReason): void => { + setSingleWalletModalState({ status: 'closed', closeReason: closeReason }); + setInfoTab(false); + }; + + const unsubscribe = connector.onStatusChange(wallet => { + if (wallet) { + onClose('wallet-selected'); + } + }); + + onCleanup(unsubscribe); + + return ( + onClose('action-cancelled')} + onClickQuestion={() => setInfoTab(v => !v)} + data-tc-wallets-modal-container="true" + > + + setInfoTab(false)} /> + + + + + + Wallets list is loading + + + + + + + + {}} + backDisabled={true} + /> + + + + ); +}; diff --git a/packages/ui/src/app/widget-controller.tsx b/packages/ui/src/app/widget-controller.tsx index 454fcb4f..307858ad 100644 --- a/packages/ui/src/app/widget-controller.tsx +++ b/packages/ui/src/app/widget-controller.tsx @@ -5,12 +5,14 @@ import { lastSelectedWalletInfo, setAction, setLastSelectedWalletInfo, + setSingleWalletModalState, setWalletsModalState } from 'src/app/state/modals-state'; import { TonConnectUI } from 'src/ton-connect-ui'; import App from './App'; import { WalletInfoWithOpenMethod, WalletOpenMethod } from 'src/models/connected-wallet'; import { WalletsModalCloseReason } from 'src/models'; +import { WalletInfoRemote } from '@tonconnect/sdk'; export const widgetController = { openWalletsModal: (): void => @@ -27,6 +29,22 @@ export const widgetController = { closeReason: reason }) ), + openSingleWalletModal: (walletInfo: WalletInfoRemote): void => { + void setTimeout(() => + setSingleWalletModalState({ + status: 'opened', + closeReason: null, + walletInfo: walletInfo + }) + ); + }, + closeSingleWalletModal: (reason: WalletsModalCloseReason): void => + void setTimeout(() => + setSingleWalletModalState({ + status: 'closed', + closeReason: reason + }) + ), setAction: (action: Action): void => void setTimeout(() => setAction(action)), clearAction: (): void => void setTimeout(() => setAction(null)), getSelectedWalletInfo: (): diff --git a/packages/ui/src/managers/single-wallet-modal-manager.ts b/packages/ui/src/managers/single-wallet-modal-manager.ts new file mode 100644 index 00000000..a55a2074 --- /dev/null +++ b/packages/ui/src/managers/single-wallet-modal-manager.ts @@ -0,0 +1,159 @@ +import { setLastSelectedWalletInfo, singleWalletModalState } from 'src/app/state/modals-state'; +import { createEffect } from 'solid-js'; +import { + ConnectAdditionalRequest, + isWalletInfoCurrentlyEmbedded, + isWalletInfoRemote, + ITonConnect, + WalletInfoCurrentlyEmbedded, + WalletInfoRemote +} from '@tonconnect/sdk'; +import { appState } from 'src/app/state/app.state'; +import { widgetController } from 'src/app/widget-controller'; +import { SingleWalletModal, SingleWalletModalState } from 'src/models/single-wallet-modal'; +import { isInTMA, sendExpand } from 'src/app/utils/tma-api'; +import { TonConnectUIError } from 'src/errors'; +import { applyWalletsListConfiguration, eqWalletName } from 'src/app/utils/wallets'; + +interface SingleWalletModalManagerCreateOptions { + /** + * TonConnect instance. + */ + connector: ITonConnect; + + /** + * Set connect request parameters callback. + */ + setConnectRequestParametersCallback: ( + callback: (parameters?: ConnectAdditionalRequest) => void + ) => void; +} + +/** + * Manages the modal window state. + */ +export class SingleWalletModalManager implements SingleWalletModal { + /** + * TonConnect instance. + * @internal + */ + private readonly connector: ITonConnect; + + /** + * Callback to call when the connection parameters are received. + * @internal + */ + private readonly setConnectRequestParametersCallback: ( + callback: (parameters?: ConnectAdditionalRequest) => void + ) => void; + + /** + * List of subscribers to the modal window state changes. + * @internal + */ + private consumers: Array<(state: SingleWalletModalState) => void> = []; + + /** + * Current modal window state. + */ + public state: SingleWalletModalState = singleWalletModalState(); + + constructor(options: SingleWalletModalManagerCreateOptions) { + this.connector = options.connector; + this.setConnectRequestParametersCallback = options.setConnectRequestParametersCallback; + + createEffect(() => { + const state = singleWalletModalState(); + this.state = state; + this.consumers.forEach(consumer => consumer(state)); + }); + } + + /** + * Opens the modal window with the specified wallet. + * @param wallet - Wallet app name. + * @throws TonConnectUIError if the specified wallet is not found. + */ + public async open(wallet: string): Promise { + const fetchedWalletsList = await this.connector.getWallets(); + const walletsList = applyWalletsListConfiguration( + fetchedWalletsList, + appState.walletsListConfiguration + ); + + // TODO: move to ITonConnect + const embeddedWallet = walletsList.find(isWalletInfoCurrentlyEmbedded); + const isEmbeddedWalletExist = !!embeddedWallet; + if (isEmbeddedWalletExist) { + return this.connectEmbeddedWallet(embeddedWallet); + } + + // TODO: move to ITonConnect + const externalWallets = walletsList.filter(isWalletInfoRemote); + const externalWallet = externalWallets.find(walletInfo => eqWalletName(walletInfo, wallet)); + const isExternalWalletExist = !!externalWallet; + if (isExternalWalletExist) { + return this.openSingleWalletModal(externalWallet); + } + + throw new TonConnectUIError(`Trying to open modal window with unknown wallet "${wallet}".`); + } + + /** + * Closes the modal window. + */ + public close(): void { + widgetController.closeSingleWalletModal('action-cancelled'); + } + + /** + * Subscribe to the modal window state changes, returns unsubscribe function. + */ + public onStateChange(onChange: (state: SingleWalletModalState) => void): () => void { + this.consumers.push(onChange); + + return () => { + this.consumers = this.consumers.filter(consumer => consumer !== onChange); + }; + } + + /** + * Initiates a connection with an embedded wallet. + * @param embeddedWallet - Information about the embedded wallet to connect to. + * @internal + */ + private connectEmbeddedWallet(embeddedWallet: WalletInfoCurrentlyEmbedded): void { + const connect = (parameters?: ConnectAdditionalRequest): void => { + setLastSelectedWalletInfo(embeddedWallet); + this.connector.connect({ jsBridgeKey: embeddedWallet.jsBridgeKey }, parameters); + }; + + const additionalRequest = appState.connectRequestParameters; + if (additionalRequest?.state === 'loading') { + this.setConnectRequestParametersCallback(connect); + } else { + connect(additionalRequest?.value); + } + } + + /** + * Opens the modal window to connect to a specified wallet, and waits when modal window is opened. + */ + public async openSingleWalletModal(wallet: WalletInfoRemote): Promise { + if (isInTMA()) { + sendExpand(); + } + + widgetController.openSingleWalletModal(wallet); + + return new Promise(resolve => { + const unsubscribe = this.onStateChange(state => { + const { status } = state; + if (status === 'opened') { + unsubscribe(); + resolve(); + } + }); + }); + } +} diff --git a/packages/ui/src/managers/wallets-modal-manager.ts b/packages/ui/src/managers/wallets-modal-manager.ts index 94e84948..b4d609cd 100644 --- a/packages/ui/src/managers/wallets-modal-manager.ts +++ b/packages/ui/src/managers/wallets-modal-manager.ts @@ -75,7 +75,7 @@ export class WalletsModalManager implements WalletsModal { if (embeddedWallet) { return this.connectEmbeddedWallet(embeddedWallet); } else { - return this.connectExternalWallet(); + return this.openWalletsModal(); } } @@ -120,7 +120,7 @@ export class WalletsModalManager implements WalletsModal { * Opens the modal window to connect to an external wallet, and waits when modal window is opened. * @internal */ - private async connectExternalWallet(): Promise { + private async openWalletsModal(): Promise { if (isInTMA()) { sendExpand(); } diff --git a/packages/ui/src/models/single-wallet-modal.ts b/packages/ui/src/models/single-wallet-modal.ts new file mode 100644 index 00000000..e6480f27 --- /dev/null +++ b/packages/ui/src/models/single-wallet-modal.ts @@ -0,0 +1,68 @@ +import { WalletInfoRemote } from '@tonconnect/sdk'; + +export interface SingleWalletModal { + /** + * Open the modal with the specified wallet. + */ + open: (wallet: string) => void; + + /** + * Close the modal. + */ + close: () => void; + + /** + * Subscribe to the modal window status changes. + */ + onStateChange: (callback: (state: SingleWalletModalState) => void) => () => void; + + /** + * Current modal window state. + */ + state: SingleWalletModalState; +} + +/** + * Opened modal window state. + */ +export type SingleWalletModalOpened = { + /** + * Modal window status. + */ + status: 'opened'; + + /** + * Wallet info. + */ + walletInfo: WalletInfoRemote; + + /** + * Always `null` for opened modal window. + */ + closeReason: null; +}; + +/** + * Closed modal window state. + */ +export type SingleWalletModalClosed = { + /** + * Modal window status. + */ + status: 'closed'; + + /** + * Close reason, if the modal window was closed. + */ + closeReason: SingleWalletModalCloseReason | null; +}; + +/** + * Modal window state. + */ +export type SingleWalletModalState = SingleWalletModalOpened | SingleWalletModalClosed; + +/** + * Modal window close reason. + */ +export type SingleWalletModalCloseReason = 'action-cancelled' | 'wallet-selected'; diff --git a/packages/ui/src/ton-connect-ui.ts b/packages/ui/src/ton-connect-ui.ts index 73c20f73..37be32ab 100644 --- a/packages/ui/src/ton-connect-ui.ts +++ b/packages/ui/src/ton-connect-ui.ts @@ -41,6 +41,8 @@ import { TransactionModalManager } from 'src/managers/transaction-modal-manager' import { WalletsModal, WalletsModalState } from 'src/models/wallets-modal'; import { isInTMA, sendExpand } from 'src/app/utils/tma-api'; import { addReturnStrategy, redirectToTelegram } from 'src/app/utils/url-strategy-helpers'; +import { SingleWalletModalManager } from 'src/managers/single-wallet-modal-manager'; +import { SingleWalletModal, SingleWalletModalState } from 'src/models/single-wallet-modal'; export class TonConnectUI { public static getWallets(): Promise { @@ -73,6 +75,12 @@ export class TonConnectUI { */ public readonly modal: WalletsModal; + /** + * Manages the single wallet modal window state. + * TODO: make it public when interface will be ready for external usage. + */ + private readonly singleWalletModal: SingleWalletModal; + /** * Manages the transaction modal window state. * TODO: make it public when interface will be ready for external usage. @@ -193,6 +201,15 @@ export class TonConnectUI { } }); + this.singleWalletModal = new SingleWalletModalManager({ + connector: this.connector, + setConnectRequestParametersCallback: ( + callback: (parameters?: ConnectAdditionalRequest) => void + ) => { + this.connectRequestParametersCallback = callback; + } + }); + this.transactionModal = new TransactionModalManager({ connector: this.connector }); @@ -299,6 +316,40 @@ export class TonConnectUI { return this.modal.state; } + /** + * Opens the single wallet modal window, returns a promise that resolves after the modal window is opened. + * @experimental + */ + public async openSingleWalletModal(wallet: string): Promise { + return this.singleWalletModal.open(wallet); + } + + /** + * Close the single wallet modal window. + * @experimental + */ + public closeSingleWalletModal(): void { + this.singleWalletModal.close(); + } + + /** + * Subscribe to the single wallet modal window state changes, returns a function which has to be called to unsubscribe. + * @experimental + */ + public onSingleWalletModalStateChange( + onChange: (state: SingleWalletModalState) => void + ): () => void { + return this.singleWalletModal.onStateChange(onChange); + } + + /** + * Returns current single wallet modal window state. + * @experimental + */ + public get singleWalletModalState(): SingleWalletModalState { + return this.singleWalletModal.state; + } + /** * @deprecated Use `tonConnectUI.openModal()` instead. Will be removed in the next major version. * Opens the modal window and handles a wallet connection.