import { CMType, ErrorIdentifierCode, HMType, type HostMessage, type TransactionResponse, type ValidationError, } from '../types'; import { type MessageCallback, MessageEventManager, } from '../utils/MessageEventManager'; import { TokenRefresher } from '../utils/tokenRefresh'; import { encodeObjectToFragment } from '../utils/urls'; /** * An embedded Notabene component * * @typeParam V - The transaction value type * @typeParam O - The options type * @typeParam R - The response type that `completion()` resolves with * @public */ export default class EmbeddedComponent> { private _url: string; private _value: Partial; private _options?: O; private _errors: ValidationError[] = []; private iframe?: HTMLIFrameElement; private eventManager: MessageEventManager; private modal?: HTMLDialogElement; private messageHandler?: (event: MessageEvent) => void; private tokenRefresher?: TokenRefresher; private authToken?: string; /** * Creates an instance of EmbeddedComponent. * @param url - The URL of the embedded component * @param value - The initial transaction value * @param fetchToken - Supplies the auth token, and refreshes it before it expires * @internal */ constructor( url: string, value: Partial, options?: O, fetchToken?: () => Promise, ) { this._url = url; this._value = value; this._options = options; this.eventManager = new MessageEventManager(); if (fetchToken) this.tokenRefresher = new TokenRefresher(fetchToken); this.on(CMType.COMPLETE, () => this.tokenRefresher?.stop()); this.on(CMType.CANCEL, () => this.tokenRefresher?.stop()); this.on(CMType.INVALID, (message) => { if (message.type === CMType.INVALID) { this._errors = message.errors; this._value = message.value as V; } }); this.on(CMType.RESIZE, (message) => { if (message.type === CMType.RESIZE) { if (this.iframe) this.iframe.style.height = `${message.height}px`; } }); } /** * Gets the URL of the embedded component. With `fetchToken` configured the URL * carries no token, since it is fetched when the component is shown. * @returns The URL of the embedded component */ public get url(): string { return this._url; } /** * Gets the current transaction value * @returns The current transaction value */ public get value(): Partial { return this._value; } public get options(): O | undefined { return this._options; } public get errors(): ValidationError[] { return this._errors; } private urlWithToken(token: string): string { const url = new URL(this._url); const hash = url.hash.replace(/^#/, ''); const tokenPart = encodeObjectToFragment({ authToken: token }); url.hash = hash ? `${hash}&${tokenPart}` : tokenPart; return url.toString(); } private tokenError(error: Error) { this.eventManager.emit({ type: CMType.ERROR, identifier: ErrorIdentifierCode.TOKEN_INVALID, message: error.message, }); } /** * Opens the component URL in the current window */ public open() { if (this.tokenRefresher) { throw new Error( 'open() is not supported with fetchToken; use mount, openModal or popup', ); } document.location.href = this.url; } /** * Mounts the component to a parent element * @param parentId - The ID of the parent element * @throws Will throw an error if the parent element is not found */ mount(parentId: string) { const parent = document.querySelector(parentId); if (!parent) throw new Error(`parentID ${parentId} not found`); this.embed(parent); } /** * Embeds the component into a parent element * @param parent - The parent element to embed the component into */ embed(parent: Element, modal: boolean = false) { // console.log(this.url); this.removeEmbed(); const iframe = document.createElement('iframe'); this.iframe = iframe; const suffix = modal ? '' : '&embedded=true'; if (!this.tokenRefresher) { iframe.src = this.url + suffix; } this.iframe.allow = 'web-share; clipboard-write; hid; bluetooth;'; this.iframe.style.width = '100%'; this.iframe.style.height = '0px'; this.iframe.style.border = 'none'; this.iframe.style.overflow = 'hidden'; this.iframe.scrolling = 'no'; // this.iframe.setAttribute('allowtransparency', 'true'); // this.iframe.style.backgroundColor = 'transparent'; parent.appendChild(this.iframe); this.messageHandler = (event: MessageEvent) => { if (event.source !== this.iframe?.contentWindow) { return; } if (event.ports[0]) this.connectPort(event.ports[0]); }; window.addEventListener('message', this.messageHandler); this.iframe?.contentWindow?.focus(); this.startRefresh( (token) => { iframe.src = this.urlWithToken(token) + suffix; }, () => iframe.isConnected, modal ? () => this.closeModal() : undefined, ); } private connectPort(port: MessagePort) { this.eventManager.setPort(port); // A refresh sent before the port connected was dropped, so resend the current token. if (this.authToken) { this.send({ type: HMType.TOKEN, token: this.authToken }); } } /** * Fetches the first token and navigates with it, then posts each refreshed * token to the component until the target is gone. */ private startRefresh( navigate: (token: string) => void, isLive: () => boolean, onFirstError?: () => void, ) { const tokenRefresher = this.tokenRefresher; if (!tokenRefresher) return; this.authToken = undefined; let first = true; tokenRefresher.start( (token) => { // Covers teardown the SDK never sees, e.g. the host removing the parent node. if (!isLive()) return tokenRefresher.stop(); this.authToken = token; if (first) { first = false; navigate(token); } else { this.send({ type: HMType.TOKEN, token }); } }, (error) => { if (first) onFirstError?.(); this.tokenError(error); }, ); } removeEmbed() { this.tokenRefresher?.stop(); if (this.messageHandler) { window.removeEventListener('message', this.messageHandler); this.messageHandler = undefined; } if (this.iframe) this.iframe.remove(); } /** * Sends a message to the embedded component * @param message - The message to send */ send(message: HostMessage): void { this.eventManager.send(message); } /** * Adds an event listener for a specific message type * @param messageType - The type of message to listen for * @param callback - The callback function to execute when the message is received */ on(messageType: string, callback: MessageCallback): () => void { return this.eventManager.on(messageType, callback); } /** * Removes an event listener for a specific message type * @param messageType - The type of message to stop listening for * @param callback - The callback function to remove */ off(messageType: string, callback: MessageCallback): void { this.eventManager.off(messageType, callback); } /** * Updates the transaction value and sends an update message to the component * @param value - The new transaction value */ update(value: Partial, options?: O) { this._value = value; if (options) this._options = options; this.send({ type: HMType.UPDATE, value, options: this._options }); } /** * Waits for the component to complete and returns the transaction response * @returns A promise that resolves with the transaction response */ completion(): Promise { return new Promise((resolve, reject) => { let removeComplete: undefined | (() => void) = undefined; let removeCancel: undefined | (() => void) = undefined; let removeError: undefined | (() => void) = undefined; function cleanup() { if (removeComplete) removeComplete(); if (removeCancel) removeCancel(); if (removeError) removeError(); } removeComplete = this.on(CMType.COMPLETE, (message) => { resolve((message as any).response); cleanup(); }); removeCancel = this.on(CMType.CANCEL, () => { reject(new Error('User cancelled')); cleanup(); }); removeError = this.on('error', (message) => { reject(new Error((message as any).message)); cleanup(); }); }); } /** * Opens the component in a modal dialog */ async openModal(): Promise { if (this.modal) { this.closeModal(); } this.modal = document.createElement('dialog'); this.modal.style.border = 'none'; this.modal.style.backgroundColor = 'white'; this.modal.style.maxWidth = '100vw'; this.modal.style.maxHeight = '100vh'; this.modal.style.width = '600px'; this.modal.style.height = '600px'; document.body.appendChild(this.modal); this.embed(this.modal, true); const removeCancel = this.on(CMType.CANCEL, () => { this.closeModal(); }); const removeComplete = this.on(CMType.COMPLETE, () => { this.closeModal(); }); this.modal.showModal(); this.modal.addEventListener('click', () => { // console.log('click'); this.closeModal(); }); return this.completion().finally(() => { removeCancel(); removeComplete(); }); } /** * Closes the modal dialog * */ closeModal(): void { this.tokenRefresher?.stop(); if (this.modal) { // console.log('closeModal'); this.modal?.close(); this.modal.remove(); this.modal = undefined; } } /** * Opens the component in a popup window. This may be needed to support many self-hosted wallets * @returns A promise that resolves with the transaction response * @see [MDN](https://developer.mozilla.org/en-US/docs/Web/API/Window/open#restrictions) * @see [Cross-Origin-Opener-Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cross-Origin-Opener-Policy) */ async popup(): Promise { const popup = window.open( this.tokenRefresher ? 'about:blank' : this.url, '_blank', 'popup=true,width=600,height=600', ); const popupMessageHandler = (event: MessageEvent) => { if (event.source !== popup) { return; } if (event.ports[0]) this.connectPort(event.ports[0]); }; window.addEventListener('message', popupMessageHandler); const removeCancel = this.on(CMType.CANCEL, () => { popup?.close(); }); const removeComplete = this.on(CMType.COMPLETE, () => { popup?.close(); }); this.startRefresh( (token) => { if (popup) popup.location.href = this.urlWithToken(token); }, () => !!popup && !popup.closed, // Before the first token the window is still about:blank. () => popup?.close(), ); return this.completion().finally(() => { this.tokenRefresher?.stop(); window.removeEventListener('message', popupMessageHandler); removeCancel(); removeComplete(); }); } }