diff --git a/package.json b/package.json index 3e1bcf4..937d872 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "embed-react-native-sdk", - "version": "1.1.0", + "version": "2.0.6", "description": "React Native SDK for Embedding TS", "main": "dist/index.cjs.js", "module": "dist/index.esm.js", @@ -14,23 +14,10 @@ "react-native": ">=0.60.0", "react-native-webview": ">=11.0.0" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-native": { - "optional": true - } - }, "devDependencies": { "@rollup/plugin-commonjs": "^28.0.2", "@rollup/plugin-node-resolve": "^16.0.0", "@rollup/plugin-typescript": "^12.1.2", - "@types/react": "^18.3.18", - "@types/react-native": "^0.72.8", - "react": "^18.3.1", - "react-native": "^0.77.0", - "react-native-webview": "^13.13.2", "rollup": "^4.32.0", "rollup-plugin-dts": "^6.1.1", "typescript": "^5.7.3" diff --git a/src/BaseEmbed.tsx b/src/BaseEmbed.tsx deleted file mode 100644 index bc946e7..0000000 --- a/src/BaseEmbed.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import React, { - useRef, - useEffect, - useImperativeHandle, - forwardRef, - useMemo, - useState, -} from "react"; -import { WebView, WebViewMessageEvent } from "react-native-webview"; -import { EmbedBridge, EmbedMessage } from "./event-bridge"; -import { embedConfigCache } from "./init"; - -interface BaseEmbedProps { - typeofEmbed: string; - [key: string]: any; -} - -export interface TSEmbedRef { - trigger: (hostEventName: string, payload?: any) => Promise; -} - -export const BaseEmbed = forwardRef( - (props, ref) => { - const webViewRef = useRef(null); - const embedBridge = useMemo(() => new EmbedBridge(webViewRef), []); - const [vercelShellLoaded, setVercelShellLoaded] = useState(false); - const [viewConfig, setViewConfig] = useState>({}); - - useEffect(() => { - const newViewConfig: Record = {}; - Object.keys(props).forEach((key) => { - if (key.startsWith("on")) { - const eventName = key.substring(2); - embedBridge.registerEmbedEvent(eventName, props[key]); - } else if (key !== 'embedType') { - newViewConfig[key] = props[key]; - } - }); - setViewConfig(newViewConfig); - }, [props, embedBridge]); - - useEffect(() => { - if (!webViewRef.current || !vercelShellLoaded) { - console.log("[BaseEmbed] Waiting for Vercel shell to load..."); - return; - } - - const initMsg = { - type: "INIT", - payload: embedConfigCache, - }; - embedBridge.sendMessage(initMsg); - - const message = { - type: "EMBED", - embedType: props.embedType, - viewConfig: viewConfig, - }; - embedBridge.sendMessage(message); - }, [viewConfig, embedBridge, props.embedType, vercelShellLoaded]); - - useImperativeHandle(ref, () => ({ - trigger: (hostEventName: string, payload?: any) => { - return embedBridge.trigger(hostEventName, payload); - }, - })); - - const handleMessage = (event: WebViewMessageEvent) => { - try { - const msg = JSON.parse(event.nativeEvent.data); - if (msg.type === "INIT_VERCEL_SHELL") { - setVercelShellLoaded(true); - } - embedBridge.handleMessage(msg); - } catch (err) { - console.error("Unable to parse the message from the webview", err); - } - }; - - return ( - { - const { nativeEvent } = syntheticEvent; - console.warn("error in the webview", nativeEvent); - }} - keyboardDisplayRequiresUserAction={false} // Add this for iOS - automaticallyAdjustContentInsets={false} // Add this - scrollEnabled={false} - onHttpError= {(syntheticEvent) => { - const { nativeEvent } = syntheticEvent; - console.warn("HTTP error in the webview", nativeEvent); - }} - style={{ flex: 1, - height: '100%', // Explicit height - width: '100%' - }} - /> - ); - } -); diff --git a/src/LiveboardEmbed.tsx b/src/LiveboardEmbed.tsx deleted file mode 100644 index af99729..0000000 --- a/src/LiveboardEmbed.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import React, { forwardRef } from "react"; -import { BaseEmbed, TSEmbedRef } from "./BaseEmbed"; -import { LiveboardViewConfig, EmbedEvent } from "./types"; - -type EventHandlers = { - [K in EmbedEvent as `on${Capitalize}`]?: (event: any) => void; -} - -export type LiveboardEmbedRef = TSEmbedRef; - -export const LiveboardEmbed = forwardRef( - (props, ref) => { - return ( - - ); - } -); diff --git a/src/LiveboardEmbedClass.ts b/src/LiveboardEmbedClass.ts new file mode 100644 index 0000000..f3e8cc2 --- /dev/null +++ b/src/LiveboardEmbedClass.ts @@ -0,0 +1,20 @@ +import { TSEmbed } from './tsEmbed'; +import { componentFactory } from './componentFactory'; +import { LiveboardViewConfig } from './types'; +import WebView from 'react-native-webview'; +import { EmbedProps } from './util'; +import React from 'react'; + +class LiveboardEmbedClass extends TSEmbed { + constructor(webViewRef: React.RefObject, config?: T) { + super(webViewRef, config); + } +} + +export interface LiveboardEmbedProps extends LiveboardViewConfig, EmbedProps {} + +export const LiveboardEmbed: React.FC = componentFactory< + typeof LiveboardEmbedClass, + LiveboardViewConfig, + LiveboardEmbedProps +>(LiveboardEmbedClass); \ No newline at end of file diff --git a/src/componentFactory.tsx b/src/componentFactory.tsx new file mode 100644 index 0000000..92bc712 --- /dev/null +++ b/src/componentFactory.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { WebView } from 'react-native-webview'; +import { TSEmbed } from './tsEmbed'; +import { + EmbedEvent, + ViewConfig, + MessageCallback, +} from './types'; +import { EmbedProps } from './util'; + +export type EmbedEventHandlers = { [key in keyof typeof EmbedEvent as `on${Capitalize}`]?: MessageCallback }; + + +export interface ViewConfigAndListeners { + viewConfig: T; + listeners: { [key in EmbedEvent]?: MessageCallback }; +} + +const getViewPropsAndListeners = ( + props: T +): ViewConfigAndListeners => { + return Object.keys(props).reduce( + (accu, key) => { + if (key.startsWith('on')) { + const eventName = key.slice(2) as keyof typeof EmbedEvent; + (accu.listeners as Record)[EmbedEvent[eventName]] = props[key as keyof T] as MessageCallback; + } else { + (accu.viewConfig as Record)[key] = props[key as keyof T]; + } + return accu as ViewConfigAndListeners; + }, + { + viewConfig: {} as U, + listeners: {}, + }, + ); +}; + +export const componentFactory = ( + EmbedConstructor: T, +) => React.forwardRef, U>( + (props, forwardedRef): JSX.Element => { + const embedInstance = React.useRef | null>(null); + const webViewRef = React.useRef(null); + + // Creating the instance immediately + if (!embedInstance.current) { + embedInstance.current = new EmbedConstructor(webViewRef) as InstanceType; + } + + // Memoize the rendered WebView + const renderedWebView = React.useMemo(() => { + return embedInstance?.current?.render(); + }, []); + + React.useEffect(() => { + const { viewConfig, listeners } = getViewPropsAndListeners(props as U); + + if (embedInstance.current) { + embedInstance.current.updateConfig(viewConfig); + + Object.entries(listeners).forEach(([eventName, callback]) => { + embedInstance.current?.on(eventName as EmbedEvent, callback as MessageCallback); + }); + } + + if (forwardedRef && typeof forwardedRef === 'object') { + forwardedRef.current = embedInstance.current; + } + + return () => { + embedInstance.current?.destroy(); + }; + }, [props]); + + return renderedWebView as JSX.Element; + } +); \ No newline at end of file diff --git a/src/event-bridge.ts b/src/event-bridge.ts index cb37c70..fff149d 100644 --- a/src/event-bridge.ts +++ b/src/event-bridge.ts @@ -11,55 +11,54 @@ export interface EmbedMessage { } export class EmbedBridge { - private events: Record = {}; + private eventHandlers: Record = {}; private pendingReplies: Record = {}; constructor(private webViewRef: React.RefObject) {} - registerEmbedEvent(eventName: string, callback: Function) { - if (!this.events[eventName]) { - this.events[eventName] = []; + public on(eventName: string, callback: Function) { + if (!this.eventHandlers[eventName]) { + this.eventHandlers[eventName] = []; } - this.events[eventName].push(callback); + this.eventHandlers[eventName].push(callback); } - public trigger(hostEventName: string, payload?: any): Promise { + public emit(eventName: string, payload?: any): Promise { if (!this.webViewRef.current) { - console.warn("webview is not ready for host event"); + console.warn("webview is not ready for event:", eventName); return Promise.resolve(undefined); } + return new Promise((resolve) => { const eventId = this.generateEventId(); this.pendingReplies[eventId] = resolve; - const message = { - type: "HOST_EVENT", + + this.sendMessage({ + type: 'EVENT', eventId, - eventName: hostEventName, + eventName, payload, - }; - this.sendMessage(message); + }); }); } handleMessage(msg: any) { switch (msg.type) { case "REQUEST_AUTH_TOKEN": { - authFunctionCache?.().then((token: string) => { - const replyTokenData = { - type: 'AUTH_TOKEN_RESPONSE', - token, - }; - this.sendMessage(replyTokenData); - }) - break; + authFunctionCache?.().then((token: string) => { + this.sendMessage({ type: 'AUTH_TOKEN_RESPONSE',token }); + }); + break; } - case "EMBED_EVENT": { - if (msg.eventName) { - this.triggerEmbedEvent(msg.eventName, msg.payload); + case "EVENT": { + if (msg.hasResponder) { + this.triggerEventWithResponder(msg.eventName, msg.payload, msg.eventId); + } else { + this.triggerEvent(msg.eventName, msg.payload); } break; } - case "HOST_EVENT_REPLY": { + case "EVENT_REPLY": { if (msg.eventId && this.pendingReplies[msg.eventId]) { this.pendingReplies[msg.eventId](msg.payload); delete this.pendingReplies[msg.eventId]; @@ -71,12 +70,25 @@ export class EmbedBridge { } } - private triggerEmbedEvent(eventName: string, data: any) { - const callbacks = this.events[eventName] || []; - callbacks.forEach((cb) => cb(data)); + private triggerEvent(eventName: string, data: any) { + const handlers = this.eventHandlers[eventName] || []; + handlers.forEach(handler => handler(data)); + } + + private triggerEventWithResponder(eventName: string, data: any, eventId: string) { + const handlers = this.eventHandlers[eventName] || []; + handlers.forEach(handler => { + handler(data, (responseData: any) => { + this.sendMessage({ + type: 'EVENT_REPLY', + eventId, + payload: responseData + }); + }); + }); } - public sendMessage(msg: EmbedMessage) { + public sendMessage(msg: any) { const msgString = JSON.stringify(msg); const jsCode = `window.postMessage(${msgString}, "*");true;`; this.webViewRef.current?.injectJavaScript(jsCode); @@ -85,4 +97,10 @@ export class EmbedBridge { private generateEventId(): string { return `evt_${Date.now()}_${Math.floor(Math.random() * 100000)}`; } + + public destroy() { + this.eventHandlers = {}; + this.pendingReplies = {}; + this.webViewRef = { current: null }; + } } diff --git a/src/hooks/useLiveboardRef.ts b/src/hooks/useLiveboardRef.ts deleted file mode 100644 index 5bdaaeb..0000000 --- a/src/hooks/useLiveboardRef.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { useRef } from "react"; -import { TSEmbedRef } from "src/BaseEmbed"; - -export const useLiveboardRef = () => { - const liveboardRef = useRef(null); - return liveboardRef; -}; diff --git a/src/index.ts b/src/index.ts index 71f707b..8031e4d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,14 +1,4 @@ -import { useLiveboardRef } from "./hooks/useLiveboardRef"; import { init } from "./init"; -import { LiveboardEmbed, LiveboardEmbedRef } from "./LiveboardEmbed"; +import { LiveboardEmbed } from "./LiveboardEmbedClass"; - -export { init, LiveboardEmbed, useLiveboardRef }; -export type { LiveboardEmbedRef }; - -const EmbedReactNativeSDK = { - init, - LiveboardEmbed, - useLiveboardRef, -}; -export default EmbedReactNativeSDK; \ No newline at end of file +export { init, LiveboardEmbed }; \ No newline at end of file diff --git a/src/tsEmbed.tsx b/src/tsEmbed.tsx new file mode 100644 index 0000000..953aaf8 --- /dev/null +++ b/src/tsEmbed.tsx @@ -0,0 +1,117 @@ +import { WebView, WebViewMessageEvent } from "react-native-webview"; +import { EmbedBridge } from "./event-bridge"; +import React from "react"; +import { ViewConfig } from "./types"; +import { embedConfigCache } from "./init"; + + +export class TSEmbed { + protected webViewRef: React.RefObject; + protected embedBridge: EmbedBridge | null = null; + protected viewConfig: T; + protected vercelShellLoaded: boolean = false; + private pendingHandlers: Array<[string, any]> = []; + + constructor(webViewRef: React.RefObject, config?: T) { + this.webViewRef = webViewRef; + this.viewConfig = config || {} as T; + this.handleMessage = this.handleMessage.bind(this); + this.embedBridge = new EmbedBridge(this.webViewRef); + } + + protected getEmbedType() { + return this.constructor.name.replace('EmbedClass', ''); + } + + public updateConfig(config: Partial) { + this.viewConfig = { ...this.viewConfig, ...config }; + if(this.vercelShellLoaded) { + this.sendConfigToShell(); + } + } + + public sendConfigToShell() { + if(!this.webViewRef.current || !this.vercelShellLoaded) { + console.log("[TSEmbed] Waiting for Vercel shell to load..."); + return; + } + + const initMsg = { + type: "INIT", + payload: embedConfigCache, + }; + + this.embedBridge?.sendMessage(initMsg); + + const message = { + type: "EMBED", + embedType: this.getEmbedType(), + viewConfig: this.viewConfig, + }; + + this.embedBridge?.sendMessage(message); + + } + + public on(eventName: string, callback: any) { + if (this.embedBridge) { + this.embedBridge.on(eventName, callback); + } else { + this.pendingHandlers.push([eventName, callback]); + } + } + + public trigger(hostEventName: string, payload?: any) { + return this.embedBridge?.emit(hostEventName, payload); + } + + public handleMessage(event: WebViewMessageEvent) { + try { + const msg = JSON.parse(event.nativeEvent.data); + if (msg.type === "INIT_VERCEL_SHELL") { + this.vercelShellLoaded = true; + this.sendConfigToShell(); + } + this.embedBridge?.handleMessage(msg); + } catch (err) { + console.error("[TsEmbed] handleMessage parse error:", err); + } + } + + public destroy() { + this.embedBridge?.destroy(); + + } + + public render() { + return ( + { + const { nativeEvent } = syntheticEvent; + console.warn("error in the webview", nativeEvent); + }} + keyboardDisplayRequiresUserAction={false} + automaticallyAdjustContentInsets={false} + scrollEnabled={false} + onHttpError= {(syntheticEvent) => { + const { nativeEvent } = syntheticEvent; + console.warn("HTTP error in the webview", nativeEvent); + }} + style={{ flex: 1, + height: '100%', + width: '100%' + }} + /> + ); + } + +} \ No newline at end of file diff --git a/src/util.ts b/src/util.ts new file mode 100644 index 0000000..a427806 --- /dev/null +++ b/src/util.ts @@ -0,0 +1,6 @@ +import { EmbedEventHandlers } from "./componentFactory"; +import { ViewConfig } from "./types"; + +export interface EmbedProps extends ViewConfig, EmbedEventHandlers { + +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index e0491e9..f926830 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,7 @@ "moduleResolution": "node", "declaration": true, "declarationMap": false, - "jsx": "react", + "jsx": "react-native", "outDir": "./dist", "strict": true, "esModuleInterop": true, @@ -13,7 +13,8 @@ "baseUrl": ".", "paths": { "react-native-webview": ["node_modules/react-native-webview"] - } + }, + "allowSyntheticDefaultImports": true }, "include": ["src/**/*", "src/index.ts"], "exclude": ["node_modules", "dist"]