From a19b9fca2bfd97514cd2185dca7604a896176169 Mon Sep 17 00:00:00 2001 From: Ian Craig Date: Sat, 4 Jan 2025 14:38:01 +1100 Subject: [PATCH 1/8] Copy in react native surveys port --- posthog-core/src/types.ts | 2 + posthog-react-native/index.ts | 1 + posthog-react-native/package.json | 2 + posthog-react-native/src/posthog-rn.ts | 26 ++ .../src/surveys/PostHogSurveyProvider.tsx | 170 +++++++++ posthog-react-native/src/surveys/Readme.md | 122 ++++++ .../src/surveys/components/BottomSection.tsx | 49 +++ .../src/surveys/components/Cancel.tsx | 31 ++ .../components/ConfirmationMessage.tsx | 53 +++ .../src/surveys/components/QuestionHeader.tsx | 35 ++ .../src/surveys/components/QuestionTypes.tsx | 349 ++++++++++++++++++ .../src/surveys/components/SurveyModal.tsx | 103 ++++++ .../src/surveys/components/Surveys.tsx | 132 +++++++ .../src/surveys/getActiveMatchingSurveys.ts | 63 ++++ posthog-react-native/src/surveys/icons.tsx | 49 +++ posthog-react-native/src/surveys/index.ts | 6 + .../src/surveys/posthog-surveys-types.ts | 199 ++++++++++ .../src/surveys/surveys-utils.ts | 276 ++++++++++++++ .../src/surveys/useActivatedSurveys.ts | 44 +++ .../src/surveys/useSurveyStorage.ts | 59 +++ yarn.lock | 121 ++++-- 21 files changed, 1864 insertions(+), 28 deletions(-) create mode 100644 posthog-react-native/src/surveys/PostHogSurveyProvider.tsx create mode 100644 posthog-react-native/src/surveys/Readme.md create mode 100644 posthog-react-native/src/surveys/components/BottomSection.tsx create mode 100644 posthog-react-native/src/surveys/components/Cancel.tsx create mode 100644 posthog-react-native/src/surveys/components/ConfirmationMessage.tsx create mode 100644 posthog-react-native/src/surveys/components/QuestionHeader.tsx create mode 100644 posthog-react-native/src/surveys/components/QuestionTypes.tsx create mode 100644 posthog-react-native/src/surveys/components/SurveyModal.tsx create mode 100644 posthog-react-native/src/surveys/components/Surveys.tsx create mode 100644 posthog-react-native/src/surveys/getActiveMatchingSurveys.ts create mode 100644 posthog-react-native/src/surveys/icons.tsx create mode 100644 posthog-react-native/src/surveys/index.ts create mode 100644 posthog-react-native/src/surveys/posthog-surveys-types.ts create mode 100644 posthog-react-native/src/surveys/surveys-utils.ts create mode 100644 posthog-react-native/src/surveys/useActivatedSurveys.ts create mode 100644 posthog-react-native/src/surveys/useSurveyStorage.ts diff --git a/posthog-core/src/types.ts b/posthog-core/src/types.ts index 53681162..ab73a1a8 100644 --- a/posthog-core/src/types.ts +++ b/posthog-core/src/types.ts @@ -62,6 +62,8 @@ export enum PostHogPersistedProperty { InstalledAppVersion = 'installed_app_version', // only used by posthog-react-native SessionReplay = 'session_replay', // only used by posthog-react-native DecideEndpointWasHit = 'decide_endpoint_was_hit', // only used by posthog-react-native + SurveyLastSeenDate = 'survey_last_seen_date', // only used by posthog-react-native + SurveysSeen = 'surveys_seen', // only used by posthog-react-native } export type PostHogFetchOptions = { diff --git a/posthog-react-native/index.ts b/posthog-react-native/index.ts index f47c71a8..bb2fe203 100644 --- a/posthog-react-native/index.ts +++ b/posthog-react-native/index.ts @@ -9,3 +9,4 @@ export * from './src/hooks/useFeatureFlag' export * from './src/hooks/usePostHog' export * from './src/PostHogProvider' export * from './src/types' +export * from './src/surveys' diff --git a/posthog-react-native/package.json b/posthog-react-native/package.json index 27ead4de..c0149c92 100644 --- a/posthog-react-native/package.json +++ b/posthog-react-native/package.json @@ -33,6 +33,7 @@ "react-native": "^0.69.1", "react-native-device-info": "^10.3.0", "react-native-navigation": "^6.0.0", + "react-native-svg": "^15.2.0", "posthog-react-native-session-replay": "^0.1" }, "peerDependencies": { @@ -44,6 +45,7 @@ "expo-localization": ">= 11.0.0", "react-native-device-info": ">= 10.0.0", "react-native-navigation": ">=6.0.0", + "react-native-svg": ">= 15.2.0", "posthog-react-native-session-replay": "^0.1" }, "peerDependenciesMeta": { diff --git a/posthog-react-native/src/posthog-rn.ts b/posthog-react-native/src/posthog-rn.ts index 93a0f686..7d42678b 100644 --- a/posthog-react-native/src/posthog-rn.ts +++ b/posthog-react-native/src/posthog-rn.ts @@ -22,6 +22,7 @@ import { } from './types' import { withReactNativeNavigation } from './frameworks/wix-navigation' import { OptionalReactNativeSessionReplay } from './optional/OptionalSessionReplay' +import { Survey, SurveyResponse } from './surveys/posthog-surveys-types' export type PostHogOptions = PostHogCoreOptions & { /** Allows you to provide the storage type. By default 'file'. @@ -451,4 +452,29 @@ export class PostHog extends PostHogCore { this.setPersistedProperty(PostHogPersistedProperty.InstalledAppBuild, appBuild) this.setPersistedProperty(PostHogPersistedProperty.InstalledAppVersion, appVersion) } + + /** + * @todo Where should this go, and should the result be cached? + * I can't find a public method in PostHog which does an authenticated fetch, + * so for now I've included the fetch here so it can access the api key. + */ + public async fetchSurveys(): Promise { + const response = await this.fetch( + // PostHog Dashboard complains I'm using an old SDK version, I think because the ver query param isn't provided. + // TODO I've pulled a recent version from posthog-js, but this should be updated. + `${this.host}/api/surveys/?token=${this.apiKey}&ver=1.200.0`, + { + method: 'GET', + headers: this.getCustomHeaders(), + } + ) + + if (response.status > 300) { + // TODO Best practice for handling this? + throw new Error('Failed to fetch PostHog surveys') + } + + const json = (await response.json()) as SurveyResponse + return json.surveys + } } diff --git a/posthog-react-native/src/surveys/PostHogSurveyProvider.tsx b/posthog-react-native/src/surveys/PostHogSurveyProvider.tsx new file mode 100644 index 00000000..ba7ba9a4 --- /dev/null +++ b/posthog-react-native/src/surveys/PostHogSurveyProvider.tsx @@ -0,0 +1,170 @@ +import React, { useEffect, useMemo, useState } from 'react' + +import { dismissedSurveyEvent, sendSurveyShownEvent } from './components/Surveys' + +import { getActiveMatchingSurveys } from './getActiveMatchingSurveys' +import { useSurveyStorage } from './useSurveyStorage' +import { useActivatedSurveys } from './useActivatedSurveys' +import { SurveyModal } from './components/SurveyModal' +import { defaultSurveyAppearance, getContrastingTextColor, SurveyAppearanceTheme } from './surveys-utils' +import { Survey, SurveyAppearance } from './posthog-surveys-types' +import { usePostHog } from '../hooks/usePostHog' +import { useFeatureFlags } from '../hooks/useFeatureFlags' + +type ActiveSurveyContextType = { survey: Survey; onShow: () => void; onClose: (submitted: boolean) => void } | undefined +const ActiveSurveyContext = React.createContext(undefined) +export const useActiveSurvey = (): ActiveSurveyContextType => React.useContext(ActiveSurveyContext) + +type FeedbackSurveyHook = { + survey: Survey + showSurveyModal: () => void + hideSurveyModal: () => void +} +const FeedbackSurveyContext = React.createContext< + | { + surveys: Survey[] + activeSurvey: Survey | undefined + setActiveSurvey: React.Dispatch> + } + | undefined +>(undefined) +export const useFeedbackSurvey = (selector: string): FeedbackSurveyHook | undefined => { + const context = React.useContext(FeedbackSurveyContext) + const survey = context?.surveys.find( + (survey) => survey.type === 'widget' && survey.appearance?.widgetSelector === selector + ) + if (!context || !survey) { + return undefined + } + + return { + survey, + showSurveyModal: () => context.setActiveSurvey(survey), + hideSurveyModal: () => { + if (context.activeSurvey === survey) { + context.setActiveSurvey(undefined) + } + }, + } +} + +export type PostHogSurveyProviderProps = { + /** + * Whether to show the default survey modal when there is an active survey. (Default true) + * If false, you can call useActiveSurvey and render survey content yourself. + **/ + automaticSurveyModal?: boolean + + /** + * The default appearance for surveys when not specified in PostHog. + */ + defaultSurveyAppearance?: SurveyAppearance + + /** + * If true, PosHog appearance will be ignored and defaultSurveyAppearance is always used. + */ + overrideAppearanceWithDefault?: boolean + + children: React.ReactNode +} + +export function PostHogSurveyProvider(props: PostHogSurveyProviderProps): JSX.Element { + const posthog = usePostHog() + const { seenSurveys, setSeenSurvey, lastSeenSurveyDate, setLastSeenSurveyDate } = useSurveyStorage() + const [surveys, setSurveys] = useState([]) + const [activeSurvey, setActiveSurvey] = useState(undefined) + const activatedSurveys = useActivatedSurveys(posthog, surveys) + + //TODO Why is this untyped? + + const flags: Record | undefined = useFeatureFlags(posthog) + + // Load surveys once + useEffect(() => { + posthog + .fetchSurveys() + .then(setSurveys) + .catch((error: unknown) => { + posthog.capture('PostHogSurveyProvider failed to fetch surveys', { error }) + }) + }, [posthog]) + + // Whenever state changes and there's no active survey, check if there is a new survey to show + useEffect(() => { + if (activeSurvey || !props.automaticSurveyModal) { + return + } + + const activeSurveys = getActiveMatchingSurveys( + surveys, + flags ?? {}, + seenSurveys, + activatedSurveys, + lastSeenSurveyDate + ) + const popoverSurveys = activeSurveys.filter((survey) => survey.type === 'popover') + const popoverSurveyQueue = sortSurveysByAppearanceDelay(popoverSurveys) + + if (popoverSurveyQueue.length > 0) { + setActiveSurvey(popoverSurveyQueue[0]) + } + }, [activeSurvey, flags, surveys, seenSurveys, activatedSurveys, lastSeenSurveyDate, props.automaticSurveyModal]) + + // Merge survey appearance so that components and hooks can use a consistent model + const surveyAppearance = useMemo(() => { + if (props.overrideAppearanceWithDefault || !activeSurvey) { + return { + ...defaultSurveyAppearance, + ...(props.defaultSurveyAppearance ?? {}), + } + } + return { + ...defaultSurveyAppearance, + ...(props.defaultSurveyAppearance ?? {}), + ...(activeSurvey.appearance ?? {}), + // If submitButtonColor is set by PostHog, ensure submitButtonTextColor is also set to contrast + ...(activeSurvey.appearance?.submitButtonColor + ? { + submitButtonTextColor: + activeSurvey.appearance.submitButtonTextColor ?? + getContrastingTextColor(activeSurvey.appearance.submitButtonColor), + } + : {}), + } + }, [activeSurvey, props.defaultSurveyAppearance, props.overrideAppearanceWithDefault]) + + const activeContext = useMemo(() => { + if (!activeSurvey) { + return undefined + } + return { + survey: activeSurvey, + onShow: () => { + sendSurveyShownEvent(activeSurvey, posthog) + setLastSeenSurveyDate(new Date()) + }, + onClose: (submitted: boolean) => { + setSeenSurvey(activeSurvey.id) + setActiveSurvey(undefined) + if (!submitted) { + dismissedSurveyEvent(activeSurvey, posthog) + } + }, + } + }, [activeSurvey, posthog, setLastSeenSurveyDate, setSeenSurvey]) + + return ( + + + {props.children} + {activeContext && } + + + ) +} + +function sortSurveysByAppearanceDelay(surveys: Survey[]): Survey[] { + return surveys.sort( + (a, b) => (a.appearance?.surveyPopupDelaySeconds ?? 0) - (b.appearance?.surveyPopupDelaySeconds ?? 0) + ) +} diff --git a/posthog-react-native/src/surveys/Readme.md b/posthog-react-native/src/surveys/Readme.md new file mode 100644 index 00000000..87d69f7f --- /dev/null +++ b/posthog-react-native/src/surveys/Readme.md @@ -0,0 +1,122 @@ +# PostHog React Native Surveys + +A port of survey UI and hooks from [poshog-js](https://github.com/PostHog/posthog-js) to React Native. + +Adds support for popover and manually triggered surveys when using posthog-react-native. + +## Usage + +Add `PostHogSurveyProvider` to your app anywhere inside `PostHogProvider`. This component fetches surveys and provides hooks described below. It also acts as the root for where popover surveys are rendered. + +```tsx + + {children} + +``` + +Survey appearance respects settings in the Customization section of the survey setup. +If you want to override this so that all surveys use your app theme, you can set + +```tsx + + + {children} + + +``` + +### Feedback Button Surveys + +While this library doesn't provide the beta Feedback Widget UI, you can manually trigger a survey using your own button by using the `useFeedbackSurvey` hook. + +When creating your survey in PostHog set: + +- Presentation = Feedback button +- Customization -> Feedback button type = Custom +- Customization -> Class or ID selector = The value you pass to `useFeedbackSurvey` + +```ts +export function FeedbackButton() { + const feedbackSurvey = useFeedbackSurvey('MySurveySelector') + + const onPress = () => { + if (feedbackSurvey) { + feedbackSurvey.showSurveyModal() + } else { + // Your fallback in case this survey doesn't exist + } + } + + return