diff --git a/.env.example b/.env.example
index e90541cabb..65fb601353 100644
--- a/.env.example
+++ b/.env.example
@@ -28,6 +28,10 @@ EXPO_PUBLIC_CHAT_PROXY_DID=
#
#
+# Growthbook config
+EXPO_PUBLIC_GROWTHBOOK_API_HOST=
+EXPO_PUBLIC_GROWTHBOOK_CLIENT_KEY=
+
# Sentry DSN for telemetry
EXPO_PUBLIC_SENTRY_DSN=
diff --git a/package.json b/package.json
index b89ec45d6f..fc9d5ed714 100644
--- a/package.json
+++ b/package.json
@@ -93,6 +93,7 @@
"@fortawesome/free-regular-svg-icons": "^6.1.1",
"@fortawesome/free-solid-svg-icons": "^6.1.1",
"@fortawesome/react-native-fontawesome": "^0.3.2",
+ "@growthbook/growthbook-react": "^1.6.2",
"@haileyok/bluesky-video": "0.3.2",
"@ipld/dag-cbor": "^9.2.0",
"@lingui/react": "^4.14.1",
diff --git a/src/App.native.tsx b/src/App.native.tsx
index 7f49c0a145..fb93534435 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -21,7 +21,7 @@ import {Provider as StatsigProvider, tryFetchGates} from '#/lib/statsig/statsig'
import {s} from '#/lib/styles'
import {ThemeProvider} from '#/lib/ThemeContext'
import I18nProvider from '#/locale/i18nProvider'
-import {logger} from '#/logger'
+import {logger, Provider as LoggingProvider} from '#/logger'
import {Provider as A11yProvider} from '#/state/a11y'
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
import {Provider as DialogStateProvider} from '#/state/dialogs'
@@ -238,7 +238,9 @@ function App() {
-
+
+
+
diff --git a/src/App.web.tsx b/src/App.web.tsx
index 460d9ff174..2eae93d3e2 100644
--- a/src/App.web.tsx
+++ b/src/App.web.tsx
@@ -12,7 +12,8 @@ import {QueryProvider} from '#/lib/react-query'
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
import {ThemeProvider} from '#/lib/ThemeContext'
import I18nProvider from '#/locale/i18nProvider'
-import {logger} from '#/logger'
+import {logger, Provider as LoggingProvider} from '#/logger'
+import {initializer as growthbookInitializer} from '#/logger/growthbook/context'
import {Provider as A11yProvider} from '#/state/a11y'
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
import {Provider as DialogStateProvider} from '#/state/dialogs'
@@ -85,6 +86,7 @@ function InnerApp() {
useEffect(() => {
async function onLaunch(account?: SessionAccount) {
try {
+ await growthbookInitializer
if (account) {
await resumeSession(account)
}
@@ -205,7 +207,9 @@ function App() {
-
+
+
+
diff --git a/src/env/common.ts b/src/env/common.ts
index 04e98c49cb..0cb8d67872 100644
--- a/src/env/common.ts
+++ b/src/env/common.ts
@@ -84,6 +84,17 @@ export const BLUESKY_PROXY_DID: Did =
export const CHAT_PROXY_DID: Did =
process.env.EXPO_PUBLIC_CHAT_PROXY_DID || 'did:web:api.bsky.chat'
+/**
+ * Growthbook API host
+ */
+export const GROWTHBOOK_API_HOST: string | undefined = process.env.EXPO_PUBLIC_GROWTHBOOK_API_HOST
+
+/**
+ * Growthbook client key
+ */
+export const GROWTHBOOK_CLIENT_KEY: string | undefined = process.env.EXPO_PUBLIC_GROWTHBOOK_CLIENT_KEY
+
+
/**
* Sentry DSN for telemetry
*/
diff --git a/src/logger/growthbook/context.tsx b/src/logger/growthbook/context.tsx
new file mode 100644
index 0000000000..da45e886e3
--- /dev/null
+++ b/src/logger/growthbook/context.tsx
@@ -0,0 +1,114 @@
+import {useMemo, useEffect} from 'react'
+import {Platform} from 'react-native'
+import {
+ GrowthBook,
+ GrowthBookProvider,
+ useFeatureIsOn,
+} from '@growthbook/growthbook-react'
+
+import * as env from '#/env'
+import {useSession, SessionAccount} from '#/state/session'
+import {useGeolocation} from '#/geolocation'
+import * as persisted from '#/state/persisted'
+import * as referrer from '#/logger/growthbook/util/referrer'
+
+type DefaultAttributes = {
+ country: string
+}
+type UserAttributes = {
+ id: string
+ pds: string | undefined
+ platform: string
+ appVersion: string
+ bundleIdentifier: string
+ bundleDate: number
+ refSrc: string
+ refUrl: string
+ appLanguage: string
+ contentLanguages: string[]
+}
+
+const gb = new GrowthBook({
+ apiHost: env.GROWTHBOOK_API_HOST,
+ clientKey: env.GROWTHBOOK_CLIENT_KEY,
+ trackingCallback: (experiment, result) => {
+ // TODO
+ console.log('Experiment Viewed', {
+ experimentId: experiment.key,
+ variationId: result.key,
+ })
+ },
+})
+
+export const initializer = gb.init({
+ timeout: 1e3,
+})
+
+export function useGate(gate: string): boolean {
+ return useFeatureIsOn(gate)
+}
+
+export function Provider({children}: {children: React.ReactNode}) {
+ const geo = useGeolocation()
+ const {currentAccount} = useSession()
+ const defaultAttributes = useMemo(
+ () => ({
+ country: geo.countryCode || 'unknown',
+ }),
+ [geo],
+ )
+
+ /**
+ * Decorate existing attributes with any new default attributes
+ */
+ useEffect(() => {
+ const attr = {
+ ...gb.getAttributes(),
+ ...defaultAttributes,
+ }
+ gb.setAttributes(attr)
+ console.debug(`update attributes`, {attributes: attr})
+ }, [defaultAttributes])
+
+ /**
+ * Update user attributes on session change, and clear them on logout
+ */
+ useEffect(() => {
+ if (currentAccount) {
+ const attr = getUserAttributes(currentAccount)
+ gb.setAttributes({
+ ...defaultAttributes,
+ ...getUserAttributes(currentAccount),
+ })
+ console.debug(`has session, set attributes`, {attributes: attr})
+ } else {
+ gb.setAttributes(defaultAttributes)
+ console.debug(`no session, reset attributes`, {
+ attributes: defaultAttributes,
+ })
+ }
+ }, [defaultAttributes, currentAccount])
+
+ return {children}
+}
+
+export function getUserAttributes(account: SessionAccount): UserAttributes
+export function getUserAttributes(account: undefined): null
+export function getUserAttributes(
+ account?: SessionAccount,
+): UserAttributes | null {
+ if (!account) return null
+ const languagePrefs = persisted.get('languagePrefs')
+ return {
+ id: account.did,
+ pds: account.pdsUrl,
+ platform: Platform.OS,
+ appVersion: env.RELEASE_VERSION,
+ bundleIdentifier: env.BUNDLE_IDENTIFIER,
+ bundleDate: env.BUNDLE_DATE,
+ appLanguage: languagePrefs.appLanguage,
+ contentLanguages: languagePrefs.contentLanguages,
+ refSrc: referrer.src,
+ refUrl: referrer.url,
+ }
+}
diff --git a/src/logger/growthbook/util/referrer.ts b/src/logger/growthbook/util/referrer.ts
new file mode 100644
index 0000000000..d2ccdc47a4
--- /dev/null
+++ b/src/logger/growthbook/util/referrer.ts
@@ -0,0 +1,2 @@
+export const src = ''
+export const url = ''
diff --git a/src/logger/growthbook/util/referrer.web.ts b/src/logger/growthbook/util/referrer.web.ts
new file mode 100644
index 0000000000..0105ccd476
--- /dev/null
+++ b/src/logger/growthbook/util/referrer.web.ts
@@ -0,0 +1,3 @@
+const params = new URLSearchParams(window.location.search)
+export const src = params.get('ref_src') ?? ''
+export const url = decodeURIComponent(params.get('ref_url') ?? '')
diff --git a/src/logger/index.ts b/src/logger/index.tsx
similarity index 94%
rename from src/logger/index.ts
rename to src/logger/index.tsx
index 3da8908c79..cabb76653e 100644
--- a/src/logger/index.ts
+++ b/src/logger/index.tsx
@@ -1,5 +1,6 @@
import {nanoid} from 'nanoid/non-secure'
+import {Provider as GrowthbookProvider} from '#/logger/growthbook/context'
import {logEvent} from '#/lib/statsig/statsig'
import {add} from '#/logger/logDump'
import {type MetricEvents} from '#/logger/metrics'
@@ -171,3 +172,10 @@ export class Logger {
* `logger.error(error[, metadata])`
*/
export const logger = Logger.create(Logger.Context.Default)
+
+/**
+ * Logger context provider
+ */
+export function Provider({children}: {children: React.ReactNode}) {
+ return {children}
+}
diff --git a/yarn.lock b/yarn.lock
index cc81ec14ef..5179c42234 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4678,6 +4678,20 @@
dependencies:
nanoid "^3.3.1"
+"@growthbook/growthbook-react@^1.6.2":
+ version "1.6.2"
+ resolved "https://registry.yarnpkg.com/@growthbook/growthbook-react/-/growthbook-react-1.6.2.tgz#847135be0c46b167f980dbe6015e5f4ed010475c"
+ integrity sha512-96Bo2Jwd4NBn/kBLN4ceF299PhTw8fQltRykD32hu2xMW8/LXhB8swxbPshGK+Xfa2gjgt24kpZ5oSvaVLLT7w==
+ dependencies:
+ "@growthbook/growthbook" "^1.6.2"
+
+"@growthbook/growthbook@^1.6.2":
+ version "1.6.2"
+ resolved "https://registry.yarnpkg.com/@growthbook/growthbook/-/growthbook-1.6.2.tgz#6a25122deac8a09955f6bddeb134af62b209809b"
+ integrity sha512-x3sK6Lff4BVusIzcdBeHZqA3B4kLs3kM/pJ7vvLTJwb6N/+Yn99EF1yc0XU6cfDVqFq6uFkvIFhMwDWUaKD73g==
+ dependencies:
+ dom-mutator "^0.6.0"
+
"@grpc/grpc-js@^1.8.20":
version "1.13.3"
resolved "https://registry.yarnpkg.com/@grpc/grpc-js/-/grpc-js-1.13.3.tgz#6ad08d186c2a8651697085f790c5c68eaca45904"
@@ -10522,6 +10536,11 @@ dom-converter@^0.2.0:
dependencies:
utila "~0.4"
+dom-mutator@^0.6.0:
+ version "0.6.0"
+ resolved "https://registry.yarnpkg.com/dom-mutator/-/dom-mutator-0.6.0.tgz#079d7a4b3e8981a562cd777548b99baab51d65c5"
+ integrity sha512-iCt9o0aYfXMUkz/43ZOAUFQYotjGB+GNbYJiJdz4TgXkyToXbbRy5S6FbTp72lRBtfpUMwEc1KmpFEU4CZeoNg==
+
dom-serializer@^1.0.1:
version "1.4.1"
resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.4.1.tgz#de5d41b1aea290215dc45a6dae8adcf1d32e2d30"