import type { ForwardRefRenderFunction } from 'react'; import { forwardRef, useEffect, useImperativeHandle, useReducer, useRef, useState, } from 'react'; import { findNodeHandle, type NativeSyntheticEvent, Platform, type StyleProp, UIManager, type ViewStyle, } from 'react-native'; import FireworkSDK from '../FireworkSDK'; import type AdConfiguration from '../models/AdConfiguration'; import type FWError from '../models/FWError'; import { FWEventName } from '../models/FWEventName'; import type { StoryBlockConfiguration } from '../models/StoryBlockConfiguration'; import type StoryBlockNativeConfiguration from '../models/StoryBlockNativeConfiguration'; import type { StoryBlockSource } from '../models/StoryBlockSource'; import { FireworkSDKModuleEventEmitter } from '../modules/FireworkSDKModule'; import FWGlobalState from '../utils/FWGlobalState'; import FWLoggerUtil from '../utils/FWLoggerUtil'; import FWStoryBlock from './FWStoryBlock'; import type ButtonInfo from '../models/ButtonInfo'; import gennerateJsonKey from '../utils/FWJsonUtil'; import { FWComponentType } from '../models/FWComponentType'; const NativeComponentName = 'FWStoryBlock'; enum ViewportState { Entered = 'entered', Left = 'left', } export interface IStoryBlockMethods { /** * Play the story block. */ play: () => void; /** * Pause the story block. */ pause: () => void; /** * Open the fullscreen story block. */ openFullscreen: () => void; /** * Triggered when the story block enters the viewport. * It is recommended to call this method when the host app detects scroll events. */ onViewportEntered: () => void; /** * Triggered when the story block leaves the viewport. * It is recommended that the host app is triggered when listening for scrolling. */ onViewportLeft: () => void; } /** * The props type of StoryBlock component. */ export interface IStoryBlockProps { /** * Standard React Native View Style. */ style?: StyleProp; /** * One of four available story block sources. */ source: StoryBlockSource; /** * Channel id of the story block. Required when the source is set as channel or playlist or dynamicContent. */ channel?: string; /** * Playlist id of the story block. Please note channel id is necessary. Required when the source is set as playlist. */ playlist?: string; /** * The dynamic content parameters of the story block. Required when the source is set as dynamicContent. */ dynamicContentParameters?: { [key: string]: string[] }; /** * Hashtag filter expression is an s-expression used to provide feeds filtered by hashtags with specified criteria. * Queries are specified with boolean predicates on what hashtags are there on the video. * For instance, (and sport food) (or sport food) (and sport (or food comedy)) sport are all valid expressions. * Non-UTF-8 characters are not allowed. If using boolean predicates, the expression needs to be wrapped with parenthesis. */ hashtagFilterExpression?: string; /** * Product ids used to generate the sku feed */ productIds?: string[]; /** * The video or live stream id. */ contentId?: string; /** * Specifies if Picture in Picture is enabled. Only supported on iOS. */ enablePictureInPicture?: boolean; /** * Controls whether the system (native iOS) Picture-in-Picture is enabled. * When set to `false`, the system PiP window will not appear automatically, * while background audio and the in-app floating player continue to work. * Defaults to `true`. Only supported on iOS. */ enableSystemPictureInPicture?: boolean; /** * The corner radius of story block. */ cornerRadius?: number; /** * Ad configuration of the feed. */ adConfiguration?: AdConfiguration; /** * The configuration of the story block. */ storyBlockConfiguration?: StoryBlockConfiguration; /** * The feed loading result callback. It means loading successfully when error equals to undefined. * This callback will not be triggered when the feed is empty; onStoryBlockEmpty will be triggered instead. */ onStoryBlockLoadFinished?: (error?: FWError) => void; /** * The callback is triggered when the feed loads successfully but there are no items in the story block. * onStoryBlockLoadFinished will not be triggered in this case; the two callbacks are mutually exclusive. */ onStoryBlockEmpty?: (error?: FWError) => void; /** * Start Picture in Picture callback. Only supported on iOS. */ onStoryBlockDidStartPictureInPicture?: (error?: FWError) => void; /** * Stop Picture in Picture callback. Only supported on iOS. */ onStoryBlockDidStopPictureInPicture?: (error?: FWError) => void; /** * * The host app could use this callback to get feed id. * The feed id can be used for conversion tracking. */ onStoryBlockGetFeedId?: (feedId: string) => void; } const StoryBlock: ForwardRefRenderFunction< IStoryBlockMethods, IStoryBlockProps > = (props: IStoryBlockProps, forwardedRef) => { const nativeComponentRef = useRef(null); const isMountedRef = useRef(true); const [sdkInitSucceeded, setSdkInitSucceeded] = useState( FWGlobalState.getInstance().sdkInitSucceeded ); const loadedRef = useRef(false); const viewportStateRef = useRef(null); const [, forceUpdate] = useReducer((x) => x + 1, 0); const sendCommand = (command: string) => { if (!isMountedRef.current) return; const nativeNodeHandle = findNodeHandle(nativeComponentRef.current); let commandId: string | number = UIManager.getViewManagerConfig(NativeComponentName).Commands[command] ?? ''; if (Platform.OS === 'android') { commandId = commandId.toString(); } let reactTag: number | null = findNodeHandle(nativeNodeHandle); FWLoggerUtil.log( `StoryBlock sendCommand: ${command} commandId: ${commandId} nativeNodeHandle: ${nativeNodeHandle} reactTag: ${reactTag}` ); if (!nativeNodeHandle || !reactTag) { return; } UIManager.dispatchViewManagerCommand(reactTag, commandId, []); }; const handleStoryBlockLoadFinished = (event: NativeSyntheticEvent) => { if (!isMountedRef.current) return; FWLoggerUtil.log( `StoryBlock handleStoryBlockLoadFinished ${event.nativeEvent.name}` ); const { onStoryBlockLoadFinished, onStoryBlockEmpty } = props; const { name, reason } = event.nativeEvent; if (onStoryBlockLoadFinished) { if (name) { let error: FWError = { name }; if (reason) { error.reason = reason; } onStoryBlockLoadFinished(error); if (!loadedRef.current) { onStoryBlockEmpty?.(error); } } else { onStoryBlockLoadFinished(); loadedRef.current = true; } } }; const handleStoryBlockEmpty = (event: NativeSyntheticEvent) => { if (!isMountedRef.current) return; FWLoggerUtil.log(`StoryBlock handleStoryBlockEmpty ${event.nativeEvent}`); const { onStoryBlockEmpty } = props; if (onStoryBlockEmpty) { onStoryBlockEmpty(); } }; const handleStoryBlockDidStartPictureInPicture = ( event: NativeSyntheticEvent ) => { if (!isMountedRef.current) return; FWLoggerUtil.log( `StoryBlock handleStoryBlockDidStartPictureInPicture ${JSON.stringify( event.nativeEvent )}` ); const { onStoryBlockDidStartPictureInPicture } = props; const { name, reason } = event.nativeEvent; if (onStoryBlockDidStartPictureInPicture) { if (name) { if (reason) { onStoryBlockDidStartPictureInPicture({ name, reason }); } else { onStoryBlockDidStartPictureInPicture({ name }); } } else { onStoryBlockDidStartPictureInPicture(); } } }; const handleStoryBlockDidStopPictureInPicture = ( event: NativeSyntheticEvent ) => { if (!isMountedRef.current) return; FWLoggerUtil.log( `StoryBlock handleStoryBlockDidStopPictureInPicture ${JSON.stringify( event.nativeEvent )}` ); const { onStoryBlockDidStopPictureInPicture } = props; const { name, reason } = event.nativeEvent; if (onStoryBlockDidStopPictureInPicture) { if (name) { if (reason) { onStoryBlockDidStopPictureInPicture({ name, reason }); } else { onStoryBlockDidStopPictureInPicture({ name }); } } else { onStoryBlockDidStopPictureInPicture(); } } }; const handleStoryBlockGetFeedId = (event: NativeSyntheticEvent) => { if (!isMountedRef.current) return; FWLoggerUtil.log( `StoryBlock handleStoryBlockGetFeedId ${JSON.stringify( event.nativeEvent )}` ); const { onStoryBlockGetFeedId } = props; const { feedId } = event.nativeEvent; if (feedId) { FWGlobalState.getInstance().componentTypeByFeedIdMap.set( feedId, FWComponentType.storyBlock ); } if (onStoryBlockGetFeedId) { onStoryBlockGetFeedId(feedId ?? ''); } const viewportState = viewportStateRef.current; if (viewportState === ViewportState.Entered) { sendCommand('onViewportEntered'); } else if (viewportState === ViewportState.Left) { sendCommand('onViewportLeft'); } }; const generateDynamicContentParametersString = (): string => { const { dynamicContentParameters } = props; if (!dynamicContentParameters) { return ''; } let resultString = ''; const sortedKeyList = Object.keys(dynamicContentParameters).sort(); for (const key of sortedKeyList) { const value = dynamicContentParameters[key] ?? []; const valueString = value.join(','); if (resultString.length > 0) { resultString += '_'; } resultString += `${key}:${valueString}`; } return resultString; }; const generateVastAttributesString = () => { const { adConfiguration } = props; const vastAttributes = adConfiguration?.vastAttributes; if (!vastAttributes) { return ''; } let resultString = ''; for (const attribute of vastAttributes) { if (resultString.length > 0) { resultString += '_'; } resultString += `${attribute.name ?? ''}:${attribute.value}`; } return resultString; }; const generateButtonInfoString = (buttonInfo?: ButtonInfo) => { return `imageName:${buttonInfo?.imageName}_systemImageName:${buttonInfo?.systemImageName}_tintColor:${buttonInfo?.tintColor}`; }; const getStoryBlockConfiguration: () => | StoryBlockNativeConfiguration | undefined = () => { const { storyBlockConfiguration, enablePictureInPicture, enableSystemPictureInPicture, } = props; let resultStoryBlockConfiguration: | StoryBlockNativeConfiguration | undefined = storyBlockConfiguration; if (typeof enablePictureInPicture === 'boolean') { if (!resultStoryBlockConfiguration) { resultStoryBlockConfiguration = {}; } resultStoryBlockConfiguration = { ...resultStoryBlockConfiguration, enablePictureInPicture, }; } if (typeof enableSystemPictureInPicture === 'boolean') { if (!resultStoryBlockConfiguration) { resultStoryBlockConfiguration = {}; } resultStoryBlockConfiguration = { ...resultStoryBlockConfiguration, enableSystemPictureInPicture, }; } return resultStoryBlockConfiguration; }; const storyBlockConfiguration = getStoryBlockConfiguration(); const generateKey = (): string => { const gShareBaseURL = FireworkSDK.getInstance().shareBaseURL; const appLanguage = FireworkSDK.getInstance().appLanguage; const videoLaunchBehavior = FireworkSDK.getInstance().videoLaunchBehavior; const adBadgeConfiguration = FireworkSDK.getInstance().adBadgeConfiguration; const adBadgeTextType = adBadgeConfiguration?.badgeTextType; const backgroundColorOfAdBadge = adBadgeConfiguration?.backgroundColor; const textColorOfAdBadge = adBadgeConfiguration?.textColor; const androidFontIsCustomOfAdBadge = adBadgeConfiguration?.androidFontInfo?.isCustom; const androidFontTypefaceNameOfAdBadge = adBadgeConfiguration?.androidFontInfo?.typefaceName; const dataTrackingLevel = FireworkSDK.getInstance().dataTrackingLevel; const livestreamPlayerDesignVersion = FireworkSDK.getInstance().livestreamPlayerDesignVersion; const shortVideoPlayerDesignVersion = FireworkSDK.getInstance().shortVideoPlayerDesignVersion; const { source, channel, playlist, hashtagFilterExpression, productIds, contentId, enablePictureInPicture, cornerRadius, adConfiguration, } = props; const dynamicContentParametersString = generateDynamicContentParametersString(); const playerStyle = storyBlockConfiguration?.playerStyle; const videoCompleteAction = storyBlockConfiguration?.videoCompleteAction; const showShareButton = storyBlockConfiguration?.showShareButton; const showPlaybackButton = storyBlockConfiguration?.showPlaybackButton; const showMuteButton = storyBlockConfiguration?.showMuteButton; const showBranding = storyBlockConfiguration?.showBranding; const ctaDelayType = storyBlockConfiguration?.ctaDelay?.type; const ctaDelayValue = storyBlockConfiguration?.ctaDelay?.value?.toFixed(5); const ctaHighlightDelayType = storyBlockConfiguration?.ctaHighlightDelay?.type; const ctaHighlightDelayValue = storyBlockConfiguration?.ctaHighlightDelay?.value?.toFixed(5); const shareBaseURL = storyBlockConfiguration?.shareBaseURL; const buttonConfiguration = storyBlockConfiguration?.buttonConfiguration; const videoPlayerLogoConfigurationJsonKey = gennerateJsonKey( storyBlockConfiguration?.videoPlayerLogoConfiguration ); const ctaWidth = storyBlockConfiguration?.ctaWidth; const showVideoDetailTitle = storyBlockConfiguration?.showVideoDetailTitle; const requiresAds = adConfiguration?.requiresAds; const adsFetchTimeout = adConfiguration?.adsFetchTimeout; const vastAttributesString = generateVastAttributesString(); const productInfoViewConfigurationJsonKey = gennerateJsonKey( FireworkSDK.getInstance().shopping.productInfoViewConfiguration ); const replayBadgeConfigurationJsonKey = gennerateJsonKey( storyBlockConfiguration?.replayBadgeConfiguration ); const countdownTimerConfigurationJsonKey = gennerateJsonKey( storyBlockConfiguration?.countdownTimerConfiguration ); const additionalControlsInsetJsonKey = gennerateJsonKey( storyBlockConfiguration?.additionalControlsInset ); const feedCompleteAction = storyBlockConfiguration?.feedCompleteAction; const shouldExtendMediaOutsideSafeArea = storyBlockConfiguration?.shouldExtendMediaOutsideSafeArea; const statusBarHidden = storyBlockConfiguration?.statusBarHidden; const statusBarStyle = storyBlockConfiguration?.statusBarStyle; const pipPlacement = storyBlockConfiguration?.pipPlacement; const scrollDirection = storyBlockConfiguration?.scrollDirection; const enableScrollForVertical = storyBlockConfiguration?.enableScrollForVertical; const isArrowButtonVisible = storyBlockConfiguration?.isArrowButtonVisible; const isFullscreenArrowButtonVisible = storyBlockConfiguration?.isFullscreenArrowButtonVisible; const ctaButtonStyle = storyBlockConfiguration?.ctaButtonStyle; const showMoreButton = storyBlockConfiguration?.showMoreButton; const shareButtonPosition = storyBlockConfiguration?.shareButtonPosition; let key = `gShareBaseURL:${gShareBaseURL}`; key += `_appLanguage:${appLanguage}`; key += `_videoLaunchBehavior:${videoLaunchBehavior}`; key += `_adBadgeTextType:${adBadgeTextType}`; key += `_backgroundColorOfAdBadge:${backgroundColorOfAdBadge}`; key += `_textColorOfAdBadge:${textColorOfAdBadge}`; if (Platform.OS === 'android') { key += `_androidFontIsCustomOfAdBadge${androidFontIsCustomOfAdBadge}`; key += `_androidFontTypefaceNameOfAdBadge${androidFontTypefaceNameOfAdBadge}`; } key += `_dataTrackingLevel:${dataTrackingLevel}`; key += `_livestreamPlayerDesignVersion:${livestreamPlayerDesignVersion}`; key += `_shortVideoPlayerDesignVersion:${shortVideoPlayerDesignVersion}`; key += `_source:${source}`; key += `_channel:${channel}`; key += `_playlist:${playlist}`; key += `_dynamicContentParameters:${dynamicContentParametersString}`; key += `_hashtagFilterExpression:${hashtagFilterExpression}`; key += `productIds:${productIds?.join(',')}`; key += `_contentId:${contentId}`; key += `_enablePictureInPicture:${enablePictureInPicture}`; key += `_cornerRadius:${cornerRadius}`; key += `_shareBaseURL:${shareBaseURL}`; key += `_playerStyle:${playerStyle}`; key += `_videoCompleteAction:${videoCompleteAction}`; key += `_showShareButton:${showShareButton}`; key += `_showPlaybackButton:${showPlaybackButton}`; key += `_showMuteButton:${showMuteButton}`; key += `_showBranding:${showBranding}`; key += `_ctaDelayType:${ctaDelayType}`; key += `_ctaDelayValue:${ctaDelayValue}`; key += `_ctaHighlightDelayType:${ctaHighlightDelayType}`; key += `_ctaHighlightDelayValue:${ctaHighlightDelayValue}`; key += `_ctaWidth:${ctaWidth}`; key += `_ctaButtonStyle.backgroundColor:${ctaButtonStyle?.backgroundColor}`; key += `_ctaButtonStyle.textColor:${ctaButtonStyle?.textColor}`; key += `_ctaButtonStyle.fontSize:${ctaButtonStyle?.fontSize}`; key += `_ctaButtonStyle.shape:${ctaButtonStyle?.shape}`; key += `_buttonConfiguration.videoDetailButton:${generateButtonInfoString( buttonConfiguration?.videoDetailButton )}`; key += `_buttonConfiguration.closeButton:${generateButtonInfoString( buttonConfiguration?.closeButton )}`; key += `_buttonConfiguration.muteButton:${generateButtonInfoString( buttonConfiguration?.muteButton )}`; key += `_buttonConfiguration.unmuteButton:${generateButtonInfoString( buttonConfiguration?.unmuteButton )}`; key += `_buttonConfiguration.playButton:${generateButtonInfoString( buttonConfiguration?.playButton )}`; key += `_buttonConfiguration.pauseButton:${generateButtonInfoString( buttonConfiguration?.pauseButton )}`; if (Platform.OS === 'android') { key += `_videoPlayerLogoConfiguration:${videoPlayerLogoConfigurationJsonKey}`; key += `_handleAppearanceManually:${storyBlockConfiguration?.handleAppearanceManually}`; } key += `_showVideoDetailTitle:${showVideoDetailTitle}`; key += `_requiresAds:${requiresAds}`; key += `_adsFetchTimeout:${adsFetchTimeout}`; key += `_vastAttributes:${vastAttributesString}`; key += `_productInfoViewConfiguration:${productInfoViewConfigurationJsonKey}`; key += `_replayBadgeConfiguration:${replayBadgeConfigurationJsonKey}`; key += `_countdownTimerConfiguration:${countdownTimerConfigurationJsonKey}`; key += `_additionalControlsInset:${additionalControlsInsetJsonKey}`; key += `_feedCompleteAction:${feedCompleteAction}`; key += `_shouldExtendMediaOutsideSafeArea:${shouldExtendMediaOutsideSafeArea}`; key += `_statusBarHidden:${statusBarHidden}`; key += `_statusBarStyle:${statusBarStyle}`; key += `_pipPlacement:${pipPlacement}`; key += `_scrollDirection:${scrollDirection}`; key += `_enableScrollForVertical:${enableScrollForVertical}`; key += `_isArrowButtonVisible:${isArrowButtonVisible}`; key += `_isFullscreenArrowButtonVisible:${isFullscreenArrowButtonVisible}`; key += `_showMoreButton:${showMoreButton}`; key += `_shareButtonPosition:${shareButtonPosition}`; return key; }; const key = generateKey(); useImperativeHandle(forwardedRef, () => { return { play: () => { sendCommand('play'); }, pause: () => { sendCommand('pause'); }, openFullscreen: () => { sendCommand('openFullscreen'); }, onViewportEntered: () => { viewportStateRef.current = ViewportState.Entered; sendCommand('onViewportEntered'); }, onViewportLeft: () => { viewportStateRef.current = ViewportState.Left; sendCommand('onViewportLeft'); }, }; }, []); useEffect(() => { const subscriptionOfShareBaseURLUpdated = FireworkSDKModuleEventEmitter.addListener( FWEventName.ShareBaseURLUpdated, () => { FWLoggerUtil.log('Receive FWEventName.ShareBaseURLUpdated'); forceUpdate(); } ); const subscriptionOfAdBadgeConfigurationUpdated = FireworkSDKModuleEventEmitter.addListener( FWEventName.AdBadgeConfigurationUpdated, () => { FWLoggerUtil.log('Receive FWEventName.AdBadgeConfigurationUpdated'); forceUpdate(); } ); const subscriptionOfVideoLaunchBehaviorUpdated = FireworkSDKModuleEventEmitter.addListener( FWEventName.VideoLaunchBehaviorUpdated, () => { FWLoggerUtil.log('Receive FWEventName.VideoLaunchBehaviorUpdated'); forceUpdate(); } ); const subscriptionOfAppLanguageUpdated = FireworkSDKModuleEventEmitter.addListener( FWEventName.AppLanguageUpdated, () => { FWLoggerUtil.log('Receive FWEventName.AppLanguageUpdated'); forceUpdate(); } ); const subscriptionOfProductInfoViewConfigurationUpdated = FireworkSDKModuleEventEmitter.addListener( FWEventName.ProductInfoViewConfigurationUpdated, () => { FWLoggerUtil.log( 'Receive FWEventName.ProductInfoViewConfigurationUpdated' ); forceUpdate(); } ); const subscriptionOfDataTrackingLevelUpdated = FireworkSDKModuleEventEmitter.addListener( FWEventName.DataTrackingLevelUpdated, () => { FWLoggerUtil.log('Receive FWEventName.DataTrackingLevelUpdated'); forceUpdate(); } ); const subscriptionOfLivestreamPlayerDesignVersionUpdated = FireworkSDKModuleEventEmitter.addListener( FWEventName.LivestreamPlayerDesignVersionUpdated, () => { FWLoggerUtil.log( 'Receive FWEventName.LivestreamPlayerDesignVersionUpdated' ); forceUpdate(); } ); const subscriptionOfShortVideoPlayerDesignVersionUpdated = FireworkSDKModuleEventEmitter.addListener( FWEventName.ShortVideoPlayerDesignVersionUpdated, () => { FWLoggerUtil.log( 'Receive FWEventName.ShortVideoPlayerDesignVersionUpdated' ); forceUpdate(); } ); return () => { subscriptionOfShareBaseURLUpdated.remove(); subscriptionOfAdBadgeConfigurationUpdated.remove(); subscriptionOfVideoLaunchBehaviorUpdated.remove(); subscriptionOfAppLanguageUpdated.remove(); subscriptionOfProductInfoViewConfigurationUpdated.remove(); subscriptionOfDataTrackingLevelUpdated.remove(); subscriptionOfLivestreamPlayerDesignVersionUpdated.remove(); subscriptionOfShortVideoPlayerDesignVersionUpdated.remove(); }; }, []); // On Android, fragment creation is now handled natively in onAfterUpdateTransaction // via StoryBlockContainerView.setFragment(), eliminating the 500ms race condition. /* useEffect(() => { if (Platform.OS === 'android') { setTimeout(() => { if (!isMountedRef.current) return; const viewId = findNodeHandle(nativeComponentRef.current); FWLoggerUtil.log(`StoryBlock createFragment viewId: ${viewId}`); if (!viewId) { return; } UIManager.dispatchViewManagerCommand( viewId, UIManager.getViewManagerConfig( NativeComponentName ).Commands.create?.toString() ?? '', [viewId] ); }, 500); } }, [key]); */ useEffect(() => { FWGlobalState.getInstance().sdkInitSucceededPromise.then(() => { if (!isMountedRef.current) return; setSdkInitSucceeded(true); }); }, []); useEffect(() => { return () => { FWLoggerUtil.log( '[react-native-firework-sdk] [StoryBlock] useEffect return' ); isMountedRef.current = false; }; }, []); if (!sdkInitSucceeded) { return null; } const { style, cornerRadius, ...otherProps } = props; return ( ); }; export default forwardRef(StoryBlock);