diff --git a/posthog-core/src/index.ts b/posthog-core/src/index.ts index f603b759..9eec1128 100644 --- a/posthog-core/src/index.ts +++ b/posthog-core/src/index.ts @@ -23,6 +23,7 @@ export * as utils from './utils' import { LZString } from './lz-string' import { SimpleEventEmitter } from './eventemitter' import { uuidv7 } from './vendor/uuidv7' +import { Survey, SurveyResponse } from './posthog-surveys-types' class PostHogFetchHttpError extends Error { name = 'PostHogFetchHttpError' @@ -472,6 +473,29 @@ export abstract class PostHogCoreStateless { } } + /*** + *** SURVEYS + ***/ + + public async getSurveys(): Promise { + await this._initPromise + + const url = `${this.host}/api/surveys/?token=${this.apiKey}` + const fetchOptions: PostHogFetchOptions = { + method: 'GET', + headers: this.getCustomHeaders(), + } + + const response = await this.fetchWithRetry(url, fetchOptions) + .then((response) => response.json() as Promise) + .catch((error) => { + this._events.emit('error', error) + return { surveys: [] } + }) + + return response.surveys + } + /*** *** QUEUEING AND FLUSHING ***/ diff --git a/posthog-core/src/posthog-surveys-types.ts b/posthog-core/src/posthog-surveys-types.ts new file mode 100644 index 00000000..df286455 --- /dev/null +++ b/posthog-core/src/posthog-surveys-types.ts @@ -0,0 +1,199 @@ +/** + * Having Survey types in types.ts was confusing tsc + * and generating an invalid module.d.ts + * See https://github.com/PostHog/posthog-js/issues/698 + */ + +export interface SurveyAppearance { + // keep in sync with frontend/src/types.ts -> SurveyAppearance + backgroundColor?: string + submitButtonColor?: string + // deprecate submit button text eventually + submitButtonText?: string + submitButtonTextColor?: string + ratingButtonColor?: string + ratingButtonActiveColor?: string + autoDisappear?: boolean + displayThankYouMessage?: boolean + thankYouMessageHeader?: string + thankYouMessageDescription?: string + thankYouMessageDescriptionContentType?: SurveyQuestionDescriptionContentType + thankYouMessageCloseButtonText?: string + borderColor?: string + position?: 'left' | 'right' | 'center' + placeholder?: string + shuffleQuestions?: boolean + surveyPopupDelaySeconds?: number + // widget options + widgetType?: 'button' | 'tab' | 'selector' + widgetSelector?: string + widgetLabel?: string + widgetColor?: string +} + +export enum SurveyType { + Popover = 'popover', + API = 'api', + Widget = 'widget', +} + +export type SurveyQuestion = BasicSurveyQuestion | LinkSurveyQuestion | RatingSurveyQuestion | MultipleSurveyQuestion + +export type SurveyQuestionDescriptionContentType = 'html' | 'text' + +interface SurveyQuestionBase { + question: string + description?: string | null + descriptionContentType?: SurveyQuestionDescriptionContentType + optional?: boolean + buttonText?: string + originalQuestionIndex: number + branching?: NextQuestionBranching | EndBranching | ResponseBasedBranching | SpecificQuestionBranching +} + +export interface BasicSurveyQuestion extends SurveyQuestionBase { + type: SurveyQuestionType.Open +} + +export interface LinkSurveyQuestion extends SurveyQuestionBase { + type: SurveyQuestionType.Link + link?: string | null +} + +export interface RatingSurveyQuestion extends SurveyQuestionBase { + type: SurveyQuestionType.Rating + display: 'number' | 'emoji' + scale: 3 | 5 | 7 | 10 + lowerBoundLabel: string + upperBoundLabel: string +} + +export interface MultipleSurveyQuestion extends SurveyQuestionBase { + type: SurveyQuestionType.SingleChoice | SurveyQuestionType.MultipleChoice + choices: string[] + hasOpenChoice?: boolean + shuffleOptions?: boolean +} + +export enum SurveyQuestionType { + Open = 'open', + MultipleChoice = 'multiple_choice', + SingleChoice = 'single_choice', + Rating = 'rating', + Link = 'link', +} + +export enum SurveyQuestionBranchingType { + NextQuestion = 'next_question', + End = 'end', + ResponseBased = 'response_based', + SpecificQuestion = 'specific_question', +} + +interface NextQuestionBranching { + type: SurveyQuestionBranchingType.NextQuestion +} + +interface EndBranching { + type: SurveyQuestionBranchingType.End +} + +interface ResponseBasedBranching { + type: SurveyQuestionBranchingType.ResponseBased + responseValues: Record +} + +interface SpecificQuestionBranching { + type: SurveyQuestionBranchingType.SpecificQuestion + index: number +} + +export interface SurveyResponse { + surveys: Survey[] +} + +export type SurveyCallback = (surveys: Survey[]) => void + +export type SurveyUrlMatchType = 'regex' | 'not_regex' | 'exact' | 'is_not' | 'icontains' | 'not_icontains' + +export interface SurveyElement { + text?: string + $el_text?: string + tag_name?: string + href?: string + attr_id?: string + attr_class?: string[] + nth_child?: number + nth_of_type?: number + attributes?: Record + event_id?: number + order?: number + group_id?: number +} +export interface SurveyRenderReason { + visible: boolean + disabledReason?: string +} + +export interface Survey { + // Sync this with the backend's SurveyAPISerializer! + id: string + name: string + description: string + type: SurveyType + feature_flag_keys: + | { + key: string + value?: string + }[] + | null + linked_flag_key: string | null + targeting_flag_key: string | null + internal_targeting_flag_key: string | null + questions: SurveyQuestion[] + appearance: SurveyAppearance | null + conditions: { + url?: string + selector?: string + seenSurveyWaitPeriodInDays?: number + urlMatchType?: SurveyUrlMatchType + events: { + repeatedActivation?: boolean + values: { + name: string + }[] + } | null + actions: { + values: SurveyActionType[] + } | null + } | null + start_date: string | null + end_date: string | null + current_iteration: number | null + current_iteration_start_date: string | null +} + +export interface SurveyActionType { + id: number + name: string | null + steps?: ActionStepType[] +} + +/** Sync with plugin-server/src/types.ts */ +export type ActionStepStringMatching = 'contains' | 'exact' | 'regex' + +export interface ActionStepType { + event?: string | null + selector?: string | null + /** @deprecated Only `selector` should be used now. */ + tag_name?: string + text?: string | null + /** @default StringMatching.Exact */ + text_matching?: ActionStepStringMatching | null + href?: string | null + /** @default ActionStepStringMatching.Exact */ + href_matching?: ActionStepStringMatching | null + url?: string | null + /** @default StringMatching.Contains */ + url_matching?: ActionStepStringMatching | null +} diff --git a/posthog-core/src/types.ts b/posthog-core/src/types.ts index d3d833d0..8a940b24 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 25e35d03..498e8016 100644 --- a/posthog-react-native/package.json +++ b/posthog-react-native/package.json @@ -33,6 +33,8 @@ "react-native": "^0.69.1", "react-native-device-info": "^10.3.0", "react-native-navigation": "^6.0.0", + "react-native-safe-area-context": ">= 4.10.1", + "react-native-svg": "^15.2.0", "react-native-localize": "^3.0.0", "posthog-react-native-session-replay": "^1.0.0" }, @@ -45,6 +47,8 @@ "expo-localization": ">= 11.0.0", "react-native-device-info": ">= 10.0.0", "react-native-navigation": ">=6.0.0", + "react-native-safe-area-context": ">= 4.10.1", + "react-native-svg": ">= 15.2.0", "posthog-react-native-session-replay": "^1.0.0" }, "peerDependenciesMeta": { @@ -72,7 +76,7 @@ "react-native-navigation": { "optional": true }, - "posthog-react-native-session-replay": { + "react-native-safe-area-context": { "optional": true } } diff --git a/posthog-react-native/src/optional/OptionalReactNativeSafeArea.ts b/posthog-react-native/src/optional/OptionalReactNativeSafeArea.ts new file mode 100644 index 00000000..e0557df7 --- /dev/null +++ b/posthog-react-native/src/optional/OptionalReactNativeSafeArea.ts @@ -0,0 +1,22 @@ +import type RNSafeAreaContext from 'react-native-safe-area-context' + +let OptionalRNSafeArea: typeof RNSafeAreaContext | undefined = undefined + +try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + OptionalRNSafeArea = require('react-native-safe-area-context') +} catch (e) {} + +function createDefaultInsets(): RNSafeAreaContext.EdgeInsets { + // If the library isn't available, fall back to a default which should cover most devices + return { top: 60, bottom: 30, left: 0, right: 0 } +} + +export const useOptionalSafeAreaInsets = (): RNSafeAreaContext.EdgeInsets => { + const useSafeAreaInsets = OptionalRNSafeArea?.useSafeAreaInsets ?? createDefaultInsets + try { + return useSafeAreaInsets() + } catch (err) { + return createDefaultInsets() + } +} diff --git a/posthog-react-native/src/posthog-rn.ts b/posthog-react-native/src/posthog-rn.ts index eedb706b..42fcf4e1 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 '../../posthog-core/src/posthog-surveys-types' export type PostHogOptions = PostHogCoreOptions & { /** Allows you to provide the storage type. By default 'file'. diff --git a/posthog-react-native/src/surveys/PostHogSurveyProvider.tsx b/posthog-react-native/src/surveys/PostHogSurveyProvider.tsx new file mode 100644 index 00000000..e5e8de6b --- /dev/null +++ b/posthog-react-native/src/surveys/PostHogSurveyProvider.tsx @@ -0,0 +1,173 @@ +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-core/src/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) + + const flags = useFeatureFlags(posthog) + + // Load surveys once + useEffect(() => { + posthog + .getSurveys() + .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) { + 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]) + + // Modal is shown for PopOver surveys or if automaticSurveyModal is true, and for all widget surveys + // because these would have been invoked by the useFeedbackSurvey hook's showSurveyModal() method + const shouldShowModal = + activeContext && (props.automaticSurveyModal !== false || activeContext.survey.type === 'widget') + + return ( + + + {props.children} + {shouldShowModal && } + + + ) +} + +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..0a0b1e16 --- /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