diff --git a/packages/passport/sdk/src/config/config.ts b/packages/passport/sdk/src/config/config.ts index 9d90ee9120..4d5e1fed60 100644 --- a/packages/passport/sdk/src/config/config.ts +++ b/packages/passport/sdk/src/config/config.ts @@ -3,6 +3,7 @@ import { createConfig, MultiRollupAPIConfiguration, multiRollupConfig } from '@i import { OidcConfiguration, PassportModuleConfiguration, + PopupOverlayOptions, } from '../types'; import { PassportError, PassportErrorType } from '../errors/passportError'; @@ -49,10 +50,13 @@ export class PassportConfiguration { readonly crossSdkBridgeEnabled: boolean; + readonly popupOverlayOptions: PopupOverlayOptions; + constructor({ baseConfig, overrides, crossSdkBridgeEnabled, + popupOverlayOptions, ...oidcConfiguration }: PassportModuleConfiguration) { validateConfiguration(oidcConfiguration, [ @@ -62,6 +66,10 @@ export class PassportConfiguration { this.oidcConfiguration = oidcConfiguration; this.baseConfig = baseConfig; this.crossSdkBridgeEnabled = crossSdkBridgeEnabled || false; + this.popupOverlayOptions = popupOverlayOptions || { + disableGenericPopupOverlay: false, + disableBlockedPopupOverlay: false, + }; if (overrides) { validateConfiguration( diff --git a/packages/passport/sdk/src/confirmation/confirmation.ts b/packages/passport/sdk/src/confirmation/confirmation.ts index 2e8db452a6..65c4993b49 100644 --- a/packages/passport/sdk/src/confirmation/confirmation.ts +++ b/packages/passport/sdk/src/confirmation/confirmation.ts @@ -7,6 +7,7 @@ import { } from './types'; import { openPopupCenter } from './popup'; import { PassportConfiguration } from '../config'; +import Overlay from '../overlay/overlay'; const CONFIRMATION_WINDOW_TITLE = 'Confirm this transaction'; const CONFIRMATION_WINDOW_HEIGHT = 720; @@ -23,8 +24,17 @@ export default class ConfirmationScreen { private confirmationWindow: Window | undefined; + private popupOptions: { width: number; height: number } | undefined; + + private overlay: Overlay | undefined; + + private overlayClosed: boolean; + + private timer: NodeJS.Timeout | undefined; + constructor(config: PassportConfiguration) { this.config = config; + this.overlayClosed = false; } private getHref(relativePath: string, queryStringParams?: { [key: string]: any }) { @@ -57,6 +67,7 @@ export default class ConfirmationScreen { ) { return; } + switch (data.messageType as ReceiveMessage) { case ReceiveMessage.CONFIRMATION_WINDOW_READY: { this.confirmationWindow?.postMessage({ @@ -66,26 +77,25 @@ export default class ConfirmationScreen { break; } case ReceiveMessage.TRANSACTION_CONFIRMED: { + this.closeWindow(); resolve({ confirmed: true }); break; } case ReceiveMessage.TRANSACTION_ERROR: { + this.closeWindow(); reject(new Error('Error during transaction confirmation')); break; } case ReceiveMessage.TRANSACTION_REJECTED: { + this.closeWindow(); reject(new Error('User rejected transaction')); break; } default: + this.closeWindow(); reject(new Error('Unsupported message type')); } }; - if (!this.confirmationWindow) { - resolve({ confirmed: false }); - return; - } - window.addEventListener('message', messageHandler); let href = ''; if (chainType === TransactionApprovalRequestChainTypeEnum.Starkex) { @@ -95,6 +105,7 @@ export default class ConfirmationScreen { transactionID: transactionId, etherAddress, chainType, chainID: chainId, }); } + window.addEventListener('message', messageHandler); this.showConfirmationScreen(href, messageHandler, resolve); }); } @@ -117,26 +128,26 @@ export default class ConfirmationScreen { break; } case ReceiveMessage.MESSAGE_CONFIRMED: { + this.closeWindow(); resolve({ confirmed: true }); break; } case ReceiveMessage.MESSAGE_ERROR: { + this.closeWindow(); reject(new Error('Error during message confirmation')); break; } case ReceiveMessage.MESSAGE_REJECTED: { + this.closeWindow(); reject(new Error('User rejected message')); break; } - default: + this.closeWindow(); reject(new Error('Unsupported message type')); } }; - if (!this.confirmationWindow) { - resolve({ confirmed: false }); - return; - } + window.addEventListener('message', messageHandler); const href = this.getHref('zkevm/message', { messageID, etherAddress }); this.showConfirmationScreen(href, messageHandler, resolve); @@ -149,27 +160,89 @@ export default class ConfirmationScreen { return; } - this.confirmationWindow = openPopupCenter({ - url: this.getHref('loading'), - title: CONFIRMATION_WINDOW_TITLE, - width: popupOptions?.width || CONFIRMATION_WINDOW_WIDTH, - height: popupOptions?.height || CONFIRMATION_WINDOW_HEIGHT, - }); + this.popupOptions = popupOptions; + + try { + this.confirmationWindow = openPopupCenter({ + url: this.getHref('loading'), + title: CONFIRMATION_WINDOW_TITLE, + width: popupOptions?.width || CONFIRMATION_WINDOW_WIDTH, + height: popupOptions?.height || CONFIRMATION_WINDOW_HEIGHT, + }); + this.overlay = new Overlay(this.config.popupOverlayOptions); + } catch (e) { + // If an error is thrown here then the popup is blocked + this.overlay = new Overlay(this.config.popupOverlayOptions, true); + } + + this.overlay.append( + () => { + try { + this.confirmationWindow?.close(); + this.confirmationWindow = openPopupCenter({ + url: this.getHref('loading'), + title: CONFIRMATION_WINDOW_TITLE, + width: this.popupOptions?.width || CONFIRMATION_WINDOW_WIDTH, + height: this.popupOptions?.height || CONFIRMATION_WINDOW_HEIGHT, + }); + } catch { /* Empty */ } + }, + () => { + this.overlayClosed = true; + this.closeWindow(); + }, + ); } closeWindow() { this.confirmationWindow?.close(); + this.overlay?.remove(); + this.overlay = undefined; } showConfirmationScreen(href: string, messageHandler: MessageHandler, resolve: Function) { - this.confirmationWindow!.location.href = href; + // If popup blocked, the confirmation window will not exist + if (this.confirmationWindow) { + this.confirmationWindow.location.href = href; + } + + // This indicates the user closed the overlay so the transaction should be rejected + if (!this.overlay) { + this.overlayClosed = false; + resolve({ confirmed: false }); + return; + } + // https://stackoverflow.com/questions/9388380/capture-the-close-event-of-popup-window-in-javascript/48240128#48240128 - const timer = setInterval(() => { - if (this.confirmationWindow?.closed) { - clearInterval(timer); + const timerCallback = () => { + if (this.confirmationWindow?.closed || this.overlayClosed) { + clearInterval(this.timer); window.removeEventListener('message', messageHandler); resolve({ confirmed: false }); + this.overlayClosed = false; + this.confirmationWindow = undefined; } - }, CONFIRMATION_WINDOW_CLOSED_POLLING_DURATION); + }; + this.timer = setInterval( + timerCallback, + CONFIRMATION_WINDOW_CLOSED_POLLING_DURATION, + ); + this.overlay.update(() => this.recreateConfirmationWindow(href, timerCallback)); + } + + private recreateConfirmationWindow(href: string, timerCallback: () => void) { + try { + // Clears and recreates the timer to ensure when the confirmation window + // is closed and recreated the transaction is not rejected. + clearInterval(this.timer); + this.confirmationWindow?.close(); + this.confirmationWindow = openPopupCenter({ + url: href, + title: CONFIRMATION_WINDOW_TITLE, + width: this.popupOptions?.width || CONFIRMATION_WINDOW_WIDTH, + height: this.popupOptions?.height || CONFIRMATION_WINDOW_HEIGHT, + }); + this.timer = setInterval(timerCallback, CONFIRMATION_WINDOW_CLOSED_POLLING_DURATION); + } catch { /* Empty */ } } } diff --git a/packages/passport/sdk/src/overlay/constants.ts b/packages/passport/sdk/src/overlay/constants.ts new file mode 100644 index 0000000000..741379bba7 --- /dev/null +++ b/packages/passport/sdk/src/overlay/constants.ts @@ -0,0 +1,220 @@ +/* eslint-disable max-len */ + +export const PASSPORT_OVERLAY_ID = 'passport-overlay'; +export const PASSPORT_OVERLAY_CLOSE_ID = `${PASSPORT_OVERLAY_ID}-close`; +export const PASSPORT_OVERLAY_TRY_AGAIN_ID = `${PASSPORT_OVERLAY_ID}-try-again`; + +export const CLOSE_BUTTON_SVG = ` + + + +`; + +export const POPUP_BLOCKED_SVG = ` + + + +`; + +export const IMMUTABLE_LOGO_SVG = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; diff --git a/packages/passport/sdk/src/overlay/elements.ts b/packages/passport/sdk/src/overlay/elements.ts new file mode 100644 index 0000000000..9d8d0c3d0b --- /dev/null +++ b/packages/passport/sdk/src/overlay/elements.ts @@ -0,0 +1,126 @@ +import { + CLOSE_BUTTON_SVG, + POPUP_BLOCKED_SVG, + IMMUTABLE_LOGO_SVG, + PASSPORT_OVERLAY_CLOSE_ID, + PASSPORT_OVERLAY_ID, + PASSPORT_OVERLAY_TRY_AGAIN_ID, +} from './constants'; + +const getCloseButton = (): string => ` + + `; + +const getBlockedContents = () => ` +
+ ${POPUP_BLOCKED_SVG} + Pop-up blocked +
+

