Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add survey support for React Native #333

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions posthog-core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
1 change: 1 addition & 0 deletions posthog-react-native/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
4 changes: 3 additions & 1 deletion posthog-react-native/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "posthog-react-native",
"version": "3.6.1",
"version": "3.6.1-surveys-0.2.0",
"main": "lib/posthog-react-native/index.js",
"files": [
"lib/"
Expand Down Expand Up @@ -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": {
Expand All @@ -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": {
Expand Down
26 changes: 26 additions & 0 deletions posthog-react-native/src/posthog-rn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'.
Expand Down Expand Up @@ -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.
*/
Comment on lines +457 to +460
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://github.com/PostHog/posthog-js/blob/8d70a9ba4383b453ade94b71d7f45df4af2c892c/src/posthog-core.ts#L1294C25-L1321
i think those methods can be in the core package, here and reuse the fetch infrastructure from there, example here.

public async fetchSurveys(): Promise<Survey[]> {
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`,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can remove the version for now since the user-agent also has it

headers['User-Agent'] = customUserAgent

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

by doing this, the user agent is automatically added

{
method: 'GET',
headers: this.getCustomHeaders(),
}
)

if (response.status > 300) {
// TODO Best practice for handling this?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

by doing this, error handling is builtin

throw new Error('Failed to fetch PostHog surveys')
}

const json = (await response.json()) as SurveyResponse
return json.surveys
}
}
175 changes: 175 additions & 0 deletions posthog-react-native/src/surveys/PostHogSurveyProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
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<ActiveSurveyContextType>(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<React.SetStateAction<Survey | undefined>>
}
| 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<Survey[]>([])
const [activeSurvey, setActiveSurvey] = useState<Survey | undefined>(undefined)
const activatedSurveys = useActivatedSurveys(posthog, surveys)

//TODO Why is this untyped?

const flags: Record<string, string | boolean> | undefined = useFeatureFlags(posthog)

// Load surveys once
useEffect(() => {
posthog
.fetchSurveys()
.then(setSurveys)
.catch((error: unknown) => {
posthog.capture('PostHogSurveyProvider failed to fetch surveys', { error })
})
}, [posthog])
Comment on lines +82 to +90
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will likely change since we are adding a remote config API (WIP), and we'll only load surveys if there are surveys to be loaded.
That means we'll be calling another API when the SDK starts.


// 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<SurveyAppearanceTheme>(() => {
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 (
<ActiveSurveyContext.Provider value={activeContext}>
<FeedbackSurveyContext.Provider value={{ surveys, activeSurvey, setActiveSurvey }}>
{props.children}
{shouldShowModal && <SurveyModal appearance={surveyAppearance} {...activeContext} />}
</FeedbackSurveyContext.Provider>
</ActiveSurveyContext.Provider>
)
}

function sortSurveysByAppearanceDelay(surveys: Survey[]): Survey[] {
return surveys.sort(
(a, b) => (a.appearance?.surveyPopupDelaySeconds ?? 0) - (b.appearance?.surveyPopupDelaySeconds ?? 0)
)
}
122 changes: 122 additions & 0 deletions posthog-react-native/src/surveys/Readme.md
Original file line number Diff line number Diff line change
@@ -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
<PostHogProvider /*... your config ...*/>
<PostHogSurveyProvider>{children}</PostHogSurveyProvider>
</PostHogProvider>
```

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
<PostHogProvider /*... your config ...*/>
<PostHogSurveyProvider
overrideAppearanceWithDefault={true}
defaultSurveyAppearance={{ ... }}>
{children}
</PostHogSurveyProvider>
</PostHogProvider>
```

### 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 <Button onPress={onPress} title="Send Feedback" />
}
```

### Custom Components

By default, popover surveys are shown automatically. You can disable this by setting `automaticSurveyModal={false}`.

The hook `useActiveSurvey` will return the survey that should currently be displayed.
You can also import the `<Questions>` component directly and pass your own survey appearance if you'd like to reuse the survey content in your own modal or screen.

```ts
import { useActiveSurvey, type SurveyAppearance } from 'posthog-react-native'

const appearance: SurveyAppearance = {
// ... Your theme here
}

export function SurveyScreen() {
const activeSurvey = useActiveSurvey()
if (!activeSurvey) return null

const { survey, onShow, onClose } = activeSurvey

const onSubmit = () => {
// e.g. you might show your own thank you message here
onClose()
}

useEffect(() => {
// Call this once when the survey is show
onShow()
}, [onShow])

return (
<View style={styles.surveyScreen}>
<Questions
survey={survey}
onSubmit={onSubmit}
appearance={appearance}
styleOverride={styles.surveyScrollViewStyle}
/>
</View>
)
}
```

## Supported Features

| Feature | Support |
| --------------------------------- | ---------------------------------- |
| **Questions** | |
| All question types | ✅ |
| Multi-question surveys | ✅ |
| Confirmation message | ✅ When using default modal UI |
| **Feedback Button Presentation** | |
| Custom feedback button | ✅ Via `useFeedbackSurvey` hook |
| Pre-built feedback tab | ❌ |
| **Customization / Appearance** | _When using default modal UI_ |
| Set colors in PostHog Dashboard | ✅ Or override with your app theme |
| Shuffle Questions | ✅ |
| PostHog branding | ❌ Always off |
| Delay popup after page load | ✅ |
| Position config | ❌ Always bottom center |
| **Display conditions** | |
| Feature flag & property targeting | ✅ |
| URL Targeting | ❌ |
| CSS Selector Matches | ❌ |
| Survey Wait period | ✅ |
| Event Triggers | ✅ |
| Action Triggers | ❌ |
Loading