diff --git a/packages/react-native/src/components/formbricks.tsx b/packages/react-native/src/components/formbricks.tsx index e363f38..df3010a 100644 --- a/packages/react-native/src/components/formbricks.tsx +++ b/packages/react-native/src/components/formbricks.tsx @@ -1,6 +1,6 @@ import type React from "react"; import { useCallback, useEffect, useSyncExternalStore } from "react"; -import { View } from "react-native"; +import { StyleSheet, View } from "react-native"; import { SurveyWebView } from "@/components/survey-web-view"; import { Logger } from "@/lib/common/logger"; import { setup } from "@/lib/common/setup"; @@ -55,7 +55,9 @@ export function Formbricks({ // Wrap in View with pointerEvents="box-none" to fix Android touch event handling. return survey ? ( - + // Fills the parent: a no-overlay survey renders in the host's view tree (not a Modal) and + // positions itself against this wrapper, which would otherwise be 0x0. + ) : null; diff --git a/packages/react-native/src/components/survey-web-view.tsx b/packages/react-native/src/components/survey-web-view.tsx index 37b30ea..e38f2b2 100644 --- a/packages/react-native/src/components/survey-web-view.tsx +++ b/packages/react-native/src/components/survey-web-view.tsx @@ -1,13 +1,22 @@ import { type JSX, useEffect, useRef, useState } from "react"; import { + BackHandler, + Keyboard, KeyboardAvoidingView, Linking, Modal, + Platform, StyleSheet, View, } from "react-native"; import { WebView, type WebViewMessageEvent } from "react-native-webview"; import { getSurveyScriptUrl } from "@/components/utils/survey-script-url"; +import { + androidKeyboardPadding, + getPassthroughFrames, + parseCardRectMessage, + type TCardState, +} from "@/components/utils/survey-touch-region"; import { RNConfig } from "@/lib/common/config"; import { Logger } from "@/lib/common/logger"; import { filterSurveys, getLanguageCode, getStyling } from "@/lib/common/utils"; @@ -29,6 +38,8 @@ interface SurveyWebViewProps { export function SurveyWebView(props: SurveyWebViewProps): JSX.Element | null { const webViewRef = useRef(null); + /** The no-overlay host area, measured for the Android keyboard padding; never padded itself. */ + const hostRef = useRef(null); const [isSurveyRunning, setIsSurveyRunning] = useState(false); const [showSurvey, setShowSurvey] = useState(false); const [appConfig, setAppConfig] = useState(null); @@ -45,6 +56,32 @@ export function SurveyWebView(props: SurveyWebViewProps): JSX.Element | null { */ const [embeddedDataSnapshot, setEmbeddedDataSnapshot] = useState({}); + /** The card's state as the renderer reports it; see `TCardState` for why it has three values. */ + const [cardState, setCardState] = useState(undefined); + /** The area a no-overlay survey renders over, so the WebView keeps exactly that size. */ + const [hostSize, setHostSize] = useState({ width: 0, height: 0 }); + /** Android only; see `androidKeyboardPadding`. */ + const [androidKeyboardHeight, setAndroidKeyboardHeight] = useState(0); + + useEffect(() => { + if (Platform.OS !== "android") return; + const subscriptions = [ + Keyboard.addListener("keyboardDidShow", (event) => { + const keyboardTop = event.endCoordinates.screenY; + hostRef.current?.measureInWindow((_x, y, _width, height) => { + setAndroidKeyboardHeight( + androidKeyboardPadding(y + height, keyboardTop), + ); + }); + }), + Keyboard.addListener("keyboardDidHide", () => { + setAndroidKeyboardHeight(0); + }), + ]; + return () => { + for (const subscription of subscriptions) subscription.remove(); + }; + }, []); useEffect(() => { const fetchConfig = async (): Promise => { @@ -146,192 +183,281 @@ export function SurveyWebView(props: SurveyWebViewProps): JSX.Element | null { const overlay = props.survey.projectOverwrites?.overlay ?? settings.overlay; const appUrl = appConfig.get().appUrl; - return ( - { - setShowSurvey(false); - setIsSurveyRunning(false); + const isPassThrough = (overlay ?? "none") === "none"; + + const webView = ( + { + if (isAllowedWebViewNavigation(event.url, appUrl)) { + return true; + } + + void openExternalUrl(event.url); + return false; }} + onMessage={(event: WebViewMessageEvent) => { + try { + const { data } = event.nativeEvent; + const unvalidatedMessage = JSON.parse(data) as { + type: string; + data: unknown; + }; + + if (unvalidatedMessage.type === "CardRect") { + const card = parseCardRectMessage(unvalidatedMessage.data); + if (card === undefined) { + logger.error("Error parsing card rect from WebView."); + return; + } + setCardState(card); + return; + } + + // debugger + if (unvalidatedMessage.type === "Console") { + if (__DEV__) { + console.info( + `[Console] ${JSON.stringify(unvalidatedMessage.data)}`, + ); + } + return; + } + + const validatedMessage = + ZJsRNWebViewOnMessageData.safeParse(unvalidatedMessage); + if (!validatedMessage.success) { + logger.error("Error parsing message from WebView."); + return; + } + + const { + onClose, + onDisplayCreated, + onFinished, + onOpenExternalURL, + onOpenExternalURLParams, + onResponseCreated, + } = validatedMessage.data; + if (onDisplayCreated) { + const existingDisplays = appConfig.get().user.data.displays; + const newDisplay = { + surveyId: props.survey.id, + createdAt: new Date(), + }; + + const displays = [...existingDisplays, newDisplay]; + const previousConfig = appConfig.get(); + + const updatedUserState = { + ...previousConfig.user, + data: { + ...previousConfig.user.data, + displays, + lastDisplayAt: new Date(), + }, + }; + + const filteredSurveys = filterSurveys( + previousConfig.workspace, + updatedUserState, + ); + + appConfig.update({ + ...previousConfig, + workspace: previousConfig.workspace, + user: updatedUserState, + filteredSurveys, + }); + + // A new display can flip "have seen X" / "have not seen X" segments. The + // optimistic update above keeps recontact/display-cap correct locally; this + // pulls fresh `segments` (gated + coalesced) so interaction targeting is + // current by the time this survey closes and the next trigger evaluates. + refreshSegmentsAfterInteraction( + previousConfig.user.data.userId, + props.survey, + "onDisplay", + ); + } + if (onResponseCreated) { + const responses = appConfig.get().user.data.responses; + const newPersonState: TUserState = { + ...appConfig.get().user, + data: { + ...appConfig.get().user.data, + responses: [...responses, props.survey.id], + }, + }; + + const filteredSurveys = filterSurveys( + appConfig.get().workspace, + newPersonState, + ); + + appConfig.update({ + ...appConfig.get(), + workspace: appConfig.get().workspace, + user: newPersonState, + filteredSurveys, + }); + + // A created response flips "have started responding to X" segments. The + // "completed X" case is handled by onFinished below. + refreshSegmentsAfterInteraction( + appConfig.get().user.data.userId, + props.survey, + "onResponse", + ); + } + if (onFinished) { + // Survey completion flips "have completed X" (and clears "have not completed + // X") segments. The surveys library only fires this after the finished + // response has been sent, so the server recompute sees finished=true. + refreshSegmentsAfterInteraction( + appConfig.get().user.data.userId, + props.survey, + "onFinished", + ); + } + if (onOpenExternalURL && onOpenExternalURLParams?.url) { + void openExternalUrl(onOpenExternalURLParams.url); + } + if (onClose) { + onCloseSurvey(); + } + } catch (error) { + logger.error(`Error handling WebView message: ${error as string}`); + } + }} + /> + ); + + if (!isPassThrough) { + return ( + { + setIsSurveyRunning(false); + // The same close as the survey's own X: it also clears the store, which the + // "already showing" guard in `triggerSurvey` reads. Leaving the survey there + // would block every later one until the app restarts. + onCloseSurvey(); + }} + > + + + {webView} + + + + ); + } + + // `overlay: none` renders in the host's own view tree instead of a . A Modal is its own + // native container and takes every touch however transparent it is, so it cannot let any + // through. Here the WebView sits in a view clipped to the card (see `getPassthroughFrames`), and + // a touch outside that clip reaches the host app. + if (!showSurvey) { + return null; + } + + // The tree shape below never changes between states — only styles do. A different shape would + // remount the WebView, reload the survey and fire onDisplayCreated a second time. + const frames = getPassthroughFrames(cardState, hostSize); + + // The bottom padding shrinks the measured host area, so the WebView shrinks with it and the + // renderer re-lays out the card above the keyboard — and reports the new rect, which moves the + // window with it. KeyboardAvoidingView supplies it on iOS; Android uses its own keyboard height. + // Android gets no `behavior`: "padding" always writes its own paddingBottom over ours. + // + // The outer view is what the Android padding is measured against, so it must never carry the + // padding itself; `collapsable={false}` stops Android flattening it away, which would leave + // `measureInWindow` nothing to measure. + return ( + - - + + { + const { width, height } = event.nativeEvent.layout; + setHostSize({ width, height }); + }} > - { - if (isAllowedWebViewNavigation(event.url, appUrl)) { - return true; - } - - void openExternalUrl(event.url); - return false; - }} - onMessage={(event: WebViewMessageEvent) => { - try { - const { data } = event.nativeEvent; - const unvalidatedMessage = JSON.parse(data) as { - type: string; - data: unknown; - }; - - // debugger - if (unvalidatedMessage.type === "Console") { - if (__DEV__) { - console.info( - `[Console] ${JSON.stringify(unvalidatedMessage.data)}`, - ); - } - return; - } - - const validatedMessage = - ZJsRNWebViewOnMessageData.safeParse(unvalidatedMessage); - if (!validatedMessage.success) { - logger.error("Error parsing message from WebView."); - return; - } - - const { - onClose, - onDisplayCreated, - onFinished, - onOpenExternalURL, - onOpenExternalURLParams, - onResponseCreated, - } = validatedMessage.data; - if (onDisplayCreated) { - const existingDisplays = appConfig.get().user.data.displays; - const newDisplay = { - surveyId: props.survey.id, - createdAt: new Date(), - }; - - const displays = [...existingDisplays, newDisplay]; - const previousConfig = appConfig.get(); - - const updatedUserState = { - ...previousConfig.user, - data: { - ...previousConfig.user.data, - displays, - lastDisplayAt: new Date(), - }, - }; - - const filteredSurveys = filterSurveys( - previousConfig.workspace, - updatedUserState, - ); - - appConfig.update({ - ...previousConfig, - workspace: previousConfig.workspace, - user: updatedUserState, - filteredSurveys, - }); - - // A new display can flip "have seen X" / "have not seen X" segments. The - // optimistic update above keeps recontact/display-cap correct locally; this - // pulls fresh `segments` (gated + coalesced) so interaction targeting is - // current by the time this survey closes and the next trigger evaluates. - refreshSegmentsAfterInteraction( - previousConfig.user.data.userId, - props.survey, - "onDisplay", - ); - } - if (onResponseCreated) { - const responses = appConfig.get().user.data.responses; - const newPersonState: TUserState = { - ...appConfig.get().user, - data: { - ...appConfig.get().user.data, - responses: [...responses, props.survey.id], - }, - }; - - const filteredSurveys = filterSurveys( - appConfig.get().workspace, - newPersonState, - ); - - appConfig.update({ - ...appConfig.get(), - workspace: appConfig.get().workspace, - user: newPersonState, - filteredSurveys, - }); - - // A created response flips "have started responding to X" segments. The - // "completed X" case is handled by onFinished below. - refreshSegmentsAfterInteraction( - appConfig.get().user.data.userId, - props.survey, - "onResponse", - ); - } - if (onFinished) { - // Survey completion flips "have completed X" (and clears "have not completed - // X") segments. The surveys library only fires this after the finished - // response has been sent, so the server recompute sees finished=true. - refreshSegmentsAfterInteraction( - appConfig.get().user.data.userId, - props.survey, - "onFinished", - ); - } - if (onOpenExternalURL && onOpenExternalURLParams?.url) { - void openExternalUrl(onOpenExternalURLParams.url); - } - if (onClose) { - onCloseSurvey(); - } - } catch (error) { - logger.error( - `Error handling WebView message: ${error as string}`, - ); - } - }} - /> - - - + + {webView} + + + + ); } +/** + * Back closes a no-overlay survey, as the Modal's `onRequestClose` does on the overlay path. + * Letting it reach the host instead could navigate away and leave the survey over another screen. + */ +function CloseOnBack({ onBack }: Readonly<{ onBack: () => void }>): null { + useEffect(() => { + const subscription = BackHandler.addEventListener( + "hardwareBackPress", + () => { + onBack(); + return true; + }, + ); + return () => subscription.remove(); + }, [onBack]); + return null; +} + const isAllowedWebViewNavigation = ( candidateUrl: string, appUrl: string, @@ -445,6 +571,13 @@ export const renderHtml = ( window.ReactNativeWebView.postMessage(JSON.stringify({ onFinished: true })); }; + // Where the survey card is, so a no-overlay survey can let touches outside it through. + // Only newer renderers call this; against an older server it never fires and the survey + // keeps blocking the host as before. + function onCardRectChange(rect) { + window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'CardRect', data: rect })); + }; + function getSetIsResponseSendingFinished() { /* noop — presence flips initial state to false so loading spinner renders until ResponseQueue resolves */ }; function getSetIsError() { /* noop */ }; @@ -456,6 +589,7 @@ export const renderHtml = ( onResponseCreated, onFinished, onClose, + onCardRectChange, getSetIsResponseSendingFinished, getSetIsError, }; diff --git a/packages/react-native/src/components/tests/survey-touch-region.test.ts b/packages/react-native/src/components/tests/survey-touch-region.test.ts new file mode 100644 index 0000000..37516ed --- /dev/null +++ b/packages/react-native/src/components/tests/survey-touch-region.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, test } from "vitest"; +import { + androidKeyboardPadding, + getPassthroughFrames, + parseCardRectMessage, +} from "@/components/utils/survey-touch-region"; + +/** + * A no-overlay survey lets touches through by clipping the WebView to the card. These pin what + * the clip covers in each state, because getting it wrong is invisible in review and obvious to a + * user: either the host app freezes, or the survey itself stops responding. + */ +describe("getPassthroughFrames", () => { + const host = { width: 400, height: 800 }; + const card = { x: 16, y: 500, width: 368, height: 280 }; + const fill = { position: "absolute", left: 0, top: 0, right: 0, bottom: 0 }; + + test("blocks everything until the renderer reports a rect", () => { + // An older server never reports one, and the survey must keep blocking as it always did. + const frames = getPassthroughFrames(undefined, host); + + expect(frames.window).toEqual(fill); + expect(frames.webViewFrame).toEqual(fill); + }); + + test("keeps blocking until the host area is measured", () => { + expect(getPassthroughFrames(card, { width: 0, height: 0 }).window).toEqual( + fill, + ); + }); + + test("clips to the card and keeps the WebView full-size behind it", () => { + const frames = getPassthroughFrames(card, host); + + expect(frames.window).toEqual({ + position: "absolute", + left: 16, + top: 500, + width: 368, + height: 280, + overflow: "hidden", + }); + // Offset back so the card lands inside the window, at the host's full size so the renderer + // lays it out exactly as before. + expect(frames.webViewFrame).toEqual({ + position: "absolute", + left: -16, + top: -500, + width: 400, + height: 800, + }); + }); + + test("claims nothing once the card has gone", () => { + const frames = getPassthroughFrames(null, host); + + expect(frames.window.width).toBe(0); + expect(frames.window.height).toBe(0); + expect(frames.window.overflow).toBe("hidden"); + }); +}); + +describe("parseCardRectMessage", () => { + test("reads a rect, and null as no card", () => { + expect(parseCardRectMessage({ x: 1, y: 2, width: 3, height: 4 })).toEqual({ + x: 1, + y: 2, + width: 3, + height: 4, + }); + expect(parseCardRectMessage(null)).toBeNull(); + }); + + test("rejects malformed payloads rather than treating them as no card", () => { + // Reading junk as `null` would make the survey untappable. + expect(parseCardRectMessage({ x: "1", y: 2 })).toBeUndefined(); + expect(parseCardRectMessage(undefined)).toBeUndefined(); + }); +}); + +describe("androidKeyboardPadding", () => { + test("an edge-to-edge host makes room for the whole keyboard", () => { + // The host still reaches the bottom of the screen, under the keyboard. + expect(androidKeyboardPadding(914, 578)).toBe(336); + }); + + test("a host whose window already shrank for the keyboard needs nothing more", () => { + // adjustResize without edge-to-edge: the host ends at the keyboard's top. Padding on top of + // that would push the card a whole keyboard height too far. + expect(androidKeyboardPadding(578, 578)).toBe(0); + }); + + test("only makes up the part of the host the keyboard covers", () => { + expect(androidKeyboardPadding(700, 578)).toBe(122); + }); + + test("is never negative", () => { + expect(androidKeyboardPadding(800, 914)).toBe(0); + }); +}); diff --git a/packages/react-native/src/components/tests/survey-web-view-harness.test.ts b/packages/react-native/src/components/tests/survey-web-view-harness.test.ts index 844a062..fd2a537 100644 --- a/packages/react-native/src/components/tests/survey-web-view-harness.test.ts +++ b/packages/react-native/src/components/tests/survey-web-view-harness.test.ts @@ -44,6 +44,21 @@ describe("WebView harness", () => { expect(propsBlock).toContain("onClose,"); }); + /** The card rect is how a no-overlay survey lets touches through; unwired, it silently blocks. */ + test("wires onCardRectChange into renderSurvey's props and posts it back", () => { + const html = harness(); + const propsBlock = html.slice( + html.indexOf("const surveyProps = {"), + html.indexOf("window.formbricksSurveys.renderSurvey"), + ); + + expect(html).toContain("function onCardRectChange(rect)"); + expect(html).toContain( + "window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'CardRect', data: rect }))", + ); + expect(propsBlock).toContain("onCardRectChange,"); + }); + /** * The Embedded Data bag (ENG-1844/2472) rides the props blob that already exists — no new bridge * message. The blob is JSON, so this pins that the key survives serialization under the name the diff --git a/packages/react-native/src/components/utils/survey-touch-region.ts b/packages/react-native/src/components/utils/survey-touch-region.ts new file mode 100644 index 0000000..253dd3d --- /dev/null +++ b/packages/react-native/src/components/utils/survey-touch-region.ts @@ -0,0 +1,108 @@ +import { z } from "zod"; + +/** + * Where the survey card is, as the renderer reports it through `onCardRectChange`. CSS pixels, + * which the viewport (`initial-scale=1.0`) makes equal to React Native points. + */ +export const ZCardRect = z.object({ + x: z.number(), + y: z.number(), + width: z.number(), + height: z.number(), +}); + +export type TCardRect = z.infer; + +/** + * The card's state, in three values rather than two: + * - `undefined` — no report yet. An older self-hosted server serves a renderer that never calls + * `onCardRectChange`, and then the survey must keep blocking the host exactly as it always did. + * - a rect — only the card takes touches. + * - `null` — no card on screen (it is animating out), so nothing takes touches. + * + * Conflating the first and last is what once made the Flutter SDK's card untappable. + */ +export type TCardState = TCardRect | null | undefined; + +/** Reads a `CardRect` bridge message. Returns `undefined` for anything malformed, so junk changes nothing. */ +export const parseCardRectMessage = ( + data: unknown, +): TCardRect | null | undefined => { + if (data === null) return null; + const parsed = ZCardRect.safeParse(data); + return parsed.success ? parsed.data : undefined; +}; + +interface TAbsoluteFrame { + position: "absolute"; + left: number; + top: number; + width?: number; + height?: number; + right?: number; + bottom?: number; + overflow?: "hidden"; +} + +const FILL: TAbsoluteFrame = { + position: "absolute", + left: 0, + top: 0, + right: 0, + bottom: 0, +}; + +/** + * Styles for the two views around the WebView on the no-overlay path. + * + * The WebView is never resized: the renderer caps the card's content at 60dvh of the WebView's + * own viewport, so shrinking the WebView to the card shrinks the card with it and the two settle + * at a clipped height. Instead a "window" view sits at the card's rect with `overflow: hidden`, + * holding the full-size WebView offset by -x/-y so only the card shows through. React Native + * hit-testing respects that clip, so a touch outside the window reaches the host app. + */ +export const getPassthroughFrames = ( + card: TCardState, + hostSize: { width: number; height: number }, +): { window: TAbsoluteFrame; webViewFrame: TAbsoluteFrame } => { + // No report yet, or the host area is not measured: cover everything and block, as before. + if (card === undefined || hostSize.width === 0) { + return { window: FILL, webViewFrame: FILL }; + } + + // A null card collapses the window to nothing, so it claims no touches at all. + const rect = card ?? { x: 0, y: 0, width: 0, height: 0 }; + return { + window: { + position: "absolute", + left: rect.x, + top: rect.y, + width: rect.width, + height: rect.height, + overflow: "hidden", + }, + webViewFrame: { + position: "absolute", + left: -rect.x, + top: -rect.y, + width: hostSize.width, + height: hostSize.height, + }, + }; +}; + +/** + * Android only: how far the keyboard reaches up into the survey's host area, both edges in + * window coordinates. + * + * Measured against the host's own bottom edge, not the window's: an edge-to-edge app (Expo's + * default) does not shrink for the keyboard, so the host reaches the bottom and needs the whole + * keyboard; an app whose window still resizes (`adjustResize` without edge-to-edge) has already + * lost that height and needs nothing more. The keyboard's reported `height` leaves out the gesture + * bar under it, so its top comes from `screenY`. KeyboardAvoidingView is no help: it recomputes + * from the hide event too, whose `screenY` sits above the navigation bar, and leaves a gap. + */ +export const androidKeyboardPadding = ( + hostBottom: number, + keyboardTop: number, +): number => Math.max(0, hostBottom - keyboardTop); diff --git a/packages/react-native/src/lib/survey/action.ts b/packages/react-native/src/lib/survey/action.ts index 0266364..c0ce697 100644 --- a/packages/react-native/src/lib/survey/action.ts +++ b/packages/react-native/src/lib/survey/action.ts @@ -20,6 +20,16 @@ export const triggerSurvey = (survey: TSurvey): void => { const surveyStore = SurveyStore.getInstance(); const logger = Logger.getInstance(); + // A no-overlay survey leaves the host usable, so it can track again mid-survey. Without this + // a different survey would replace the open one in place. + const showing = surveyStore.getSurvey(); + if (showing) { + logger.debug( + `Survey display of "${survey.id}" skipped: survey "${showing.id}" is already showing.`, + ); + return; + } + // Check if the survey should be displayed based on displayPercentage if (survey.displayPercentage) { const shouldDisplaySurvey = shouldDisplayBasedOnPercentage( diff --git a/packages/react-native/src/lib/survey/tests/action.test.ts b/packages/react-native/src/lib/survey/tests/action.test.ts index 2477268..19d0ac3 100644 --- a/packages/react-native/src/lib/survey/tests/action.test.ts +++ b/packages/react-native/src/lib/survey/tests/action.test.ts @@ -59,6 +59,7 @@ describe("survey/action.ts", () => { const mockSurveyStore = { setSurvey: vi.fn(), + getSurvey: vi.fn(), }; const mockLogger = { @@ -110,6 +111,19 @@ describe("survey/action.ts", () => { // Ensure survey is set expect(mockSurveyStore.setSurvey).toHaveBeenCalledWith(mockSurvey); }); + + // The host stays usable under a no-overlay survey, so it can track again mid-survey. + test("does not replace a survey that is already showing", () => { + vi.mocked(shouldDisplayBasedOnPercentage).mockReturnValueOnce(true); + mockSurveyStore.getSurvey.mockReturnValueOnce({ id: "survey_open" }); + + triggerSurvey(mockSurvey as unknown as TSurvey); + + expect(mockSurveyStore.setSurvey).not.toHaveBeenCalled(); + expect(mockLogger.debug).toHaveBeenCalledWith( + 'Survey display of "survey_001" skipped: survey "survey_open" is already showing.', + ); + }); }); describe("trackAction", () => {