From 1e65c43e870fbfa38c930f6fe75be40f7bfde27b Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Tue, 25 Mar 2025 20:14:55 -0700 Subject: [PATCH] Implement feature gate control dialog in the lab --- src/lib/statsig/gates.ts | 9 - src/lib/statsig/gates.tsx | 51 +++++ src/logger/metrics.ts | 11 ++ .../LabSettings/FeatureGateDialog.tsx | 175 ++++++++++++++++++ src/screens/Settings/LabSettings/index.tsx | 71 ++++--- src/screens/Settings/Settings.tsx | 11 +- .../Settings/components/SettingsList.tsx | 14 +- 7 files changed, 302 insertions(+), 40 deletions(-) delete mode 100644 src/lib/statsig/gates.ts create mode 100644 src/lib/statsig/gates.tsx create mode 100644 src/screens/Settings/LabSettings/FeatureGateDialog.tsx diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts deleted file mode 100644 index c88a97c751..0000000000 --- a/src/lib/statsig/gates.ts +++ /dev/null @@ -1,9 +0,0 @@ -export type Gate = - // Keep this alphabetic please. - | 'debug_show_feedcontext' - | 'debug_subscriptions' - | 'old_postonboarding' - | 'onboarding_add_video_feed' - | 'remove_show_latest_button' - | 'test_gate_1' - | 'test_gate_2' diff --git a/src/lib/statsig/gates.tsx b/src/lib/statsig/gates.tsx new file mode 100644 index 0000000000..7b9c49da5c --- /dev/null +++ b/src/lib/statsig/gates.tsx @@ -0,0 +1,51 @@ +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +export type Gate = + // Keep this alphabetic please. + | 'debug_show_feedcontext' + | 'debug_subscriptions' + | 'old_postonboarding' + | 'onboarding_add_video_feed' + | 'remove_show_latest_button' + | 'test_gate_1' + | 'test_gate_2' + +export interface GateDescription { + title: string + description: string + help?: string[] +} + +export type GateDescriptions = Record + +export function useGateDescriptions(): GateDescriptions { + const {_} = useLingui() + return { + debug_show_feedcontext: undefined, + debug_subscriptions: undefined, + old_postonboarding: undefined, + onboarding_add_video_feed: undefined, + remove_show_latest_button: undefined, + test_gate_1: { + title: _(msg`Test Gate 1`), + description: _( + msg`A test gate which should be removed before we launch this.`, + ), + }, + test_gate_2: { + title: _(msg`Test Gate 2`), + description: _( + msg`A test gate which should be removed before we launch this.`, + ), + help: [ + _( + msg`This is a lengthier description of the feature which instructs the user on how to use it.`, + ), + _( + msg`Each line is interpretted as a separate paragraph. We might also need to introduce a way to put images in here, but let's not get ahead of ourselves.`, + ), + ], + }, + } +} diff --git a/src/logger/metrics.ts b/src/logger/metrics.ts index abb7b670fa..e0e847e943 100644 --- a/src/logger/metrics.ts +++ b/src/logger/metrics.ts @@ -1,3 +1,5 @@ +import {type Gate} from '#/lib/statsig/gates' + export type MetricEvents = { // App events init: { @@ -329,4 +331,13 @@ export type MetricEvents = { details: boolean } 'reportDialog:failure': {} + + 'featureGate:override': { + gate: Gate + enabled: boolean + } + 'featureGate:feedback': { + gate: Gate + feedback: string + } } diff --git a/src/screens/Settings/LabSettings/FeatureGateDialog.tsx b/src/screens/Settings/LabSettings/FeatureGateDialog.tsx new file mode 100644 index 0000000000..dd2eb98c7d --- /dev/null +++ b/src/screens/Settings/LabSettings/FeatureGateDialog.tsx @@ -0,0 +1,175 @@ +import {useState} from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {type Gate, useGateDescriptions} from '#/lib/statsig/gates' +import {logger} from '#/logger' +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {Divider} from '#/components/Divider' +import * as Toggle from '#/components/forms/Toggle' +import { + EmojiSad_Stroke2_Corner0_Rounded as EmojiSad, + EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmile, +} from '#/components/icons/Emoji' +import {P, Text} from '#/components/Typography' + +enum FeedbackToken { + LoveIt = 'love-it', + HateIt = 'hate-it', + Useful = 'useful', + MakesAppLooksBetter = 'makes-app-look-better', + MakesThingsEasier = 'makes-things-easier', + PromisingButNeedsWork = 'promising-but-needs-work', + RemovesImportantFeatures = 'removes-important-features', + ConfusedMe = 'confused-me', + BrokeMyWillToLive = 'broke-my-will-to-live', +} + +export function FeatureGateDialog({ + control, + gate, +}: { + control: Dialog.DialogOuterProps['control'] + gate: Gate +}) { + const {_} = useLingui() + const t = useTheme() + + const descriptions = useGateDescriptions() + const desc = descriptions[gate] + + if (!desc) { + return null + } + + return ( + + + + + + {desc.title} + + +

