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

Merged
Merged
Show file tree
Hide file tree
Changes from 4 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"
Copy link
Author

Choose a reason for hiding this comment

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

TODO add peer dependency for safe area context

},
"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.
*/
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`,
{
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
}
}
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])
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.

Copy link
Author

Choose a reason for hiding this comment

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

I've moved the fetching of surveys to Core. If I understand this comment correctly it sounds like it'd be best to check this remote config, and possibly cache the fetched surveys to prevent re-fetch, in core as well later on.

I think this surveys state would still be needed though as it's allowing everything else in this provider to update once the surveys finish loading.

Copy link
Member

Choose a reason for hiding this comment

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

yes, i will do the remote config next week so we can cherry pick from my changes


// 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])
Comment on lines +91 to +109
Copy link
Contributor

Choose a reason for hiding this comment

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

logic: props.automaticSurveyModal is in dependency array but not used in effect. This causes unnecessary effect runs.

Suggested change
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])
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])


// 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)
)
}
Comment on lines +169 to +173
Copy link
Contributor

Choose a reason for hiding this comment

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

logic: sortSurveysByAppearanceDelay mutates the original array due to Array.sort(). Consider using [...surveys].sort() to avoid side effects.

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.
Copy link
Contributor

Choose a reason for hiding this comment

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

syntax: Typo in URL - 'poshog-js' should be 'posthog-js'

Suggested change
A port of survey UI and hooks from [poshog-js](https://github.com/PostHog/posthog-js) to React Native.
A port of survey UI and hooks from [posthog-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()
Comment on lines +82 to +83
Copy link
Contributor

Choose a reason for hiding this comment

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

syntax: Typo in comment - 'show' should be 'shown'

Suggested change
// Call this once when the survey is show
onShow()
// Call this once when the survey is shown
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