-
Notifications
You must be signed in to change notification settings - Fork 43
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||
---|---|---|---|---|
|
@@ -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<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`, | ||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 posthog-js-lite/posthog-core/src/index.ts Line 563 in a36099a
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? | ||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||||
} | ||||
} |
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
|
||
// 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) | ||
) | ||
} |
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 | ❌ | |
There was a problem hiding this comment.
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.