+ Please adjust your browser settings
and try again below +

+ `; + +const getGenericContents = () => ` +

+ Secure pop-up not showing?
We'll help you re-launch +

+ `; + +const getTryAgainButton = () => ` + + `; + +const getOverlay = (contents: string): string => ` +
+ ${getCloseButton()} +
+ ${IMMUTABLE_LOGO_SVG} + ${contents} + ${getTryAgainButton()} +
+
+ `; + +export const getBlockedOverlay = () => getOverlay(getBlockedContents()); +export const getGenericOverlay = () => getOverlay(getGenericContents()); diff --git a/packages/passport/sdk/src/overlay/overlay.test.ts b/packages/passport/sdk/src/overlay/overlay.test.ts new file mode 100644 index 0000000000..d729efba8a --- /dev/null +++ b/packages/passport/sdk/src/overlay/overlay.test.ts @@ -0,0 +1,55 @@ +import Overlay from './overlay'; + +describe('overlay', () => { + beforeEach(() => { + document.body.innerHTML = ''; + }); + + it('should append generic overlay', () => { + const overlay = new Overlay({}, false); + overlay.append(() => {}, () => {}); + expect(document.body.innerHTML).toContain('passport-overlay'); + }); + + it('should append blocked overlay', () => { + const overlay = new Overlay({}, true); + overlay.append(() => {}, () => {}); + expect(document.body.innerHTML).toContain('passport-overlay'); + }); + + it('should not append generic overlay when generic disabled', () => { + const overlay = new Overlay({ disableGenericPopupOverlay: true }, false); + overlay.append(() => {}, () => {}); + expect(document.body.innerHTML).not.toContain('passport-overlay'); + }); + + it('should append overlay if only generic disabled and is blocked', () => { + const overlay = new Overlay({ disableGenericPopupOverlay: true }, true); + overlay.append(() => {}, () => {}); + expect(document.body.innerHTML).toContain('passport-overlay'); + }); + + it('should not append blocked overlay when blocked disabled', () => { + const overlay = new Overlay({ disableBlockedPopupOverlay: true }, true); + overlay.append(() => {}, () => {}); + expect(document.body.innerHTML).not.toContain('passport-overlay'); + }); + + it('should append generic overlay when only blocked disabled', () => { + const overlay = new Overlay({ disableBlockedPopupOverlay: true }, false); + overlay.append(() => {}, () => {}); + expect(document.body.innerHTML).toContain('passport-overlay'); + }); + + it('should not append generic overlay when overlays disabled', () => { + const overlay = new Overlay({ disableGenericPopupOverlay: true, disableBlockedPopupOverlay: true }, false); + overlay.append(() => {}, () => {}); + expect(document.body.innerHTML).not.toContain('passport-overlay'); + }); + + it('should not append blocked overlay when overlays disabled', () => { + const overlay = new Overlay({ disableGenericPopupOverlay: true, disableBlockedPopupOverlay: true }, true); + overlay.append(() => {}, () => {}); + expect(document.body.innerHTML).not.toContain('passport-overlay'); + }); +}); diff --git a/packages/passport/sdk/src/overlay/overlay.ts b/packages/passport/sdk/src/overlay/overlay.ts new file mode 100644 index 0000000000..65c0f34ab9 --- /dev/null +++ b/packages/passport/sdk/src/overlay/overlay.ts @@ -0,0 +1,79 @@ +import { PopupOverlayOptions } from 'types'; +import { PASSPORT_OVERLAY_CLOSE_ID, PASSPORT_OVERLAY_TRY_AGAIN_ID } from './constants'; +import { getBlockedOverlay, getGenericOverlay } from './elements'; + +export default class Overlay { + private disableGenericPopupOverlay: boolean; + + private disableBlockedPopupOverlay: boolean; + + private overlay: HTMLDivElement | undefined; + + private isBlockedOverlay: boolean; + + private tryAgainListener: (() => void) | undefined; + + private onCloseListener: (() => void) | undefined; + + constructor(popupOverlayOptions: PopupOverlayOptions, isBlockedOverlay: boolean = false) { + this.disableBlockedPopupOverlay = popupOverlayOptions.disableBlockedPopupOverlay || false; + this.disableGenericPopupOverlay = popupOverlayOptions.disableGenericPopupOverlay || false; + this.isBlockedOverlay = isBlockedOverlay; + } + + append(tryAgainOnClick: () => void, onCloseClick: () => void) { + if (this.shouldAppendOverlay()) { + this.appendOverlay(); + this.updateTryAgainButton(tryAgainOnClick); + this.updateCloseButton(onCloseClick); + } + } + + update(tryAgainOnClick: () => void) { + this.updateTryAgainButton(tryAgainOnClick); + } + + remove() { + if (this.overlay) { + this.overlay.remove(); + } + } + + private shouldAppendOverlay(): boolean { + if (this.disableGenericPopupOverlay && this.disableBlockedPopupOverlay) return false; + if (this.disableGenericPopupOverlay && !this.isBlockedOverlay) return false; + if (this.disableBlockedPopupOverlay && this.isBlockedOverlay) return false; + return true; + } + + private appendOverlay() { + if (!this.overlay) { + const overlay = document.createElement('div'); + overlay.innerHTML = this.isBlockedOverlay ? getBlockedOverlay() : getGenericOverlay(); + document.body.insertAdjacentElement('beforeend', overlay); + this.overlay = overlay; + } + } + + private updateTryAgainButton(tryAgainOnClick: () => void) { + const tryAgainButton = document.getElementById(PASSPORT_OVERLAY_TRY_AGAIN_ID); + if (tryAgainButton) { + if (this.tryAgainListener) { + tryAgainButton.removeEventListener('click', this.tryAgainListener); + } + this.tryAgainListener = tryAgainOnClick; + tryAgainButton.addEventListener('click', tryAgainOnClick); + } + } + + private updateCloseButton(onCloseClick: () => void) { + const closeButton = document.getElementById(PASSPORT_OVERLAY_CLOSE_ID); + if (closeButton) { + if (this.onCloseListener) { + closeButton.removeEventListener('click', this.onCloseListener); + } + this.onCloseListener = onCloseClick; + closeButton.addEventListener('click', onCloseClick); + } + } +} diff --git a/packages/passport/sdk/src/types.ts b/packages/passport/sdk/src/types.ts index 9179599acd..67ec544307 100644 --- a/packages/passport/sdk/src/types.ts +++ b/packages/passport/sdk/src/types.ts @@ -69,6 +69,11 @@ export interface PassportOverrides { imxApiClients?: ImxApiClients; // needs to be optional because ImxApiClients is not exposed publicly } +export interface PopupOverlayOptions { + disableGenericPopupOverlay?: boolean; + disableBlockedPopupOverlay?: boolean; +} + export interface PassportModuleConfiguration extends ModuleConfiguration, OidcConfiguration { /** @@ -76,6 +81,10 @@ export interface PassportModuleConfiguration extends ModuleConfiguration = T & { [P in K]-?: T[P] };