diff --git a/README.md b/README.md index ca5c03b74..af62a0f3d 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,7 @@ These are all available as options in the Runes sub-page of the app's settings. - Show debug context for posts in Discover feed - Don't include the 'via' field in own posts - Developer mode + - Feature gates ### TODO: Xan diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 6ab5c1daf..394caf99b 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -126,6 +126,7 @@ import {RunesBadgesSettingsScreen} from '#/screens/Settings/RunesSettings/Badges import {RunesDisplayDensitySettingsScreen} from '#/screens/Settings/RunesSettings/DensitySettings' import {RunesDisplaySettingsScreen} from '#/screens/Settings/RunesSettings/DisplaySettings' import {RunesExtraSettingsScreen} from '#/screens/Settings/RunesSettings/ExtraSettings' +import {RunesExtraFeatureGatesSettingsScreen} from '#/screens/Settings/RunesSettings/FeatureGatesSettings' import {RunesUsabilityFeedSettingsScreen} from '#/screens/Settings/RunesSettings/FeedSettings' import {RunesImpressionsSettingsScreen} from '#/screens/Settings/RunesSettings/ImpressionsSettings' import {RunesInfrastructureSettingsScreen} from '#/screens/Settings/RunesSettings/InfrastructureSettings' @@ -487,6 +488,11 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) { getComponent={() => RunesExtraSettingsScreen} options={{title: title(msg`Extra settings`), requireAuth: true}} /> + RunesExtraFeatureGatesSettingsScreen} + options={{title: title(msg`Feature gates`), requireAuth: true}} + /> RunesSettingsSyncSettingsScreen} diff --git a/src/analytics/features/index.ts b/src/analytics/features/index.ts index ca980b984..4a9ef5d99 100644 --- a/src/analytics/features/index.ts +++ b/src/analytics/features/index.ts @@ -37,6 +37,10 @@ export const features = new GrowthBook({ */ export const init = Promise.resolve() +export function setForcedFeatureValues(overrides?: Record) { + features.setForcedFeatures(new Map(Object.entries(overrides ?? {}))) +} + /** * Refresh feature gates from GrowthBook. Updates attributes based on the * provided account, if any. diff --git a/src/analytics/index.tsx b/src/analytics/index.tsx index d6b1f55c8..163ca129a 100644 --- a/src/analytics/index.tsx +++ b/src/analytics/index.tsx @@ -8,6 +8,7 @@ import { init, refresh, setAttributes, + setForcedFeatureValues, } from '#/analytics/features' import { getAndMigrateDeviceId, @@ -25,7 +26,7 @@ import {type Metrics, metrics} from '#/analytics/metrics' import * as refParams from '#/analytics/misc/refParams' import * as env from '#/env' import {useGeolocationServiceResponse} from '#/geolocation/service' -import {device} from '#/storage' +import {device, useStorage} from '#/storage' export * as utils from '#/analytics/utils' export const features = {init, refresh} @@ -180,6 +181,7 @@ export function AnalyticsFeaturesContext({ children: React.ReactNode }) { const parentContext = useContext(Context) + const [featureGateOverrides] = useStorage(device, ['featureGateOverrides']) /** * Side-effects: we need to synchronously set these during the same render @@ -187,6 +189,7 @@ export function AnalyticsFeaturesContext({ * the singleton GrowthBook instance. */ setAttributes(parentContext.metadata) + setForcedFeatureValues(featureGateOverrides) feats.setTrackingCallback((experiment, result) => { parentContext.metric('experiment:viewed', { experimentId: experiment.key, diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index 255195d0f..01546c868 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -63,6 +63,7 @@ export type CommonNavigatorParams = { RunesDisplayDensitySettings: undefined RunesInfrastructureSettings: undefined RunesExtraSettings: undefined + RunesExtraFeatureGatesSettings: undefined RunesSettingsSyncSettings: undefined AccountSettings: undefined AutomationLabelSettings: undefined diff --git a/src/routes.ts b/src/routes.ts index 159848c82..7729cb65a 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -60,6 +60,7 @@ export const router = new Router({ RunesDisplayDensitySettings: '/settings/runes/display/density', RunesInfrastructureSettings: '/settings/runes/infrastructure', RunesExtraSettings: '/settings/runes/extra', + RunesExtraFeatureGatesSettings: '/settings/runes/extra/feature-gates', RunesSettingsSyncSettings: '/settings/runes/settings-sync', AppearanceSettings: '/settings/appearance', AppearanceColorThemeSettings: '/settings/appearance/color-theme', diff --git a/src/screens/Settings/RunesSettings/ExtraSettings.tsx b/src/screens/Settings/RunesSettings/ExtraSettings.tsx index d71cf4fa1..183cee1e3 100644 --- a/src/screens/Settings/RunesSettings/ExtraSettings.tsx +++ b/src/screens/Settings/RunesSettings/ExtraSettings.tsx @@ -140,6 +140,14 @@ export function RunesExtraSettingsScreen() { + + + + Feature gates + + ) } diff --git a/src/screens/Settings/RunesSettings/FeatureGatesSettings.tsx b/src/screens/Settings/RunesSettings/FeatureGatesSettings.tsx new file mode 100644 index 000000000..7b7fe0412 --- /dev/null +++ b/src/screens/Settings/RunesSettings/FeatureGatesSettings.tsx @@ -0,0 +1,66 @@ +import {Trans, useLingui} from '@lingui/react/macro' + +import * as SettingsList from '#/screens/Settings/components/SettingsList' +import {atoms as a} from '#/alf' +import {Admonition} from '#/components/Admonition' +import * as Toggle from '#/components/forms/Toggle' +import {Features, features} from '#/analytics/features' +import {device, useStorage} from '#/storage' +import {RunesScreenLayout} from './components/RunesScreenLayout' + +export function RunesExtraFeatureGatesSettingsScreen() { + const {t: l} = useLingui() + const [featureGateOverrides, setFeatureGateOverrides] = useStorage(device, [ + 'featureGateOverrides', + ]) + + const featureGates = Object.values(Features) + + return ( + + + + + These switches force local overrides for GrowthBook feature gates on + this device. + + + + {featureGates.map(gate => { + const hasOverride = Object.prototype.hasOwnProperty.call( + featureGateOverrides ?? {}, + gate, + ) + const value = hasOverride + ? (featureGateOverrides?.[gate] ?? false) + : features.isOn(gate) + + return ( + + setFeatureGateOverrides({ + ...(featureGateOverrides ?? {}), + [gate]: next, + }) + }> + + {gate} + + + + ) + })} + setFeatureGateOverrides({})}> + + Reset all feature gate overrides + + + + ) +} diff --git a/src/storage/schema.ts b/src/storage/schema.ts index 6e9ff6548..8617a6f71 100644 --- a/src/storage/schema.ts +++ b/src/storage/schema.ts @@ -56,6 +56,8 @@ export type Device = { */ mergedGeolocation?: Geolocation + featureGateOverrides?: Record + trendingBetaEnabled: boolean devMode: boolean demoMode: boolean