From 8fd3d94d788800869c850b6ae29095077841e37e Mon Sep 17 00:00:00 2001 From: Cedric van Putten Date: Fri, 23 Feb 2024 09:46:36 +0100 Subject: [PATCH] refactor(website): rewrite Appetize device frame with the Appetize JS SDK (#558) * refactor(website): drop unused event hooks * chore(website): add missing appetize events * chore(website): add the Appetize JS SDK * refactor(website): rework the appetize device frame using the js sdk * chore: update appetize workflow with new constants * refactor(website): drop outdated appetize code * fix(website): resolve warnings in console * fix(website): update popup url when possible and restart full app on reload * fix(website): make appetize device selector input controlled * chore(website): tweak the auto-scaling device preview for embeds * fix(website): add back embed appetize device overrides * refactor(website): use Appetize device endpoint with device names * fix(website): use proper device prop type in device preview --- .github/workflows/appetize.yml | 6 +- .../DevicePreview/AppetizeDeviceControl.tsx | 112 ++++ .../DevicePreview/AppetizeFrame.tsx | 524 ++++++------------ .../DevicePreview/DevicePreview.tsx | 19 +- website/src/client/configs/constants.tsx | 79 ++- website/src/client/utils/Appetize.ts | 74 +++ .../constructAppetizeURL.test.tsx.snap | 5 - .../__tests__/constructAppetizeURL.test.tsx | 50 -- .../src/client/utils/constructAppetizeURL.tsx | 136 ----- .../src/server/components/AppetizeScript.tsx | 99 ++++ website/src/server/pages/Document.tsx | 2 + 11 files changed, 516 insertions(+), 590 deletions(-) create mode 100644 website/src/client/components/DevicePreview/AppetizeDeviceControl.tsx create mode 100644 website/src/client/utils/Appetize.ts delete mode 100644 website/src/client/utils/__tests__/__snapshots__/constructAppetizeURL.test.tsx.snap delete mode 100644 website/src/client/utils/__tests__/constructAppetizeURL.test.tsx delete mode 100644 website/src/client/utils/constructAppetizeURL.tsx create mode 100644 website/src/server/components/AppetizeScript.tsx diff --git a/.github/workflows/appetize.yml b/.github/workflows/appetize.yml index 34310d4b1..bab31191a 100644 --- a/.github/workflows/appetize.yml +++ b/.github/workflows/appetize.yml @@ -71,16 +71,18 @@ jobs: const appetizeInfo = appetizePerSdk[sdkVersion][queue] if (!appetizeInfo) throw new Error(`Configured Appetize is missing queue "${queue}"`); if (!appetizeInfo.android) throw new Error(`Configured Appetize queue "${queue}" is missing Android`); + if (!appetizeInfo.android.publicKey) throw new Error(`Configured Appetize queue "${queue}" is missing Android's public key`); if (!appetizeInfo.ios) throw new Error(`Configured Appetize queue "${queue}" is missing iOS`); + if (!appetizeInfo.ios.publicKey) throw new Error(`Configured Appetize queue "${queue}" is missing iOS's public key`); return { sdkVersion: sdkVersion, appetizeName: queue, appetizeTokenName: `SNACK_RUNTIME_APPETIZE_${queue.toUpperCase()}_TOKEN`, - androidAppetizeId: appetizeInfo.android, + androidAppetizeId: appetizeInfo.android.publicKey, androidUrl: sdkInfo.androidClientUrl, androidVersion: sdkInfo.androidClientVersion, - iosAppetizeId: appetizeInfo.ios, + iosAppetizeId: appetizeInfo.ios.publicKey, iosUrl: sdkInfo.iosClientUrl, iosVersion: sdkInfo.iosClientVersion, } diff --git a/website/src/client/components/DevicePreview/AppetizeDeviceControl.tsx b/website/src/client/components/DevicePreview/AppetizeDeviceControl.tsx new file mode 100644 index 000000000..16e7f75d6 --- /dev/null +++ b/website/src/client/components/DevicePreview/AppetizeDeviceControl.tsx @@ -0,0 +1,112 @@ +import { StyleSheet, css } from 'aphrodite'; +import React, { PropsWithChildren } from 'react'; + +import { useAppetizeDevices } from '../../utils/Appetize'; +import { c } from '../ThemeProvider'; + +export function AppetizeDeviceControl({ children }: PropsWithChildren) { + return
{children}
; +} + +AppetizeDeviceControl.ReloadSnack = ReloadSnack; +AppetizeDeviceControl.SelectDevice = SelectDevice; + +type ReloadSnackProps = { + canReloadSnack: boolean; + onReloadSnack: () => void; +}; + +function ReloadSnack({ onReloadSnack, canReloadSnack }: ReloadSnackProps) { + return ( + + ); +} + +type SelectDeviceProps = { + platform: 'android' | 'ios'; + selectedDevice?: string; + onSelectDevice?: (device: string) => void; +}; + +function SelectDevice({ platform, onSelectDevice, selectedDevice }: SelectDeviceProps) { + const devices = useAppetizeDevices(platform); + + if (!selectedDevice) { + return null; + } + + return ( + + ); +} + +const styles = StyleSheet.create({ + container: { + display: 'flex', + flex: 0, + alignItems: 'center', + justifyContent: 'center', + }, + button: { + appearance: 'none', + outline: 0, + border: `1px solid ${c('border')}`, + borderLeftWidth: 0, + backgroundColor: c('content'), + color: c('text'), + padding: 8, + margin: '16px 0', + textAlign: 'center', + width: 112, + + ':first-child': { + borderLeftWidth: 1, + borderRadius: '3px 0 0 3px', + padding: '6px 12px', + }, + + ':last-child': { + borderRadius: '0 3px 3px 0', + padding: '6px 12px', + }, + + ':only-child': { + borderLeftWidth: 1, + borderRadius: '3px', + padding: '6px 12px', + }, + + ':hover': { + backgroundColor: c('hover'), + }, + + ':disabled': { + opacity: 0.5, + backgroundColor: c('content'), + }, + }, +}); diff --git a/website/src/client/components/DevicePreview/AppetizeFrame.tsx b/website/src/client/components/DevicePreview/AppetizeFrame.tsx index 1f59f84a4..b6085b865 100644 --- a/website/src/client/components/DevicePreview/AppetizeFrame.tsx +++ b/website/src/client/components/DevicePreview/AppetizeFrame.tsx @@ -1,372 +1,234 @@ import { StyleSheet, css } from 'aphrodite'; -import * as React from 'react'; +import React, { Component, createRef } from 'react'; -import { getLoginHref } from '../../auth/login'; -import withAuth, { AuthProps } from '../../auth/withAuth'; -import { SDKVersion, Viewer } from '../../types'; +import { AppetizeDeviceControl } from './AppetizeDeviceControl'; +import { SDKVersion } from '../../types'; import Analytics from '../../utils/Analytics'; -import constructAppetizeURL, { getAppetizeConfig } from '../../utils/constructAppetizeURL'; -import type { EditorModal } from '../EditorViewProps'; +import { getAppetizeConstants } from '../../utils/Appetize'; import withThemeName, { ThemeName } from '../Preferences/withThemeName'; -import { c } from '../ThemeProvider'; -import Button from '../shared/Button'; -import ButtonLink from '../shared/ButtonLink'; /** @see https://docs.appetize.io/core-features/playback-options */ export type AppetizeDeviceAndroid = 'none' | string; /** @see https://docs.appetize.io/core-features/playback-options */ export type AppetizeDeviceIos = 'none' | string; +/** Custom Appetize device settings for embedded devices */ export type AppetizeDevices = { _showFrame: boolean; android?: { device?: AppetizeDeviceAndroid; scale?: number }; ios?: { device?: AppetizeDeviceIos; scale?: number }; }; -type Props = AuthProps & { - width: number; +type AppetizeFrameProps = { + /** The Apptize SDK version to use */ sdkVersion: SDKVersion; - experienceURL: string; + /** The Appetize platform to use */ platform: 'android' | 'ios'; - isEmbedded?: boolean; - payerCode?: string; - isPopupOpen: boolean; - onPopupUrl: (url: string) => void; - onShowModal: (modal: EditorModal) => void; - onAppLaunch?: () => void; + /** The Snack theme settings */ theme: ThemeName; + /** If snack is running in embed mode */ + isEmbedded: boolean; + /** The Snack experience URL to load */ + experienceURL: string; + /** Custom Appetize settings for embedded instances */ devices?: AppetizeDevices; + /** Legacy callback to force the Snack to be online */ + onAppLaunch?: () => void; + /** Legacy callback to reopen Appetize in a popup */ + onPopupUrl?: (url: string) => void; }; -type AppetizeStatus = - | { type: 'unknown' } - | { type: 'requested' } - | { type: 'queued'; position: number | undefined } - | { type: 'connecting' } - | { type: 'launch' } - | { type: 'timeout' }; - -type PayerCodeFormStatus = - | { type: 'open'; value: string } - | { type: 'submitted' } - | { type: 'closed' }; - -type State = { - appetizeStatus: AppetizeStatus; - appetizeURL: string; - autoplay: boolean; - payerCodeFormStatus: PayerCodeFormStatus; - platform: 'ios' | 'android'; - sdkVersion: SDKVersion; - theme: ThemeName; - viewer: Viewer | undefined; +type AppetizeFrameState = { + session?: AppetizeSdkSession; + deviceId?: string; + queuePosition?: number; + sentQueueInfo?: boolean; + isReloadingSnack?: boolean; }; -class AppetizeFrame extends React.PureComponent { - private static getAppetizeURL(props: Props, autoplay: boolean) { - const { experienceURL, platform, isEmbedded, payerCode, theme, devices, sdkVersion } = props; - - return constructAppetizeURL({ - type: isEmbedded ? 'embedded' : 'website', - experienceURL, - autoplay, - platform, - previewQueue: isEmbedded ? 'secondary' : 'main', - deviceColor: theme === 'dark' ? 'white' : 'black', - payerCode, - devices, - sdkVersion, - }); - } - - static getDerivedStateFromProps(props: Props, state: State) { - // Reset appetize status when we change platform or sdk version or user logs in - if ( - props.platform !== state.platform || - props.sdkVersion !== state.sdkVersion || - props.theme !== state.theme - ) { - const autoplay = state.payerCodeFormStatus.type === 'submitted'; - return { - appetizeStatus: { type: 'unknown' }, - appetizeURL: AppetizeFrame.getAppetizeURL(props, autoplay), - autoplay, - payerCodeFormStatus: { type: 'closed' }, - platform: props.platform, - sdkVersion: props.sdkVersion, - theme: props.theme, - viewer: props.viewer, - }; - } +export class AppetizeFrame extends Component { + /** The Appetize SDK client singleton instance*/ + private client?: AppetizeSdkClient; + /** The iframe ref, to reopen as a popup */ + private iframe = createRef(); - return null; + constructor(props: AppetizeFrameProps) { + super(props); + this.state = {}; // Note(cedric): this is somehow required... } - state: State = { - appetizeStatus: { type: 'unknown' }, - appetizeURL: AppetizeFrame.getAppetizeURL(this.props, false), - autoplay: false, - payerCodeFormStatus: { type: 'closed' }, - platform: this.props.platform, - sdkVersion: this.props.sdkVersion, - theme: this.props.theme, - viewer: this.props.viewer, - }; - componentDidMount() { - window.addEventListener('message', this.handlePostMessage); - window.addEventListener('unload', this.endSession); + // Load the Appetize client and setup initial bindings + this.initAppetizeClient(resolveAppetizeConfig(this.props, this.state)); + + window.addEventListener('beforeunload', this.endAppetizeSession); + } - this.props.onPopupUrl(this.state.appetizeURL); + componentWillUnmount() { + window.removeEventListener('beforeunload', this.endAppetizeSession); } - componentDidUpdate(_prevProps: Props, prevState: State) { + /** + * Update the Appetize client when platform, sdkVersion, or theme change. + */ + componentDidUpdate(prevProps: AppetizeFrameProps, prevState: AppetizeFrameState) { if ( - prevState.appetizeStatus !== this.state.appetizeStatus && - this.state.appetizeStatus.type === 'requested' + prevProps.sdkVersion !== this.props.sdkVersion || + prevProps.platform !== this.props.platform || + prevProps.theme !== this.props.theme || + prevProps.isEmbedded !== this.props.isEmbedded || + prevProps.experienceURL !== this.props.experienceURL || + prevState.deviceId !== this.state.deviceId ) { - this.handleLaunchRequest(); - } else if (this.state.appetizeURL !== prevState.appetizeURL) { - this.props.onPopupUrl(this.state.appetizeURL); + const config = resolveAppetizeConfig(this.props, this.state); + + this.resetAppetizeClient(config); + this.props.onPopupUrl?.(resolveAppetizePopupUrl(config)); } } - componentWillUnmount() { - this.endSession(); - - window.removeEventListener('message', this.handlePostMessage); - window.removeEventListener('unload', this.endSession); - } + /** Initialize the Appetize SDK client */ + private initAppetizeClient = async (config: AppetizeSdkConfig) => { + if (!this.client) { + this.setState({ deviceId: config.device }); + this.props.onPopupUrl?.(this.iframe.current!.src); + + this.client = await window.appetize.getClient('#snack-appetize', config); + this.client.on('error', (error) => console.error('Appetize error:', error)); + this.client.on('queue', (queue) => this.onAppetizeQueue(queue)); + this.client.on('session', (session) => this.onAppetizeSession(session)); + this.client.on('sessionRequested', () => this.props.onAppLaunch?.()); + } - private handleLaunchRequest = () => { - Analytics.getInstance().logEvent('RAN_EMULATOR'); + return this.client; }; - private handlePayerCodeLink = () => { - this.setState({ - payerCodeFormStatus: { type: 'open', value: '' }, - }); - Analytics.getInstance().logEvent('REQUESTED_APPETIZE_CODE', {}, 'previewQueue'); + /** Re-intialize the Appetize SDK client and clear any pending session */ + private resetAppetizeClient = async (config: AppetizeSdkConfig) => { + await this.endAppetizeSession(); + await this.client?.setConfig(config); }; - private handlePostMessage = ({ origin, data }: MessageEvent) => { - if (origin === getAppetizeConfig(this.props.sdkVersion).url) { - let status: AppetizeStatus | undefined; - - console.log(data); - - switch (data) { - case 'sessionRequested': - status = { type: 'requested' }; - break; - case 'sessionConnecting': - status = { type: 'connecting' }; - break; - case 'appLaunch': - status = { type: 'launch' }; - - this.props.onAppLaunch?.(); - - if (this.state.appetizeStatus.type === 'queued') { - Analytics.getInstance().logEvent('APP_LAUNCHED', {}, 'previewQueue'); - } - Analytics.getInstance().clearTimer('previewQueue'); - break; - case 'timeoutWarning': - status = { type: 'timeout' }; - break; - case 'sessionEnded': - status = { type: 'unknown' }; - Analytics.getInstance().clearTimer('previewQueue'); - break; - case 'accountQueued': - status = { type: 'queued', position: undefined }; - break; - // Disabled, needs to be redesigned - // case 'concurrentQueued': - // status = { type: 'queued', position: data.position }; - // break; - // case 'concurrentQueuedPosition': - // status = { type: 'queued', position: data.position }; - // break; - default: - if (data && data.type === 'accountQueuedPosition') { - status = { type: 'queued', position: data.position }; - if ( - this.state.appetizeStatus.type !== 'queued' || - !this.state.appetizeStatus.position - ) { - Analytics.getInstance().logEvent('QUEUED_FOR_PREVIEW', { - queuePosition: status.position, - }); - Analytics.getInstance().startTimer('previewQueue'); - } - } - } - - if (status) { - this.setState({ - appetizeStatus: status, - }); - } + /** Clear any active Appetize sessions */ + private endAppetizeSession = async () => { + if (this.state.session) { + await this.state.session.end(); + this.setState({ session: undefined }); } - }; - - private handlePayerCodeChange = (e: React.ChangeEvent) => - this.setState({ - payerCodeFormStatus: { type: 'open', value: e.target.value }, - }); - private handlePayerCodeSubmit = (e: React.FormEvent) => { - e.preventDefault(); + Analytics.getInstance().clearTimer('previewQueue'); + }; - if (this.props.viewer) { - this.savePayerCode(); - } + /** Store the session reference and clear possible queue timer */ + private onAppetizeSession = (session: AppetizeSdkSession) => { + this.setState({ session }); - Analytics.getInstance().logEvent('ENTERED_APPETIZE_CODE', {}, 'previewQueue'); + Analytics.getInstance().clearTimer('previewQueue'); }; - private savePayerCode = () => { - const { payerCodeFormStatus } = this.state; + /** Provide telemetry about queue build up */ + private onAppetizeQueue = (queue: AppetizeQueueEventData) => { + if (!this.state.sentQueueInfo) { + this.setState({ sentQueueInfo: true }); - if (payerCodeFormStatus.type !== 'open' || !payerCodeFormStatus.value) { - return; + Analytics.getInstance().startTimer('previewQueue'); + Analytics.getInstance().logEvent('QUEUED_FOR_PREVIEW', { + queuePosition: queue.position, + }); } - - this.props.setMetadata({ - appetizeCode: payerCodeFormStatus.value, - }); - - this.setState({ - payerCodeFormStatus: { type: 'submitted' }, - }); }; - private iframe = React.createRef(); - - private endSession = () => { - this.iframe.current?.contentWindow?.postMessage('endSession', '*'); + private onReloadSnack = () => { + if (this.state.session) { + this.setState({ isReloadingSnack: true }); + this.state.session.restartApp().finally(() => this.setState({ isReloadingSnack: false })); + } }; - render() { - const { appetizeStatus, payerCodeFormStatus, viewer, appetizeURL } = this.state; - const { width, isEmbedded } = this.props; + private onDeviceChange = (deviceId: string) => this.setState({ deviceId }); + render() { return ( <> -
+