From 512678ff25ae877a7a2f608e8d8c06fda5dcbd21 Mon Sep 17 00:00:00 2001 From: thekiba Date: Tue, 10 Oct 2023 16:09:39 +0400 Subject: [PATCH 01/17] fix(ui): resolve illegal constructor error in safari Closes #87 --- packages/ui/src/app/global.d.ts | 2 +- packages/ui/src/app/utils/web-api.ts | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/app/global.d.ts b/packages/ui/src/app/global.d.ts index d2392641..dde6cb51 100644 --- a/packages/ui/src/app/global.d.ts +++ b/packages/ui/src/app/global.d.ts @@ -4,7 +4,7 @@ import { globalStylesTag } from 'src/app/styles/global-styles'; declare module 'solid-js' { namespace JSX { interface IntrinsicElements { - [globalStylesTag]: JSX.HTMLAttributes; + [globalStylesTag]: JSX.HTMLAttributes; } } } diff --git a/packages/ui/src/app/utils/web-api.ts b/packages/ui/src/app/utils/web-api.ts index f7dc0c78..30fd65ce 100644 --- a/packages/ui/src/app/utils/web-api.ts +++ b/packages/ui/src/app/utils/web-api.ts @@ -81,9 +81,7 @@ export function fixMobileSafariActiveTransition(): void { } export function defineStylesRoot(): void { - customElements.define(globalStylesTag, class TcRootElement extends HTMLDivElement {}, { - extends: 'div' - }); + customElements.define(globalStylesTag, class TcRootElement extends HTMLElement {}); } export function preloadImages(images: string[]): void { From 5e7b825b809856f6bd3dff969464beeb9d372a08 Mon Sep 17 00:00:00 2001 From: thekiba Date: Wed, 11 Oct 2023 17:12:41 +0400 Subject: [PATCH 02/17] fix(ui): resolve premature promise resolution and unhandled popup closing scenarios Closes #67, closes #68 --- packages/ui/src/app/state/modals-state.ts | 47 +++++- .../modals/wallets-modal/wallets-modal.tsx | 16 +- packages/ui/src/app/widget-controller.tsx | 9 +- packages/ui/src/ton-connect-ui.ts | 150 ++++++++++++++---- 4 files changed, 180 insertions(+), 42 deletions(-) diff --git a/packages/ui/src/app/state/modals-state.ts b/packages/ui/src/app/state/modals-state.ts index 3e9ec2cb..b470e4c9 100644 --- a/packages/ui/src/app/state/modals-state.ts +++ b/packages/ui/src/app/state/modals-state.ts @@ -1,4 +1,4 @@ -import { createSignal } from 'solid-js'; +import { createMemo, createSignal } from 'solid-js'; import { WalletInfoWithOpenMethod, WalletOpenMethod } from 'src/models/connected-wallet'; import { LastSelectedWalletInfoStorage } from 'src/storage'; import { ReturnStrategy } from 'src/models'; @@ -19,9 +19,50 @@ export type ConfirmTransactionAction = BasicAction & { twaReturnUrl: `${string}://${string}`; }; -export const [walletsModalOpen, setWalletsModalOpen] = createSignal(false); +export type WalletModalOpened = { + state: 'opened'; + onClose: WalletsModalCloseFn; +}; + +export type WalletModalClosed = { + state: 'closed'; +}; + +export type WalletsModalState = WalletModalOpened | WalletModalClosed; + +export type WalletsModalCloseReason = 'cancel' | 'select-wallet' | 'close'; + +export type WalletsModalCloseFn = (reason: WalletsModalCloseReason) => void; + +export const [walletsModalState, setWalletsModalState] = createSignal({ + state: 'closed' +}); + +export const getWalletsModalIsOpened = createMemo(() => walletsModalState().state === 'opened'); + +export const getWalletsModalOnClose = createMemo(() => { + const state = walletsModalState(); + return state.state === 'opened' ? state.onClose : () => {}; +}); + +export const openWalletsModal = (onClose: WalletsModalCloseFn): void => { + setWalletsModalState({ + state: 'opened', + onClose + }); +}; + +export const closeWalletsModal = (reason: WalletsModalCloseReason): void => { + const onClose = getWalletsModalOnClose(); + onClose(reason); + + setWalletsModalState({ + state: 'closed' + }); +}; -let lastSelectedWalletInfoStorage = typeof window !== 'undefined' ? new LastSelectedWalletInfoStorage() : undefined; +let lastSelectedWalletInfoStorage = + typeof window !== 'undefined' ? new LastSelectedWalletInfoStorage() : undefined; export const [lastSelectedWalletInfo, _setLastSelectedWalletInfo] = createSignal< | WalletInfoWithOpenMethod | { diff --git a/packages/ui/src/app/views/modals/wallets-modal/wallets-modal.tsx b/packages/ui/src/app/views/modals/wallets-modal/wallets-modal.tsx index 737c2b5a..6cab0b76 100644 --- a/packages/ui/src/app/views/modals/wallets-modal/wallets-modal.tsx +++ b/packages/ui/src/app/views/modals/wallets-modal/wallets-modal.tsx @@ -17,7 +17,11 @@ import { useContext } from 'solid-js'; import { ConnectorContext } from 'src/app/state/connector.context'; -import { setWalletsModalOpen, walletsModalOpen } from 'src/app/state/modals-state'; +import { + closeWalletsModal, + getWalletsModalIsOpened, + WalletsModalCloseReason +} from 'src/app/state/modals-state'; import { StyledModal, LoaderContainerStyled, H1Styled } from './style'; import { TonConnectUiContext } from 'src/app/state/ton-connect-ui.context'; import { useI18n } from '@solid-primitives/i18n'; @@ -96,15 +100,15 @@ export const WalletsModal: Component = () => { ?.value; }); - const onClose = (): void => { - setWalletsModalOpen(false); + const onClose = (reason: WalletsModalCloseReason): void => { + closeWalletsModal(reason); setSelectedWalletInfo(null); setInfoTab(false); }; const unsubscribe = connector.onStatusChange(wallet => { if (wallet) { - onClose(); + onClose('select-wallet'); } }); @@ -112,8 +116,8 @@ export const WalletsModal: Component = () => { return ( onClose('cancel')} onClickQuestion={() => setInfoTab(v => !v)} data-tc-wallets-modal-container="true" > diff --git a/packages/ui/src/app/widget-controller.tsx b/packages/ui/src/app/widget-controller.tsx index 4018aaf7..ac41617e 100644 --- a/packages/ui/src/app/widget-controller.tsx +++ b/packages/ui/src/app/widget-controller.tsx @@ -2,18 +2,21 @@ import { render } from 'solid-js/web'; import { Action, + closeWalletsModal, lastSelectedWalletInfo, + openWalletsModal, setAction, setLastSelectedWalletInfo, - setWalletsModalOpen + WalletsModalCloseFn } 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'; export const widgetController = { - openWalletsModal: (): void => void setTimeout(() => setWalletsModalOpen(true)), - closeWalletsModal: (): void => void setTimeout(() => setWalletsModalOpen(false)), + openWalletsModal: (onClose: WalletsModalCloseFn): void => + void setTimeout(() => openWalletsModal(onClose)), + closeWalletsModal: (): void => void setTimeout(() => closeWalletsModal('close')), setAction: (action: Action): void => void setTimeout(() => setAction(action)), clearAction: (): void => void setTimeout(() => setAction(null)), getSelectedWalletInfo: (): diff --git a/packages/ui/src/ton-connect-ui.ts b/packages/ui/src/ton-connect-ui.ts index 90609ddc..43ab7b00 100644 --- a/packages/ui/src/ton-connect-ui.ts +++ b/packages/ui/src/ton-connect-ui.ts @@ -1,4 +1,8 @@ -import type { Account, ConnectAdditionalRequest } from '@tonconnect/sdk'; +import type { + Account, + ConnectAdditionalRequest, + WalletInfoCurrentlyEmbedded +} from '@tonconnect/sdk'; import { isTelegramUrl, isWalletInfoCurrentlyEmbedded, @@ -13,7 +17,7 @@ import { import { widgetController } from 'src/app/widget-controller'; import { TonConnectUIError } from 'src/errors/ton-connect-ui.error'; import { TonConnectUiCreateOptions } from 'src/models/ton-connect-ui-create-options'; -import { WalletInfoStorage, PreferredWalletStorage } from 'src/storage'; +import { PreferredWalletStorage, WalletInfoStorage } from 'src/storage'; import { addReturnStrategy, getSystemTheme, @@ -234,42 +238,18 @@ export class TonConnectUI { /** * Opens the modal window and handles a wallet connection. + * @return Connected wallet. + * @throws TonConnectUIError if connection was aborted. */ public async connectWallet(): Promise { const walletsList = await this.getWallets(); const embeddedWallet = walletsList.find(isWalletInfoCurrentlyEmbedded); if (embeddedWallet) { - const connect = (parameters?: ConnectAdditionalRequest): void => { - setLastSelectedWalletInfo(embeddedWallet); - this.connector.connect({ jsBridgeKey: embeddedWallet.jsBridgeKey }, parameters); - }; - - const additionalRequest = appState.connectRequestParameters; - if (additionalRequest?.state === 'loading') { - this.connectRequestParametersCallback = connect; - } else { - connect(additionalRequest?.value); - } + return await this.connectEmbeddedWallet(embeddedWallet); } else { - widgetController.openWalletsModal(); + return await this.connectExternalWallet(); } - - return new Promise((resolve, reject) => { - const unsubscribe = this.connector.onStatusChange(async wallet => { - unsubscribe!(); - if (wallet) { - const lastSelectedWalletInfo = await this.getSelectedWalletInfo(wallet); - - resolve({ - ...wallet, - ...(lastSelectedWalletInfo || this.walletInfoStorage.getWalletInfo())! - }); - } else { - reject(new TonConnectUIError('Wallet was not connected')); - } - }, reject); - }); } /** @@ -347,7 +327,112 @@ export class TonConnectUI { } } + /** + * Initiates a connection with an embedded wallet, awaits its completion, and returns the connected wallet information. + * @param embeddedWallet - Information about the embedded wallet to connect to. + * @throws Error if the connection process fails. + * @internal + */ + private async connectEmbeddedWallet( + embeddedWallet: WalletInfoCurrentlyEmbedded + ): Promise { + const connect = (parameters?: ConnectAdditionalRequest): void => { + setLastSelectedWalletInfo(embeddedWallet); + this.connector.connect({ jsBridgeKey: embeddedWallet.jsBridgeKey }, parameters); + }; + + const additionalRequest = appState.connectRequestParameters; + if (additionalRequest?.state === 'loading') { + this.connectRequestParametersCallback = connect; + } else { + connect(additionalRequest?.value); + } + + return await this.waitForWalletConnection({ + ignoreErrors: false + }); + } + + /** + * Initiates the connection process for an external wallet by opening the wallet modal + * and returns the connected wallet information upon successful connection. + * @throws Error if the user cancels the connection process or if the connection process fails. + * @internal + */ + private async connectExternalWallet(): Promise { + const abortController = new AbortController(); + + widgetController.openWalletsModal(reason => { + if (reason === 'cancel') { + abortController.abort(); + } + }); + + return await this.waitForWalletConnection({ + ignoreErrors: true, + abortSignal: abortController.signal + }); + } + + /** + * Waits for a wallet connection based on provided options, returning connected wallet information. + * @param options - Configuration for connection statuses and errors handling. + * @options.ignoreErrors - If true, ignores errors during waiting, waiting continues until a valid wallet connects. Default is false. + * @options.abortSignal - Optional AbortSignal for external cancellation. Throws TonConnectUIError if aborted. + * @throws TonConnectUIError if waiting is aborted or no valid wallet connection is received and ignoreErrors is false. + * @internal + */ + private async waitForWalletConnection( + options: WaitWalletConnectionOptions + ): Promise { + return new Promise((resolve, reject) => { + const { ignoreErrors = false, abortSignal = null } = options; + + if (abortSignal && abortSignal.aborted) { + return reject(new TonConnectUIError('Wallet was not connected')); + } + + const onStatusChangeHandler = async (wallet: ConnectedWallet | null): Promise => { + if (!wallet) { + if (ignoreErrors) { + // skip empty wallet status changes to avoid aborting the process + return; + } + + unsubscribe(); + reject(new TonConnectUIError('Wallet was not connected')); + } else { + unsubscribe(); + resolve(wallet); + } + }; + + const onErrorsHandler = (reason: TonConnectError): void => { + if (ignoreErrors) { + // skip errors to avoid aborting the process + return; + } + + unsubscribe(); + reject(reason); + }; + + const unsubscribe = this.onStatusChange( + (wallet: ConnectedWallet | null) => onStatusChangeHandler(wallet), + (reason: TonConnectError) => onErrorsHandler(reason) + ); + + if (abortSignal) { + abortSignal.addEventListener('abort', (): void => { + unsubscribe(); + reject(new TonConnectUIError('Wallet was not connected')); + }); + } + }); + } + private subscribeToWalletChange(): void { + // TODO: possible memory leak here, check it this.connector.onStatusChange(async wallet => { if (wallet) { await this.updateWalletInfo(wallet); @@ -496,3 +581,8 @@ export class TonConnectUI { }; } } + +type WaitWalletConnectionOptions = { + ignoreErrors?: boolean; + abortSignal?: AbortSignal | null; +}; From ac803aa7bec56f6358071a906879d814eb485c8d Mon Sep 17 00:00:00 2001 From: thekiba Date: Tue, 17 Oct 2023 01:49:36 +0400 Subject: [PATCH 03/17] feat(ui): introduce modal, fix promise handling, and refactor internal classes - Feature: Introduced a new modal for tonconnectUI to manage modal opening and closing. - Bugfix: Added promise termination when the user closes the modal to handle transaction waiting. - Refactor: Slight rework of the internal classes for better maintainability. BREAKING CHANGE: The method tonConnectUI.connectWallet() is now deprecated and will be removed in subsequent versions. Use tonConnectUI.openModal() instead. --- packages/ui/src/app/state/modals-state.ts | 63 ++++---- .../modals/wallets-modal/wallets-modal.tsx | 12 +- packages/ui/src/app/widget-controller.tsx | 22 ++- .../src/managers/transaction-modal-manager.ts | 47 ++++++ .../ui/src/managers/wallets-modal-manager.ts | 138 ++++++++++++++++++ packages/ui/src/ton-connect-ui.ts | 137 ++++++++++++++++- 6 files changed, 374 insertions(+), 45 deletions(-) create mode 100644 packages/ui/src/managers/transaction-modal-manager.ts create mode 100644 packages/ui/src/managers/wallets-modal-manager.ts diff --git a/packages/ui/src/app/state/modals-state.ts b/packages/ui/src/app/state/modals-state.ts index b470e4c9..b2dd12e6 100644 --- a/packages/ui/src/app/state/modals-state.ts +++ b/packages/ui/src/app/state/modals-state.ts @@ -19,47 +19,52 @@ export type ConfirmTransactionAction = BasicAction & { twaReturnUrl: `${string}://${string}`; }; +/** + * Opened modal window state. + */ export type WalletModalOpened = { - state: 'opened'; - onClose: WalletsModalCloseFn; + /** + * Modal window status. + */ + status: 'opened'; + + /** + * Always `null` for opened modal window. + */ + closeReason: null; }; +/** + * Closed modal window state. + */ export type WalletModalClosed = { - state: 'closed'; + /** + * Modal window status. + */ + status: 'closed'; + + /** + * Close reason, if the modal window was closed. + */ + closeReason: WalletsModalCloseReason | null; }; +/** + * Modal window state. + */ export type WalletsModalState = WalletModalOpened | WalletModalClosed; -export type WalletsModalCloseReason = 'cancel' | 'select-wallet' | 'close'; - -export type WalletsModalCloseFn = (reason: WalletsModalCloseReason) => void; +/** + * Modal window close reason. + */ +export type WalletsModalCloseReason = 'action-cancelled' | 'wallet-selected'; export const [walletsModalState, setWalletsModalState] = createSignal({ - state: 'closed' -}); - -export const getWalletsModalIsOpened = createMemo(() => walletsModalState().state === 'opened'); - -export const getWalletsModalOnClose = createMemo(() => { - const state = walletsModalState(); - return state.state === 'opened' ? state.onClose : () => {}; + status: 'closed', + closeReason: null }); -export const openWalletsModal = (onClose: WalletsModalCloseFn): void => { - setWalletsModalState({ - state: 'opened', - onClose - }); -}; - -export const closeWalletsModal = (reason: WalletsModalCloseReason): void => { - const onClose = getWalletsModalOnClose(); - onClose(reason); - - setWalletsModalState({ - state: 'closed' - }); -}; +export const getWalletsModalIsOpened = createMemo(() => walletsModalState().status === 'opened'); let lastSelectedWalletInfoStorage = typeof window !== 'undefined' ? new LastSelectedWalletInfoStorage() : undefined; diff --git a/packages/ui/src/app/views/modals/wallets-modal/wallets-modal.tsx b/packages/ui/src/app/views/modals/wallets-modal/wallets-modal.tsx index 6cab0b76..ac7252a5 100644 --- a/packages/ui/src/app/views/modals/wallets-modal/wallets-modal.tsx +++ b/packages/ui/src/app/views/modals/wallets-modal/wallets-modal.tsx @@ -18,11 +18,11 @@ import { } from 'solid-js'; import { ConnectorContext } from 'src/app/state/connector.context'; import { - closeWalletsModal, getWalletsModalIsOpened, + setWalletsModalState, WalletsModalCloseReason } from 'src/app/state/modals-state'; -import { StyledModal, LoaderContainerStyled, H1Styled } from './style'; +import { H1Styled, LoaderContainerStyled, StyledModal } from './style'; import { TonConnectUiContext } from 'src/app/state/ton-connect-ui.context'; import { useI18n } from '@solid-primitives/i18n'; import { appState } from 'src/app/state/app.state'; @@ -100,15 +100,15 @@ export const WalletsModal: Component = () => { ?.value; }); - const onClose = (reason: WalletsModalCloseReason): void => { - closeWalletsModal(reason); + const onClose = (closeReason: WalletsModalCloseReason): void => { + setWalletsModalState({ status: 'closed', closeReason: closeReason }); setSelectedWalletInfo(null); setInfoTab(false); }; const unsubscribe = connector.onStatusChange(wallet => { if (wallet) { - onClose('select-wallet'); + onClose('wallet-selected'); } }); @@ -117,7 +117,7 @@ export const WalletsModal: Component = () => { return ( onClose('cancel')} + onClose={() => onClose('action-cancelled')} onClickQuestion={() => setInfoTab(v => !v)} data-tc-wallets-modal-container="true" > diff --git a/packages/ui/src/app/widget-controller.tsx b/packages/ui/src/app/widget-controller.tsx index ac41617e..815b07f2 100644 --- a/packages/ui/src/app/widget-controller.tsx +++ b/packages/ui/src/app/widget-controller.tsx @@ -2,21 +2,31 @@ import { render } from 'solid-js/web'; import { Action, - closeWalletsModal, lastSelectedWalletInfo, - openWalletsModal, setAction, setLastSelectedWalletInfo, - WalletsModalCloseFn + setWalletsModalState, + WalletsModalCloseReason } 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'; export const widgetController = { - openWalletsModal: (onClose: WalletsModalCloseFn): void => - void setTimeout(() => openWalletsModal(onClose)), - closeWalletsModal: (): void => void setTimeout(() => closeWalletsModal('close')), + openWalletsModal: (): void => + void setTimeout(() => + setWalletsModalState({ + status: 'opened', + closeReason: null + }) + ), + closeWalletsModal: (reason: WalletsModalCloseReason): void => + void setTimeout(() => + setWalletsModalState({ + 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/transaction-modal-manager.ts b/packages/ui/src/managers/transaction-modal-manager.ts new file mode 100644 index 00000000..febad5ec --- /dev/null +++ b/packages/ui/src/managers/transaction-modal-manager.ts @@ -0,0 +1,47 @@ +import { ITonConnect } from '@tonconnect/sdk'; +import { createEffect } from 'solid-js'; +import { action, Action } from 'src/app/state/modals-state'; + +interface TransactionModalManagerCreateOptions { + /** + * TonConnect instance. + */ + connector: ITonConnect; +} + +/** + * Manages the transaction modal window state. + */ +export class TransactionModalManager { + /** + * TonConnect instance. + * @internal + */ + private readonly connector: ITonConnect; + + /** + * List of subscribers to the modal window state changes. + * @internal + */ + private consumers: Array<(action: Action | null) => void> = []; + + constructor(options: TransactionModalManagerCreateOptions) { + this.connector = options.connector; + + createEffect(() => { + const _ = action(); + this.consumers.forEach(consumer => consumer(_)); + }); + } + + /** + * Subscribe to the modal window state changes, returns unsubscribe function. + */ + public onStateChange(consumer: (action: Action | null) => void): () => void { + this.consumers.push(consumer); + + return () => { + this.consumers = this.consumers.filter(c => c !== consumer); + }; + } +} diff --git a/packages/ui/src/managers/wallets-modal-manager.ts b/packages/ui/src/managers/wallets-modal-manager.ts new file mode 100644 index 00000000..2054a3f5 --- /dev/null +++ b/packages/ui/src/managers/wallets-modal-manager.ts @@ -0,0 +1,138 @@ +import { + setLastSelectedWalletInfo, + walletsModalState, + WalletsModalState +} from 'src/app/state/modals-state'; +import { createEffect } from 'solid-js'; +import { + ConnectAdditionalRequest, + isWalletInfoCurrentlyEmbedded, + ITonConnect, + WalletInfoCurrentlyEmbedded +} from '@tonconnect/sdk'; +import { appState } from 'src/app/state/app.state'; +import { widgetController } from 'src/app/widget-controller'; + +interface WalletsModalManagerCreateOptions { + /** + * TonConnect instance. + */ + connector: ITonConnect; + + /** + * Set connect request parameters callback. + */ + setConnectRequestParametersCallback: ( + callback: (parameters?: ConnectAdditionalRequest) => void + ) => void; +} + +/** + * Manages the modal window state. + */ +export class WalletsModalManager { + /** + * 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: WalletsModalState) => void> = []; + + /** + * Current modal window state. + */ + public state: WalletsModalState = walletsModalState(); + + constructor(options: WalletsModalManagerCreateOptions) { + this.connector = options.connector; + this.setConnectRequestParametersCallback = options.setConnectRequestParametersCallback; + + createEffect(() => { + const state = walletsModalState(); + this.state = state; + this.consumers.forEach(consumer => consumer(state)); + }); + } + + /** + * Opens the modal window. + */ + public async open(): Promise { + const walletsList = await this.connector.getWallets(); + const embeddedWallet = walletsList.find(isWalletInfoCurrentlyEmbedded); + + if (embeddedWallet) { + return this.connectEmbeddedWallet(embeddedWallet); + } else { + return this.connectExternalWallet(); + } + } + + /** + * Closes the modal window. + */ + public close(): void { + widgetController.closeWalletsModal('action-cancelled'); + } + + /** + * Subscribe to the modal window state changes, returns unsubscribe function. + */ + public onStateChange(onChange: (state: WalletsModalState) => 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 an external wallet, and waits when modal window is opened. + * @internal + */ + private async connectExternalWallet(): Promise { + widgetController.openWalletsModal(); + + return new Promise(resolve => { + const unsubscribe = this.onStateChange(state => { + const { status } = state; + if (status === 'opened') { + unsubscribe(); + resolve(); + } + }); + }); + } +} diff --git a/packages/ui/src/ton-connect-ui.ts b/packages/ui/src/ton-connect-ui.ts index 43ab7b00..9f955ab9 100644 --- a/packages/ui/src/ton-connect-ui.ts +++ b/packages/ui/src/ton-connect-ui.ts @@ -32,12 +32,14 @@ import { setBorderRadius, setColors, setTheme } from 'src/app/state/theme-state' import { mergeOptions } from 'src/app/utils/options'; import { appState, setAppState } from 'src/app/state/app.state'; import { unwrap } from 'solid-js/store'; -import { setLastSelectedWalletInfo } from 'src/app/state/modals-state'; +import { Action, setLastSelectedWalletInfo, WalletsModalState } from 'src/app/state/modals-state'; import { ActionConfiguration, StrictActionConfiguration } from 'src/models/action-configuration'; import { ConnectedWallet, WalletInfoWithOpenMethod } from 'src/models/connected-wallet'; import { applyWalletsListConfiguration, eqWalletName } from 'src/app/utils/wallets'; import { uniq } from 'src/app/utils/array'; import { Loadable } from 'src/models/loadable'; +import { WalletsModalManager } from 'src/managers/wallets-modal-manager'; +import { TransactionModalManager } from 'src/managers/transaction-modal-manager'; export class TonConnectUI { public static getWallets(): Promise { @@ -68,6 +70,16 @@ export class TonConnectUI { */ public readonly connectionRestored = Promise.resolve(false); + /** + * Manages the modal window state. + */ + public readonly modal: WalletsModalManager; + + /** + * Manages the transaction modal window state. + */ + public readonly transactionModal: TransactionModalManager; + /** * Current connection status. */ @@ -162,6 +174,19 @@ export class TonConnectUI { ); } + this.modal = new WalletsModalManager({ + connector: this.connector, + setConnectRequestParametersCallback: ( + callback: (parameters?: ConnectAdditionalRequest) => void + ) => { + this.connectRequestParametersCallback = callback; + } + }); + + this.transactionModal = new TransactionModalManager({ + connector: this.connector + }); + this.walletsList = this.getWallets(); this.walletsList.then(list => preloadImages(uniq(list.map(item => item.imageUrl)))); @@ -237,6 +262,35 @@ export class TonConnectUI { } /** + * Opens the modal window, returns a promise that resolves after the modal window is opened. + */ + public async openModal(): Promise { + return await this.modal.open(); + } + + /** + * Closes the modal window. + */ + public closeModal(): void { + this.modal.close(); + } + + /** + * Subscribe to the modal window state changes, returns a function which has to be called to unsubscribe. + */ + public onModalStateChange(onChange: (state: WalletsModalState) => void): () => void { + return this.modal.onStateChange(onChange); + } + + /** + * Returns current modal window state. + */ + public get modalState(): WalletsModalState { + return this.modal.state; + } + + /** + * @deprecated Use `tonConnectUI.openModal()` instead. Will be removed in the next major version. * Opens the modal window and handles a wallet connection. * @return Connected wallet. * @throws TonConnectUIError if connection was aborted. @@ -301,8 +355,24 @@ export class TonConnectUI { openModal: modals.includes('before') }); + const abortController = new AbortController(); + + const unsubscribe = this.onTransactionModalStateChange(action => { + if (action?.openModal) { + return; + } + + unsubscribe(); + if (!action) { + abortController.abort(); + } + }); + try { - const result = await this.connector.sendTransaction(tx); + const result = await this.waitForSendTransaction({ + transaction: tx, + abortSignal: abortController.signal + }); widgetController.setAction({ name: 'transaction-sent', @@ -324,6 +394,8 @@ export class TonConnectUI { console.error(e); throw new TonConnectUIError('Unhandled error:' + e); } + } finally { + unsubscribe(); } } @@ -362,8 +434,16 @@ export class TonConnectUI { private async connectExternalWallet(): Promise { const abortController = new AbortController(); - widgetController.openWalletsModal(reason => { - if (reason === 'cancel') { + widgetController.openWalletsModal(); + + const unsubscribe = this.onModalStateChange(state => { + const { status, closeReason } = state; + if (status === 'opened') { + return; + } + + unsubscribe(); + if (closeReason === 'action-cancelled') { abortController.abort(); } }); @@ -431,6 +511,50 @@ export class TonConnectUI { }); } + /** + * Waits for a transaction to be sent based on provided options, returning the transaction response. + * @param options - Configuration for transaction statuses and errors handling. + * @options.transaction - Transaction to send. + * @options.ignoreErrors - If true, ignores errors during waiting, waiting continues until a valid transaction is sent. Default is false. + * @options.abortSignal - Optional AbortSignal for external cancellation. Throws TonConnectUIError if aborted. + * @throws TonConnectUIError if waiting is aborted or no valid transaction response is received and ignoreErrors is false. + * @internal + */ + private async waitForSendTransaction( + options: WaitSendTransactionOptions + ): Promise { + return new Promise((resolve, reject) => { + const { transaction, abortSignal } = options; + + if (abortSignal.aborted) { + return reject(new TonConnectUIError('Transaction was not sent')); + } + + const onTransactionHandler = async ( + transaction: SendTransactionResponse + ): Promise => { + resolve(transaction); + }; + + const onErrorsHandler = (reason: TonConnectError): void => { + reject(reason); + }; + + this.connector + .sendTransaction(transaction) + .then(result => onTransactionHandler(result)) + .catch(reason => onErrorsHandler(reason)); + + abortSignal.addEventListener('abort', (): void => { + reject(new TonConnectUIError('Transaction was not sent')); + }); + }); + } + + private onTransactionModalStateChange(onChange: (action: Action | null) => void): () => void { + return this.transactionModal.onStateChange(onChange); + } + private subscribeToWalletChange(): void { // TODO: possible memory leak here, check it this.connector.onStatusChange(async wallet => { @@ -586,3 +710,8 @@ type WaitWalletConnectionOptions = { ignoreErrors?: boolean; abortSignal?: AbortSignal | null; }; + +type WaitSendTransactionOptions = { + transaction: SendTransactionRequest; + abortSignal: AbortSignal; +}; From 5618a0ae945b2aad50cb994d4cad8371f029bf65 Mon Sep 17 00:00:00 2001 From: thekiba Date: Wed, 18 Oct 2023 03:36:54 +0400 Subject: [PATCH 04/17] feat(ui): refactor wallet modal interfaces and expose them publicly --- packages/ui/src/app/state/modals-state.ts | 41 +------------ .../modals/wallets-modal/wallets-modal.tsx | 7 +-- packages/ui/src/app/widget-controller.tsx | 4 +- .../ui/src/managers/wallets-modal-manager.ts | 9 +-- packages/ui/src/models/index.ts | 7 +++ packages/ui/src/models/wallets-modal.ts | 61 +++++++++++++++++++ packages/ui/src/ton-connect-ui.ts | 27 +++++--- 7 files changed, 95 insertions(+), 61 deletions(-) create mode 100644 packages/ui/src/models/wallets-modal.ts diff --git a/packages/ui/src/app/state/modals-state.ts b/packages/ui/src/app/state/modals-state.ts index b2dd12e6..a2a3a606 100644 --- a/packages/ui/src/app/state/modals-state.ts +++ b/packages/ui/src/app/state/modals-state.ts @@ -2,6 +2,7 @@ import { createMemo, createSignal } from 'solid-js'; import { WalletInfoWithOpenMethod, WalletOpenMethod } from 'src/models/connected-wallet'; import { LastSelectedWalletInfoStorage } from 'src/storage'; import { ReturnStrategy } from 'src/models'; +import { WalletsModalState } from 'src/models/wallets-modal'; export type ActionName = 'confirm-transaction' | 'transaction-sent' | 'transaction-canceled'; @@ -19,46 +20,6 @@ export type ConfirmTransactionAction = BasicAction & { twaReturnUrl: `${string}://${string}`; }; -/** - * Opened modal window state. - */ -export type WalletModalOpened = { - /** - * Modal window status. - */ - status: 'opened'; - - /** - * Always `null` for opened modal window. - */ - closeReason: null; -}; - -/** - * Closed modal window state. - */ -export type WalletModalClosed = { - /** - * Modal window status. - */ - status: 'closed'; - - /** - * Close reason, if the modal window was closed. - */ - closeReason: WalletsModalCloseReason | null; -}; - -/** - * Modal window state. - */ -export type WalletsModalState = WalletModalOpened | WalletModalClosed; - -/** - * Modal window close reason. - */ -export type WalletsModalCloseReason = 'action-cancelled' | 'wallet-selected'; - export const [walletsModalState, setWalletsModalState] = createSignal({ status: 'closed', closeReason: null diff --git a/packages/ui/src/app/views/modals/wallets-modal/wallets-modal.tsx b/packages/ui/src/app/views/modals/wallets-modal/wallets-modal.tsx index ac7252a5..b7655910 100644 --- a/packages/ui/src/app/views/modals/wallets-modal/wallets-modal.tsx +++ b/packages/ui/src/app/views/modals/wallets-modal/wallets-modal.tsx @@ -17,11 +17,7 @@ import { useContext } from 'solid-js'; import { ConnectorContext } from 'src/app/state/connector.context'; -import { - getWalletsModalIsOpened, - setWalletsModalState, - WalletsModalCloseReason -} from 'src/app/state/modals-state'; +import { getWalletsModalIsOpened, setWalletsModalState } from 'src/app/state/modals-state'; import { H1Styled, LoaderContainerStyled, StyledModal } from './style'; import { TonConnectUiContext } from 'src/app/state/ton-connect-ui.context'; import { useI18n } from '@solid-primitives/i18n'; @@ -39,6 +35,7 @@ import { MobileConnectionModal } from 'src/app/views/modals/wallets-modal/mobile import { MobileUniversalModal } from 'src/app/views/modals/wallets-modal/mobile-universal-modal'; import { DesktopUniversalModal } from 'src/app/views/modals/wallets-modal/desltop-universal-modal'; import { Dynamic } from 'solid-js/web'; +import { WalletsModalCloseReason } from 'src/models'; export const WalletsModal: Component = () => { const { locale } = useI18n()[1]; diff --git a/packages/ui/src/app/widget-controller.tsx b/packages/ui/src/app/widget-controller.tsx index 815b07f2..454fcb4f 100644 --- a/packages/ui/src/app/widget-controller.tsx +++ b/packages/ui/src/app/widget-controller.tsx @@ -5,12 +5,12 @@ import { lastSelectedWalletInfo, setAction, setLastSelectedWalletInfo, - setWalletsModalState, - WalletsModalCloseReason + 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'; export const widgetController = { openWalletsModal: (): void => diff --git a/packages/ui/src/managers/wallets-modal-manager.ts b/packages/ui/src/managers/wallets-modal-manager.ts index 2054a3f5..92309ae8 100644 --- a/packages/ui/src/managers/wallets-modal-manager.ts +++ b/packages/ui/src/managers/wallets-modal-manager.ts @@ -1,8 +1,4 @@ -import { - setLastSelectedWalletInfo, - walletsModalState, - WalletsModalState -} from 'src/app/state/modals-state'; +import { setLastSelectedWalletInfo, walletsModalState } from 'src/app/state/modals-state'; import { createEffect } from 'solid-js'; import { ConnectAdditionalRequest, @@ -12,6 +8,7 @@ import { } from '@tonconnect/sdk'; import { appState } from 'src/app/state/app.state'; import { widgetController } from 'src/app/widget-controller'; +import { WalletsModal, WalletsModalState } from 'src/models/wallets-modal'; interface WalletsModalManagerCreateOptions { /** @@ -30,7 +27,7 @@ interface WalletsModalManagerCreateOptions { /** * Manages the modal window state. */ -export class WalletsModalManager { +export class WalletsModalManager implements WalletsModal { /** * TonConnect instance. * @internal diff --git a/packages/ui/src/models/index.ts b/packages/ui/src/models/index.ts index 7b626fd7..661dd081 100644 --- a/packages/ui/src/models/index.ts +++ b/packages/ui/src/models/index.ts @@ -25,3 +25,10 @@ import { Property } from 'csstype'; type Color = Property.Color; export type { Color }; export type { Loadable, LoadableReady, LoadableLoading } from './loadable'; +export type { + WalletsModal, + WalletsModalState, + WalletModalOpened, + WalletModalClosed, + WalletsModalCloseReason +} from './wallets-modal'; diff --git a/packages/ui/src/models/wallets-modal.ts b/packages/ui/src/models/wallets-modal.ts new file mode 100644 index 00000000..4945489b --- /dev/null +++ b/packages/ui/src/models/wallets-modal.ts @@ -0,0 +1,61 @@ +export interface WalletsModal { + /** + * Open the modal. + */ + open: () => void; + + /** + * Close the modal. + */ + close: () => void; + + /** + * Subscribe to the modal window status changes. + */ + onStateChange: (callback: (state: WalletsModalState) => void) => () => void; + + /** + * Current modal window state. + */ + state: WalletsModalState; +} + +/** + * Opened modal window state. + */ +export type WalletModalOpened = { + /** + * Modal window status. + */ + status: 'opened'; + + /** + * Always `null` for opened modal window. + */ + closeReason: null; +}; + +/** + * Closed modal window state. + */ +export type WalletModalClosed = { + /** + * Modal window status. + */ + status: 'closed'; + + /** + * Close reason, if the modal window was closed. + */ + closeReason: WalletsModalCloseReason | null; +}; + +/** + * Modal window state. + */ +export type WalletsModalState = WalletModalOpened | WalletModalClosed; + +/** + * Modal window close reason. + */ +export type WalletsModalCloseReason = 'action-cancelled' | 'wallet-selected'; diff --git a/packages/ui/src/ton-connect-ui.ts b/packages/ui/src/ton-connect-ui.ts index 9f955ab9..028e83ef 100644 --- a/packages/ui/src/ton-connect-ui.ts +++ b/packages/ui/src/ton-connect-ui.ts @@ -32,7 +32,7 @@ import { setBorderRadius, setColors, setTheme } from 'src/app/state/theme-state' import { mergeOptions } from 'src/app/utils/options'; import { appState, setAppState } from 'src/app/state/app.state'; import { unwrap } from 'solid-js/store'; -import { Action, setLastSelectedWalletInfo, WalletsModalState } from 'src/app/state/modals-state'; +import { Action, setLastSelectedWalletInfo } from 'src/app/state/modals-state'; import { ActionConfiguration, StrictActionConfiguration } from 'src/models/action-configuration'; import { ConnectedWallet, WalletInfoWithOpenMethod } from 'src/models/connected-wallet'; import { applyWalletsListConfiguration, eqWalletName } from 'src/app/utils/wallets'; @@ -40,6 +40,7 @@ import { uniq } from 'src/app/utils/array'; import { Loadable } from 'src/models/loadable'; import { WalletsModalManager } from 'src/managers/wallets-modal-manager'; import { TransactionModalManager } from 'src/managers/transaction-modal-manager'; +import { WalletsModal, WalletsModalState } from 'src/models/wallets-modal'; export class TonConnectUI { public static getWallets(): Promise { @@ -50,8 +51,6 @@ export class TonConnectUI { private readonly preferredWalletStorage = new PreferredWalletStorage(); - public readonly connector: ITonConnect; - private walletInfo: WalletInfoWithOpenMethod | null = null; private systemThemeChangeUnsubscribe: (() => void) | null = null; @@ -65,21 +64,26 @@ export class TonConnectUI { ) => void; /** - * Promise that resolves after end of th connection restoring process (promise will fire after `onStatusChange`, so you can get actual information about wallet and session after when promise resolved). - * Resolved value `true`/`false` indicates if the session was restored successfully. + * TonConnect instance. */ - public readonly connectionRestored = Promise.resolve(false); + public readonly connector: ITonConnect; /** * Manages the modal window state. */ - public readonly modal: WalletsModalManager; + public readonly modal: WalletsModal; /** * Manages the transaction modal window state. */ public readonly transactionModal: TransactionModalManager; + /** + * Promise that resolves after end of th connection restoring process (promise will fire after `onStatusChange`, so you can get actual information about wallet and session after when promise resolved). + * Resolved value `true`/`false` indicates if the session was restored successfully. + */ + public readonly connectionRestored = Promise.resolve(false); + /** * Current connection status. */ @@ -265,7 +269,7 @@ export class TonConnectUI { * Opens the modal window, returns a promise that resolves after the modal window is opened. */ public async openModal(): Promise { - return await this.modal.open(); + return this.modal.open(); } /** @@ -400,6 +404,7 @@ export class TonConnectUI { } /** + * TODO: remove in the next major version. * Initiates a connection with an embedded wallet, awaits its completion, and returns the connected wallet information. * @param embeddedWallet - Information about the embedded wallet to connect to. * @throws Error if the connection process fails. @@ -426,6 +431,7 @@ export class TonConnectUI { } /** + * TODO: remove in the next major version. * Initiates the connection process for an external wallet by opening the wallet modal * and returns the connected wallet information upon successful connection. * @throws Error if the user cancels the connection process or if the connection process fails. @@ -455,6 +461,7 @@ export class TonConnectUI { } /** + * TODO: remove in the next major version. * Waits for a wallet connection based on provided options, returning connected wallet information. * @param options - Configuration for connection statuses and errors handling. * @options.ignoreErrors - If true, ignores errors during waiting, waiting continues until a valid wallet connects. Default is false. @@ -551,6 +558,10 @@ export class TonConnectUI { }); } + /** + * Subscribe to the transaction modal window state changes, returns a function which has to be called to unsubscribe. + * @internal + */ private onTransactionModalStateChange(onChange: (action: Action | null) => void): () => void { return this.transactionModal.onStateChange(onChange); } From d1e238154694d142bae67b21bfc81deb30647d49 Mon Sep 17 00:00:00 2001 From: thekiba Date: Wed, 18 Oct 2023 03:43:36 +0400 Subject: [PATCH 05/17] feat(ui-react): add useTonConnectModal() hook for modal management --- packages/ui-react/src/hooks/index.ts | 1 + packages/ui-react/src/hooks/useTonAddress.ts | 2 +- .../ui-react/src/hooks/useTonConnectModal.ts | 33 +++++++++++++++++++ packages/ui-react/src/hooks/useTonWallet.ts | 6 ++-- 4 files changed, 38 insertions(+), 4 deletions(-) create mode 100644 packages/ui-react/src/hooks/useTonConnectModal.ts diff --git a/packages/ui-react/src/hooks/index.ts b/packages/ui-react/src/hooks/index.ts index 8d5ff062..b5cb6f27 100644 --- a/packages/ui-react/src/hooks/index.ts +++ b/packages/ui-react/src/hooks/index.ts @@ -1,4 +1,5 @@ export { useTonAddress } from './useTonAddress'; +export { useTonConnectModal } from './useTonConnectModal'; export { useTonConnectUI } from './useTonConnectUI'; export { useTonWallet } from './useTonWallet'; export { useIsConnectionRestored } from './useIsConnectionRestored'; diff --git a/packages/ui-react/src/hooks/useTonAddress.ts b/packages/ui-react/src/hooks/useTonAddress.ts index e7def794..15f99c71 100644 --- a/packages/ui-react/src/hooks/useTonAddress.ts +++ b/packages/ui-react/src/hooks/useTonAddress.ts @@ -1,5 +1,5 @@ -import { useTonWallet } from './useTonWallet'; import { CHAIN, toUserFriendlyAddress } from '@tonconnect/ui'; +import { useTonWallet } from './useTonWallet'; /** * Use it to get user's current ton wallet address. If wallet is not connected hook will return empty string. diff --git a/packages/ui-react/src/hooks/useTonConnectModal.ts b/packages/ui-react/src/hooks/useTonConnectModal.ts new file mode 100644 index 00000000..02e64673 --- /dev/null +++ b/packages/ui-react/src/hooks/useTonConnectModal.ts @@ -0,0 +1,33 @@ +import { WalletsModal, WalletsModalState } from '@tonconnect/ui'; +import { useTonConnectUI } from './useTonConnectUI'; +import { useEffect, useState } from 'react'; + +/** + * Use it to get access to the open/close modal functions. + */ +export function useTonConnectModal(): Omit { + const [tonConnectUI] = useTonConnectUI(); + const [state, setState] = useState(tonConnectUI?.modal.state || null); + + useEffect(() => { + if (tonConnectUI) { + return tonConnectUI.onModalStateChange((value: WalletsModalState) => { + setState(value); + }); + } + }, [tonConnectUI]); + + return { + state, + open: () => { + if (tonConnectUI) { + return tonConnectUI.modal.open(); + } + }, + close: () => { + if (tonConnectUI) { + tonConnectUI.modal.close(); + } + } + }; +} diff --git a/packages/ui-react/src/hooks/useTonWallet.ts b/packages/ui-react/src/hooks/useTonWallet.ts index 00958a8c..fd078f44 100644 --- a/packages/ui-react/src/hooks/useTonWallet.ts +++ b/packages/ui-react/src/hooks/useTonWallet.ts @@ -1,6 +1,6 @@ -import { useTonConnectUI } from './useTonConnectUI'; import { useEffect, useState } from 'react'; -import { WalletInfoWithOpenMethod, Wallet } from '@tonconnect/ui'; +import { WalletInfoWithOpenMethod, Wallet, ConnectedWallet } from '@tonconnect/ui'; +import { useTonConnectUI } from './useTonConnectUI'; /** * Use it to get user's current ton wallet. If wallet is not connected hook will return null. @@ -13,7 +13,7 @@ export function useTonWallet(): Wallet | (Wallet & WalletInfoWithOpenMethod) | n useEffect(() => { if (tonConnectUI) { - return tonConnectUI.onStatusChange(value => { + return tonConnectUI.onStatusChange((value: ConnectedWallet | null) => { setWallet(value); }); } From ba122608cef319ebc370d9efa121737fb88dafce Mon Sep 17 00:00:00 2001 From: thekiba Date: Fri, 20 Oct 2023 22:08:15 +0400 Subject: [PATCH 06/17] refactor(ui): hide transactionModal param in tonConnectUI --- packages/ui/src/ton-connect-ui.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/ton-connect-ui.ts b/packages/ui/src/ton-connect-ui.ts index 028e83ef..7c91fabf 100644 --- a/packages/ui/src/ton-connect-ui.ts +++ b/packages/ui/src/ton-connect-ui.ts @@ -75,8 +75,9 @@ export class TonConnectUI { /** * Manages the transaction modal window state. + * TODO: make it public when interface will be ready for external usage. */ - public readonly transactionModal: TransactionModalManager; + private readonly transactionModal: TransactionModalManager; /** * Promise that resolves after end of th connection restoring process (promise will fire after `onStatusChange`, so you can get actual information about wallet and session after when promise resolved). From ff8361138dac5efb1086ab5791e0700e67190491 Mon Sep 17 00:00:00 2001 From: thekiba Date: Thu, 12 Oct 2023 22:19:10 +0400 Subject: [PATCH 07/17] fix(ui): implement back button handler for modal popup on Android devices Closes #70 --- .../ui/src/app/components/modal/index.tsx | 5 ++ .../app/directives/android-back-handler.tsx | 52 +++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 packages/ui/src/app/directives/android-back-handler.tsx diff --git a/packages/ui/src/app/components/modal/index.tsx b/packages/ui/src/app/components/modal/index.tsx index f77ecfeb..8b79420f 100644 --- a/packages/ui/src/app/components/modal/index.tsx +++ b/packages/ui/src/app/components/modal/index.tsx @@ -3,6 +3,7 @@ import { Component, createEffect, JSXElement, Show } from 'solid-js'; import { Transition } from 'solid-transition-group'; import clickOutsideDirective from 'src/app/directives/click-outside'; import keyPressedDirective from 'src/app/directives/key-pressed'; +import androidBackHandlerDirective from 'src/app/directives/android-back-handler'; import { Styleable } from 'src/app/models/styleable'; import { isDevice, media } from 'src/app/styles/media'; import { @@ -19,8 +20,10 @@ import { disableScroll, enableScroll } from 'src/app/utils/web-api'; import { WithDataAttributes } from 'src/app/models/with-data-attributes'; import { useDataAttributes } from 'src/app/hooks/use-data-attributes'; import { TonConnectBrand } from 'src/app/components'; + const clickOutside = clickOutsideDirective; const keyPressed = keyPressedDirective; +const androidBackHandler = androidBackHandlerDirective; export interface ModalProps extends Styleable, WithDataAttributes { children: JSXElement; @@ -90,6 +93,7 @@ export const Modal: Component = props => { css` border-radius: ${borders[theme!.borderRadius]}; background-color: ${theme.colors.background.tint}; + ${media('mobile')} { border-radius: ${borders[theme!.borderRadius]} ${borders[theme!.borderRadius]} 0 0; @@ -98,6 +102,7 @@ export const Modal: Component = props => { )} use:clickOutside={() => props.onClose()} use:keyPressed={() => props.onClose()} + use:androidBackHandler={() => props.onClose()} > props.onClose()} /> diff --git a/packages/ui/src/app/directives/android-back-handler.tsx b/packages/ui/src/app/directives/android-back-handler.tsx new file mode 100644 index 00000000..8b34972b --- /dev/null +++ b/packages/ui/src/app/directives/android-back-handler.tsx @@ -0,0 +1,52 @@ +import { Accessor, onCleanup } from 'solid-js'; +import { getUserAgent } from 'src/app/utils/web-api'; + +/** + * A directive that enhances the behavior of modal-like components on Android devices. + * + * On Android, users commonly expect the back button to dismiss modals or pop-ups. This directive + * listens for the 'popstate' event, which is triggered by pressing the back button, and then + * executes the provided onClose callback to handle the modal dismissal. It also manages the + * browser history to ensure that the modal's appearance and disappearance correlate with + * history push and pop actions, respectively. + * + * Usage: + * ```jsx + *
+ * ... your modal content ... + *
+ * ``` + */ +export default function androidBackHandler(_: Element, accessor: Accessor<() => void>): void { + const userOSIsAndroid = getUserAgent().os === 'android'; + if (!userOSIsAndroid) { + return; + } + + let historyEntryAdded = true; + window.history.pushState({}, ''); + + const popstateHandler = (event: PopStateEvent): void => { + historyEntryAdded = false; + event.preventDefault(); + accessor()?.(); + }; + window.addEventListener('popstate', popstateHandler, { once: true }); + + onCleanup((): void => { + window.removeEventListener('popstate', popstateHandler); + + if (historyEntryAdded) { + historyEntryAdded = false; + window.history.back(); + } + }); +} + +declare module 'solid-js' { + namespace JSX { + interface Directives { + androidBackHandler: () => void; + } + } +} From 17bbc9a66eb01aad10dcc9bd91ba25696c652ba8 Mon Sep 17 00:00:00 2001 From: thekiba Date: Thu, 19 Oct 2023 23:30:18 +0400 Subject: [PATCH 08/17] fix(ui): prevent reappearing of success tooltip on re-render --- .../ui/src/app/hooks/use-notifications.ts | 77 +++++++++++++++++++ .../account-button/notifications/index.tsx | 41 +--------- 2 files changed, 80 insertions(+), 38 deletions(-) create mode 100644 packages/ui/src/app/hooks/use-notifications.ts diff --git a/packages/ui/src/app/hooks/use-notifications.ts b/packages/ui/src/app/hooks/use-notifications.ts new file mode 100644 index 00000000..3c53c7af --- /dev/null +++ b/packages/ui/src/app/hooks/use-notifications.ts @@ -0,0 +1,77 @@ +import { Accessor, createEffect, createSignal, on, onCleanup } from 'solid-js'; +import { Action, action, ActionName } from 'src/app/state/modals-state'; + +type Notification = { + action: ActionName; +}; + +/** + * Hook for opened notifications. + */ +export type UseOpenedNotifications = Accessor; + +/** + * Config for useOpenedNotifications hook. + */ +export type UseOpenedNotificationsConfig = { + /** + * Timeout in milliseconds after which the notification will be removed, default is 4500. + */ + timeout?: number; +}; + +const defaultConfig: UseOpenedNotificationsConfig = { + timeout: 4500 +}; + +const [latestAction, setLatestAction] = createSignal(null); + +export function useOpenedNotifications( + config?: UseOpenedNotificationsConfig +): UseOpenedNotifications { + const { timeout } = { ...defaultConfig, ...config }; + + const [openedNotifications, setOpenedNotifications] = createSignal([]); + const [timeoutIds, setTimeoutIds] = createSignal[]>([]); + + createEffect( + on(action, (action: Action | null): void => { + // do nothing if action is null or should not show notification + if (!action || !action.showNotification) { + return; + } + + // do nothing if action is the same as latest action + if (latestAction() === action) { + return; + } + + setLatestAction(action); + + // cleanup all not confirmed transactions + setOpenedNotifications(openedNotifications => + openedNotifications.filter(n => n.action !== 'confirm-transaction') + ); + + // create notification + const notification: Notification = { action: action.name }; + setOpenedNotifications(openedNotifications => [...openedNotifications, notification]); + + // remove notification after timeout + const timeoutId = setTimeout(() => { + setOpenedNotifications(openedNotifications => + openedNotifications.filter(n => n !== notification) + ); + setTimeoutIds(timeoutIds => timeoutIds.filter(id => id !== timeoutId)); + }, timeout); + setTimeoutIds(timeoutIds => [...timeoutIds, timeoutId]); + }) + ); + + // cleanup all timeouts on unmount + onCleanup(() => { + timeoutIds().forEach(id => clearTimeout(id)); + }); + + return openedNotifications; +} diff --git a/packages/ui/src/app/views/account-button/notifications/index.tsx b/packages/ui/src/app/views/account-button/notifications/index.tsx index 144f2ae5..63486057 100644 --- a/packages/ui/src/app/views/account-button/notifications/index.tsx +++ b/packages/ui/src/app/views/account-button/notifications/index.tsx @@ -1,51 +1,16 @@ -import { Component, createEffect, createSignal, For, Match, on, onCleanup, Switch } from 'solid-js'; +import { Component, For, Match, Switch } from 'solid-js'; import { TransitionGroup } from 'solid-transition-group'; -import { ActionName, action } from 'src/app/state/modals-state'; import { ConfirmOperationNotification } from './confirm-operation-notification'; import { ErrorTransactionNotification } from './error-transaction-notification'; import { SuccessTransactionNotification } from './success-transaction-notification'; import { NotificationClass } from './style'; import { Styleable } from 'src/app/models/styleable'; +import { useOpenedNotifications } from 'src/app/hooks/use-notifications'; export interface NotificationsProps extends Styleable {} export const Notifications: Component = props => { - const timeouts: ReturnType[] = []; - - const [openedNotifications, setOpenedNotifications] = createSignal< - { id: number; action: ActionName }[] - >([]); - - let lastId = -1; - const liveTimeoutMs = 4500; - - createEffect( - on(action, action => { - if (action && action.showNotification) { - lastId++; - const id = lastId; - - setOpenedNotifications(notifications => - notifications - .filter(notification => notification.action !== 'confirm-transaction') - .concat({ id, action: action.name }) - ); - timeouts.push( - setTimeout( - () => - setOpenedNotifications(notifications => - notifications.filter(notification => notification.id !== id) - ), - liveTimeoutMs - ) - ); - } - }) - ); - - onCleanup(() => { - timeouts.forEach(clearTimeout); - }); + const openedNotifications = useOpenedNotifications(); return (
From 9b33329887b2c08e687650879d973ee1b8ee0f25 Mon Sep 17 00:00:00 2001 From: thekiba Date: Sat, 21 Oct 2023 01:00:04 +0400 Subject: [PATCH 09/17] chore(ui): release version 2.0.0-beta.3 --- packages/ui/CHANGELOG.md | 23 +++++++++++++++++++++++ packages/ui/package.json | 2 +- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index 01ae31cc..c7f1703a 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog @tonconnect/ui +# [2.0.0-beta.3](https://github.com/ton-connect/sdk/compare/ui-2.0.0-beta.2...ui-2.0.0-beta.3) (2023-10-20) + + +### Bug Fixes + +* **ui:** implement back button handler for modal popup on Android devices ([ff83611](https://github.com/ton-connect/sdk/commit/ff8361138dac5efb1086ab5791e0700e67190491)), closes [#70](https://github.com/ton-connect/sdk/issues/70) +* **ui:** prevent reappearing of success tooltip on re-render ([17bbc9a](https://github.com/ton-connect/sdk/commit/17bbc9a66eb01aad10dcc9bd91ba25696c652ba8)) +* **ui:** resolve illegal constructor error in safari ([512678f](https://github.com/ton-connect/sdk/commit/512678ff25ae877a7a2f608e8d8c06fda5dcbd21)), closes [#87](https://github.com/ton-connect/sdk/issues/87) +* **ui:** resolve premature promise resolution and unhandled popup closing scenarios ([5e7b825](https://github.com/ton-connect/sdk/commit/5e7b825b809856f6bd3dff969464beeb9d372a08)), closes [#67](https://github.com/ton-connect/sdk/issues/67) [#68](https://github.com/ton-connect/sdk/issues/68) + + +### Features + +* **ui:** introduce modal, fix promise handling, and refactor internal classes ([ac803aa](https://github.com/ton-connect/sdk/commit/ac803aa7bec56f6358071a906879d814eb485c8d)) +* **ui:** refactor wallet modal interfaces and expose them publicly ([5618a0a](https://github.com/ton-connect/sdk/commit/5618a0ae945b2aad50cb994d4cad8371f029bf65)) + + +### BREAKING CHANGES + +* **ui:** The method tonConnectUI.connectWallet() is now deprecated and will be removed in subsequent versions. Use tonConnectUI.openModal() instead. + + + # [2.0.0-beta.2](https://github.com/ton-connect/sdk/compare/ui-2.0.0-beta.1...ui-2.0.0-beta.2) (2023-09-15) diff --git a/packages/ui/package.json b/packages/ui/package.json index 67576ee2..2f8b4f03 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@tonconnect/ui", - "version": "2.0.0-beta.2", + "version": "2.0.0-beta.3", "scripts": { "start": "vite --host", "dev": "vite", From cd15215080a83f41f38151c5c929718349d5fbd0 Mon Sep 17 00:00:00 2001 From: thekiba Date: Sat, 21 Oct 2023 01:20:01 +0400 Subject: [PATCH 10/17] chore(ui-react): update @tonconnect/ui to ^2.0.0-beta.3 --- packages/ui-react/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui-react/package.json b/packages/ui-react/package.json index 0fa2612e..5fb06666 100644 --- a/packages/ui-react/package.json +++ b/packages/ui-react/package.json @@ -52,7 +52,7 @@ "vite-plugin-dts": "^1.7.1" }, "dependencies": { - "@tonconnect/ui": "^2.0.0-beta.2" + "@tonconnect/ui": "^2.0.0-beta.3" }, "peerDependencies": { "react": ">=17.0.0", From 79206bba72f985d9afb60562b3a0bbddb775915d Mon Sep 17 00:00:00 2001 From: thekiba Date: Sat, 21 Oct 2023 01:21:27 +0400 Subject: [PATCH 11/17] chore(ui-react): release version 2.0.0-beta.3 --- packages/ui-react/CHANGELOG.md | 9 +++++++++ packages/ui-react/package.json | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/ui-react/CHANGELOG.md b/packages/ui-react/CHANGELOG.md index c4ce3ee2..df2a17a8 100644 --- a/packages/ui-react/CHANGELOG.md +++ b/packages/ui-react/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog @tonconnect/ui-react +# [2.0.0-beta.3](https://github.com/ton-connect/sdk/compare/ui-react-2.0.0-beta.2...ui-react-2.0.0-beta.3) (2023-10-20) + + +### Features + +* **ui-react:** add useTonConnectModal() hook for modal management ([d1e2381](https://github.com/ton-connect/sdk/commit/d1e238154694d142bae67b21bfc81deb30647d49)) + + + # [2.0.0-beta.2](https://github.com/ton-connect/sdk/compare/ui-react-2.0.0-beta.1...ui-react-2.0.0-beta.2) (2023-09-15) diff --git a/packages/ui-react/package.json b/packages/ui-react/package.json index 5fb06666..8734a591 100644 --- a/packages/ui-react/package.json +++ b/packages/ui-react/package.json @@ -1,6 +1,6 @@ { "name": "@tonconnect/ui-react", - "version": "2.0.0-beta.2", + "version": "2.0.0-beta.3", "scripts": { "dev": "vite", "build": "tsc && vite build" From 0ce0228b23cb0568e97f147ea27976f87ff97fc7 Mon Sep 17 00:00:00 2001 From: thekiba Date: Wed, 25 Oct 2023 18:36:45 +0400 Subject: [PATCH 12/17] docs(sdk): update README.md --- packages/sdk/README.md | 41 +++++++++++++++++++++++------------------ 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/packages/sdk/README.md b/packages/sdk/README.md index ce24cd2b..bb11d779 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -59,7 +59,9 @@ Make sure that manifest is available to GET by its URL. If your manifest placed not in the root of your app, you can specify its path: ```ts - const connector = new TonConnect({ manifestUrl: 'https://myApp.com/assets/tonconnect-manifest.json' }); +const connector = new TonConnect({ + manifestUrl: 'https://myApp.com/assets/tonconnect-manifest.json' +}); ``` ## Subscribe to the connection status changes @@ -273,29 +275,32 @@ To authorize user in your backend with TonConnect you can use following schema: 1. Fetch auth payload from your backend. It might be any random value. Backend must save information that this payload was sent to the client to check payload correctness later. 2. Connect to the wallet when user clicks to the connection button: ```ts - connector.connect(walletConnectionSource, { tonProof: "" }); +connector.connect( + walletConnectionSource, + { tonProof: "" } +); ``` Note that you can use `tonProof` only with `connector.connect()` method. This feature is not available in `connector.restoreConnection()`. 3. Read a signed result after user approves connection: ```ts connector.onStatusChange(wallet => { - if (!wallet) { - return; - } - - const tonProof = wallet.connectItems?.tonProof; - - if (tonProof) { - if ('proof' in tonProof) { - // send proof to your backend - // e.g. myBackendCheckProof(tonProof.proof, wallet.account); - return; - } - - console.error(tonProof.error); - } - }); + if (!wallet) { + return; + } + + const tonProof = wallet.connectItems?.tonProof; + + if (tonProof) { + if ('proof' in tonProof) { + // send proof to your backend + // e.g. myBackendCheckProof(tonProof.proof, wallet.account); + return; + } + + console.error(tonProof.error); + } +}); ``` 4. Send proof and user's account data to your backend. Backend should check the proof correctness and check that payload inside the proof was generated before. After all checks backend should return an auth token to the client. Notice that `Account` contains the `walletStateInit` property which can be helpful for your backend to get user's public key if user's wallet contract doesn't support corresponding get method. 5. Client saves the auth token in the `localStorage` and use it to access to auth-required endpoints. Client should delete the token when user disconnects the wallet. From 0544eaf8d9fb159bff0dce8ac872400ea5e5d60a Mon Sep 17 00:00:00 2001 From: thekiba Date: Wed, 25 Oct 2023 18:39:17 +0400 Subject: [PATCH 13/17] docs(ui): update README.md --- packages/ui/README.md | 79 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 68 insertions(+), 11 deletions(-) diff --git a/packages/ui/README.md b/packages/ui/README.md index fc855b46..6a19466a 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -103,38 +103,95 @@ or const walletsList = await TonConnectUI.getWallets(); ``` -## Call connect +## Open connect modal "TonConnect UI connect button" (which is added at `buttonRootId`) automatically handles clicks and calls connect. But you are still able to open "connect modal" programmatically, e.g. after click on your custom connect button. ```ts -const connectedWallet = await tonConnectUI.connectWallet(); +await tonConnectUI.openModal(); ``` -If there is an error while wallet connecting, `TonConnectUIError` or `TonConnectError` will be thrown depends on situation. +This method opens the modal window and returns a promise that resolves after the modal window is opened. + +If there is an error while modal opening, `TonConnectUIError` or `TonConnectError` will be thrown depends on situation. + +## Close connect modal + +```ts +tonConnectUI.closeModal(); +``` + +This method closes the modal window. + +## Get current modal state + +This getter returns the current state of the modal window. The state will be an object with `status` and `closeReason` properties. The `status` can be either 'opened' or 'closed'. If the modal is closed, you can check the `closeReason` to find out the reason of closing. + +```ts +const currentModalState = tonConnectUI.modalState; +``` + +## Subscribe to the modal window state changes +To subscribe to the changes of the modal window state, you can use the `onModalStateChange` method. It returns a function which has to be called to unsubscribe. + +```js +const unsubscribeModal = tonConnectUI.onModalStateChange( + (state: WalletsModalState) => { + // update state/reactive variables to show updates in the ui + // state.status will be 'opened' or 'closed' + // if state.status is 'closed', you can check state.closeReason to find out the reason + } +); +``` + +Call `unsubscribeModal()` later to save resources when you don't need to listen for updates anymore. + +## Wallets Modal Control + +The `tonConnectUI` provides methods for managing the modal window, such as `openModal()`, `closeModal()` and other, which are designed for ease of use and cover most use cases. + +```typescript +const { modal } = tonConnectUI; + +// Open and close the modal +await modal.open(); +modal.close(); + +// Get the current modal state +const currentState = modal.state; + +// Subscribe and unsubscribe to modal state changes +const unsubscribe = modal.onStateChange(state => { /* ... */ }); +unsubscribe(); +``` + +While `tonConnectUI` internally delegates these calls to the `modal`, it is recommended to use the `tonConnectUI` methods for a more straightforward and consistent experience. The `modal` is exposed in case you need direct access to the modal window's state and behavior, but this should generally be avoided unless necessary. ## Get current connected Wallet and WalletInfo You can use special getters to read current connection state. Note that this getter only represents current value, so they are not reactive. -To react and handle wallet changes use `onStatusChange` mathod. +To react and handle wallet changes use `onStatusChange` method. ```ts - const currentWallet = tonConnectUI.wallet; - const currentWalletInfo = tonConnectUI.walletInfo; - const currentAccount = tonConnectUI.account; - const currentIsConnectedStatus = tonConnectUI.connected; +const currentWallet = tonConnectUI.wallet; +const currentWalletInfo = tonConnectUI.walletInfo; +const currentAccount = tonConnectUI.account; +const currentIsConnectedStatus = tonConnectUI.connected; ``` ## Subscribe to the connection status changes -```js + +To subscribe to the changes of the connection status, you can use the `onStatusChange` method. It returns a function which has to be called to unsubscribe. + +```ts const unsubscribe = tonConnectUI.onStatusChange( walletAndwalletInfo => { // update state/reactive variables to show updates in the ui } ); - -// call `unsubscribe()` later to save resources when you don't need to listen for updates anymore. ``` +Call `unsubscribe()` later to save resources when you don't need to listen for updates anymore. + ## Disconnect wallet Call to disconnect the wallet. From 31ba6d21c3bc0e2b1815321db6eefa8356ff3703 Mon Sep 17 00:00:00 2001 From: thekiba Date: Wed, 25 Oct 2023 18:40:34 +0400 Subject: [PATCH 14/17] chore(ui): release version 2.0.0-beta.4 --- packages/ui/CHANGELOG.md | 4 ++++ packages/ui/package.json | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index c7f1703a..aea5a918 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog @tonconnect/ui +# [2.0.0-beta.4](https://github.com/ton-connect/sdk/compare/ui-2.0.0-beta.3...ui-2.0.0-beta.4) (2023-10-25) + + + # [2.0.0-beta.3](https://github.com/ton-connect/sdk/compare/ui-2.0.0-beta.2...ui-2.0.0-beta.3) (2023-10-20) diff --git a/packages/ui/package.json b/packages/ui/package.json index 2f8b4f03..7cc7de29 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@tonconnect/ui", - "version": "2.0.0-beta.3", + "version": "2.0.0-beta.4", "scripts": { "start": "vite --host", "dev": "vite", From 9dc59340a71113f1a4e1588dfe3290d92b7469fa Mon Sep 17 00:00:00 2001 From: thekiba Date: Wed, 25 Oct 2023 18:43:21 +0400 Subject: [PATCH 15/17] docs(ui-react): update README.md --- packages/ui-react/README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/ui-react/README.md b/packages/ui-react/README.md index 6ee1b955..41b5592d 100644 --- a/packages/ui-react/README.md +++ b/packages/ui-react/README.md @@ -107,6 +107,26 @@ export const Wallet = () => { }; ``` +### useTonConnectModal + +Use this hook to access the functions for opening and closing the modal window. The hook returns an object with the current modal state and methods to open and close the modal. + +```tsx +import { useTonConnectModal } from '@tonconnect/ui-react'; + +export const ModalControl = () => { + const { state, open, close } = useTonConnectModal(); + + return ( +
+
Modal state: {state?.status}
+ + +
+ ); +}; +``` + ### useTonConnectUI Use it to get access to the `TonConnectUI` instance and UI options updating function. From 87f45126993e61e87d9002aed99682c1921406d5 Mon Sep 17 00:00:00 2001 From: thekiba Date: Wed, 25 Oct 2023 18:44:10 +0400 Subject: [PATCH 16/17] chore(ui-react): update @tonconnect/ui to ^2.0.0-beta.4 --- packages/ui-react/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui-react/package.json b/packages/ui-react/package.json index 8734a591..b5011adf 100644 --- a/packages/ui-react/package.json +++ b/packages/ui-react/package.json @@ -52,7 +52,7 @@ "vite-plugin-dts": "^1.7.1" }, "dependencies": { - "@tonconnect/ui": "^2.0.0-beta.3" + "@tonconnect/ui": "^2.0.0-beta.4" }, "peerDependencies": { "react": ">=17.0.0", From 9d86c73d4f3a279bb02282226efc61fb0bce4145 Mon Sep 17 00:00:00 2001 From: thekiba Date: Wed, 25 Oct 2023 18:45:08 +0400 Subject: [PATCH 17/17] chore(ui-react): release version 2.0.0-beta.4 --- packages/ui-react/CHANGELOG.md | 4 ++++ packages/ui-react/package.json | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/ui-react/CHANGELOG.md b/packages/ui-react/CHANGELOG.md index df2a17a8..408789e6 100644 --- a/packages/ui-react/CHANGELOG.md +++ b/packages/ui-react/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog @tonconnect/ui-react +# [2.0.0-beta.4](https://github.com/ton-connect/sdk/compare/ui-react-2.0.0-beta.3...ui-react-2.0.0-beta.4) (2023-10-25) + + + # [2.0.0-beta.3](https://github.com/ton-connect/sdk/compare/ui-react-2.0.0-beta.2...ui-react-2.0.0-beta.3) (2023-10-20) diff --git a/packages/ui-react/package.json b/packages/ui-react/package.json index b5011adf..12eaaa04 100644 --- a/packages/ui-react/package.json +++ b/packages/ui-react/package.json @@ -1,6 +1,6 @@ { "name": "@tonconnect/ui-react", - "version": "2.0.0-beta.3", + "version": "2.0.0-beta.4", "scripts": { "dev": "vite", "build": "tsc && vite build"