{desc.description}

+ + + {}} + style={[a.justify_between]}> + + Enable on this device + + + + + + {desc.help ? ( + <> + + {desc.help.map((line, i) => ( +

{line}

+ ))} + + ) : undefined} + + + + Feedback + + + + + + + + + + + + + + + +
+
+
+ ) +} + +export function FeedbackButton({ + gate, + feedback, +}: { + gate: Gate + feedback: FeedbackToken +}) { + const {_} = useLingui() + const [pressed, setPressed] = useState(false) + const onPress = () => { + setPressed(!pressed) + logger.metric('featureGate:feedback', {gate, feedback}) + } + const label = { + [FeedbackToken.BrokeMyWillToLive]: _(msg`It broke my will to live`), + [FeedbackToken.ConfusedMe]: _(msg`It confused me`), + [FeedbackToken.HateIt]: _(msg`I hate it`), + [FeedbackToken.LoveIt]: _(msg`I love it`), + [FeedbackToken.MakesAppLooksBetter]: _(msg`Makes the app look better`), + [FeedbackToken.MakesThingsEasier]: _(msg`Makes things easier`), + [FeedbackToken.PromisingButNeedsWork]: _(msg`Promising, but needs work`), + [FeedbackToken.RemovesImportantFeatures]: _( + msg`Removes important features`, + ), + [FeedbackToken.Useful]: _(msg`Useful`), + }[feedback] + + return ( + + ) +} diff --git a/src/screens/Settings/LabSettings/index.tsx b/src/screens/Settings/LabSettings/index.tsx index 2be183ce37..713f653632 100644 --- a/src/screens/Settings/LabSettings/index.tsx +++ b/src/screens/Settings/LabSettings/index.tsx @@ -1,22 +1,25 @@ -import {useState} from 'react' -import {Alert, View} from 'react-native' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {NativeStackScreenProps} from '@react-navigation/native-stack' +import {View} from 'react-native' +import {Trans} from '@lingui/macro' +import {type NativeStackScreenProps} from '@react-navigation/native-stack' -import {PressableScale} from '#/lib/custom-animations/PressableScale' -import {CommonNavigatorParams} from '#/lib/routes/types' -import {useGate} from '#/lib/statsig/statsig' +import {type CommonNavigatorParams} from '#/lib/routes/types' +import {type Gate, useGateDescriptions} from '#/lib/statsig/gates' +import * as SettingsList from '#/screens/Settings/components/SettingsList' import {atoms as a, useTheme} from '#/alf' -import * as Toggle from '#/components/forms/Toggle' +import {useDialogControl} from '#/components/Dialog' +import {Beaker_Stroke2_Corner2_Rounded as BeakerIcon} from '#/components/icons/Beaker' import * as Layout from '#/components/Layout' import {Text} from '#/components/Typography' +import {FeatureGateDialog} from './FeatureGateDialog' type Props = NativeStackScreenProps export function LabSettingsScreen({}: Props) { const t = useTheme() - const {_} = useLingui() - const gate = useGate() + const descriptions = useGateDescriptions() + + const gates: Gate[] = Object.entries(descriptions) + .filter(([_k, v]) => !!v) + .map(([k, _v]) => k as Gate) return ( @@ -30,18 +33,42 @@ export function LabSettingsScreen({}: Props) { - - - The Lab (TODO) - + + + + Experimental features on Bluesky. + + + + {gates.map(gate => ( + + ))} + ) } + +function ExperimentButton({gate, enabled}: {gate: Gate; enabled: boolean}) { + const t = useTheme() + const ctrl = useDialogControl() + const descriptions = useGateDescriptions() + return ( + <> + + ctrl.open()}> + + + {descriptions[gate]?.title} + + + {enabled ? Enabled : Disabled} + + + + + ) +} diff --git a/src/screens/Settings/Settings.tsx b/src/screens/Settings/Settings.tsx index f485ae5cf7..64dc1881d8 100644 --- a/src/screens/Settings/Settings.tsx +++ b/src/screens/Settings/Settings.tsx @@ -2,23 +2,23 @@ import {useState} from 'react' import {LayoutAnimation, Pressable, View} from 'react-native' import {Linking} from 'react-native' import {useReducedMotion} from 'react-native-reanimated' -import {AppBskyActorDefs, moderateProfile} from '@atproto/api' +import {type AppBskyActorDefs, moderateProfile} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' -import {NativeStackScreenProps} from '@react-navigation/native-stack' +import {type NativeStackScreenProps} from '@react-navigation/native-stack' import {IS_INTERNAL} from '#/lib/app-info' import {HELP_DESK_URL} from '#/lib/constants' import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher' -import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types' +import {type CommonNavigatorParams, type NavigationProp} from '#/lib/routes/types' import {sanitizeHandle} from '#/lib/strings/handles' import {useProfileShadow} from '#/state/cache/profile-shadow' import {clearStorage} from '#/state/persisted' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useDeleteActorDeclaration} from '#/state/queries/messages/actor-declaration' import {useProfileQuery, useProfilesQuery} from '#/state/queries/profile' -import {SessionAccount, useSession, useSessionApi} from '#/state/session' +import {type SessionAccount, useSession, useSessionApi} from '#/state/session' import {useOnboardingDispatch} from '#/state/shell' import {useLoggedOutViewControls} from '#/state/shell/logged-out' import {useCloseAllActiveElements} from '#/state/util' @@ -32,6 +32,7 @@ import {AvatarStackWithFetch} from '#/components/AvatarStack' import {useDialogControl} from '#/components/Dialog' import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount' import {Accessibility_Stroke2_Corner2_Rounded as AccessibilityIcon} from '#/components/icons/Accessibility' +import {Beaker_Stroke2_Corner2_Rounded as BeakerIcon} from '#/components/icons/Beaker' import {BubbleInfo_Stroke2_Corner2_Rounded as BubbleInfoIcon} from '#/components/icons/BubbleInfo' import {ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon} from '#/components/icons/Chevron' import {CircleQuestion_Stroke2_Corner2_Rounded as CircleQuestionIcon} from '#/components/icons/CircleQuestion' @@ -203,7 +204,7 @@ export function SettingsScreen({}: Props) { - + The Lab diff --git a/src/screens/Settings/components/SettingsList.tsx b/src/screens/Settings/components/SettingsList.tsx index 520df41187..ad8c720210 100644 --- a/src/screens/Settings/components/SettingsList.tsx +++ b/src/screens/Settings/components/SettingsList.tsx @@ -1,11 +1,17 @@ import React, {useContext, useMemo} from 'react' -import {GestureResponderEvent, StyleProp, View, ViewStyle} from 'react-native' +import { + type GestureResponderEvent, + type StyleProp, + type TextStyle, + View, + type ViewStyle, +} from 'react-native' import {HITSLOP_10} from '#/lib/constants' -import {atoms as a, useTheme, ViewStyleProp} from '#/alf' +import {atoms as a, useTheme, type ViewStyleProp} from '#/alf' import * as Button from '#/components/Button' import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron' -import {Link, LinkProps} from '#/components/Link' +import {Link, type LinkProps} from '#/components/Link' import {createPortalGroup} from '#/components/Portal' import {Text} from '#/components/Typography' @@ -268,7 +274,7 @@ export function BadgeText({ style, }: { children: React.ReactNode - style?: StyleProp + style?: StyleProp }) { const t = useTheme() return (