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.