Compare commits

...

9 Commits

Author SHA1 Message Date
Paul Frazee c8373b9af9 Update src/screens/Settings/LabSettings/FeatureGateDialog.tsx
Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
2025-03-26 08:57:35 -07:00
Paul Frazee 341b793177 Update src/screens/Settings/LabSettings/FeatureGateDialog.tsx
Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
2025-03-26 08:56:55 -07:00
Paul Frazee 1fc25e591a Update src/screens/Settings/LabSettings/FeatureGateDialog.tsx
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2025-03-26 08:56:41 -07:00
Samuel Newman 6f3f4b2c49 fix prettier 2025-03-26 08:52:18 +02:00
Paul Frazee da2594b3e1 Add metrics for featuregate override 2025-03-25 23:17:54 -07:00
Paul Frazee 1d1348a96d Web and lightmode fixes 2025-03-25 23:15:45 -07:00
Paul Frazee c12102e6d0 Implement device-local featuregate overrides 2025-03-25 23:11:07 -07:00
Paul Frazee 1e65c43e87 Implement feature gate control dialog in the lab 2025-03-25 20:14:55 -07:00
Paul Frazee 739ed42d38 Add 'the lab' settings screen 2025-03-25 17:58:54 -07:00
13 changed files with 416 additions and 27 deletions
+1
View File
@@ -277,6 +277,7 @@ func serve(cctx *cli.Context) error {
e.GET("/settings/content-and-media", server.WebGeneric)
e.GET("/settings/about", server.WebGeneric)
e.GET("/settings/app-icon", server.WebGeneric)
e.GET("/settings/lab", server.WebGeneric)
e.GET("/sys/debug", server.WebGeneric)
e.GET("/sys/debug-mod", server.WebGeneric)
e.GET("/sys/log", server.WebGeneric)
+9
View File
@@ -83,6 +83,7 @@ import {ProfileFollowsScreen} from '#/screens/Profile/ProfileFollows'
import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy'
import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings'
import {AppIconSettingsScreen} from '#/screens/Settings/AppIconSettings'
import {LabSettingsScreen} from '#/screens/Settings/LabSettings'
import {NotificationSettingsScreen} from '#/screens/Settings/NotificationSettings'
import {
StarterPackScreen,
@@ -392,6 +393,14 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
requireAuth: true,
}}
/>
<Stack.Screen
name="LabSettings"
getComponent={() => LabSettingsScreen}
options={{
title: title(msg`The Lab`),
requireAuth: true,
}}
/>
<Stack.Screen
name="Hashtag"
getComponent={() => HashtagScreen}
+1
View File
@@ -53,6 +53,7 @@ export type CommonNavigatorParams = {
ContentAndMediaSettings: undefined
AboutSettings: undefined
AppIconSettings: undefined
LabSettings: undefined
Search: {q?: string}
Hashtag: {tag: string; author?: string}
Topic: {topic: string}
-9
View File
@@ -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'
+51
View File
@@ -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<Gate, GateDescription | undefined>
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.`,
),
],
},
}
}
+15 -10
View File
@@ -1,17 +1,17 @@
import React from 'react'
import {Platform} from 'react-native'
import {AppState, AppStateStatus} from 'react-native'
import {AppState, type AppStateStatus} from 'react-native'
import {Statsig, StatsigProvider} from 'statsig-react-native-expo'
import {BUNDLE_DATE, BUNDLE_IDENTIFIER, IS_TESTFLIGHT} from '#/lib/app-info'
import {logger} from '#/logger'
import {MetricEvents} from '#/logger/metrics'
import {type MetricEvents} from '#/logger/metrics'
import {isWeb} from '#/platform/detection'
import * as persisted from '#/state/persisted'
import {useSession} from '../../state/session'
import {timeout} from '../async/timeout'
import {useNonReactiveCallback} from '../hooks/useNonReactiveCallback'
import {Gate} from './gates'
import {type Gate} from './gates'
const SDK_KEY = 'client-SXJakO39w9vIhl3D44u8UupyzFl4oZ2qPIkjwcvuPsV'
@@ -180,25 +180,26 @@ export function useGate(): (gateName: Gate, options?: GateOptions) => boolean {
}
/**
* Debugging tool to override a gate. USE ONLY IN E2E TESTS!
* Tool to override a gate on the local device
*/
export function useDangerousSetGate(): (
export function useSetLocalGateOverride(): (
gateName: Gate,
value: boolean,
) => void {
const cache = React.useContext(GateCache)
if (!cache) {
throw Error(
'useDangerousSetGate() cannot be called outside StatsigProvider.',
'useSetLocalOverride() cannot be called outside StatsigProvider.',
)
}
const dangerousSetGate = React.useCallback(
const setGate = React.useCallback(
(gateName: Gate, value: boolean) => {
cache.set(gateName, value)
persisted.write('gateOverrides', Object.fromEntries(cache.entries()))
},
[cache],
)
return dangerousSetGate
return setGate
}
function toStatsigUser(did: string | undefined): StatsigUser {
@@ -286,11 +287,11 @@ export function Provider({children}: {children: React.ReactNode}) {
// Have our own cache in front of Statsig.
// This ensures the results remain stable until the active DID changes.
const [gateCache, setGateCache] = React.useState(() => new Map())
const [gateCache, setGateCache] = React.useState(() => createGateCache())
const [prevDid, setPrevDid] = React.useState(did)
if (did !== prevDid) {
setPrevDid(did)
setGateCache(new Map())
setGateCache(createGateCache())
}
// Periodically poll Statsig to get the current rule evaluations for all stored accounts.
@@ -323,3 +324,7 @@ export function Provider({children}: {children: React.ReactNode}) {
</GateCache.Provider>
)
}
function createGateCache(): Map<string, boolean> {
return new Map(Object.entries(persisted.get('gateOverrides') || {}))
}
+11
View File
@@ -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
}
}
+1
View File
@@ -47,6 +47,7 @@ export const router = new Router({
ContentAndMediaSettings: '/settings/content-and-media',
AboutSettings: '/settings/about',
AppIconSettings: '/settings/app-icon',
LabSettings: '/settings/lab',
// support
Support: '/support',
PrivacyPolicy: '/support/privacy',
@@ -0,0 +1,216 @@
import {useState} from 'react'
import {View} from 'react-native'
import * as Updates from 'expo-updates'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {type Gate, useGateDescriptions} from '#/lib/statsig/gates'
import {useGate, useSetLocalGateOverride} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
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 {Loader} from '#/components/Loader'
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 gateApi = useGate()
const setGateApi = useSetLocalGateOverride()
const [originalEnabled] = useState(() => gateApi(gate))
const [enabled, setEnabled] = useState(originalEnabled)
const [isRestarting, setIsRestarting] = useState(false)
const desc = descriptions[gate]
const onToggleGate = (v: boolean) => {
logger.metric('featureGate:override', {gate, enabled: v})
setGateApi(gate, v)
setEnabled(v)
}
if (!desc) {
return null
}
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner
accessibilityDescribedBy="dialog-description"
accessibilityLabelledBy="dialog-title">
<View style={[a.relative, a.gap_md, a.w_full]}>
<Text
nativeID="dialog-title"
style={[a.text_2xl, a.font_bold, t.atoms.text]}>
{desc.title}
</Text>
<P nativeID="dialog-description">{desc.description}</P>
<View
style={[a.rounded_sm, t.atoms.bg_contrast_25, a.px_md, a.py_sm]}>
<Toggle.Item
name="quoteposts"
type="checkbox"
label={_(msg`Tap to toggle this experiment on or off`)}
value={enabled}
onChange={onToggleGate}
style={[a.justify_between]}>
<Text style={[t.atoms.text_contrast_high]}>
<Trans>Enabled on this device</Trans>
</Text>
<Toggle.Switch />
</Toggle.Item>
</View>
{enabled !== originalEnabled ? (
<Button
variant="solid"
color="primary"
size="large"
onPress={() => {
setIsRestarting(true)
if (isWeb) {
location.reload()
} else {
Updates.reloadAsync()
}
}}
label={_(msg`Restart to apply changes`)}
disabled={isRestarting}>
<ButtonText>
<Trans>Restart to apply changes</Trans>
</ButtonText>
{isRestarting ? (
<Loader size="sm" style={[{color: 'white'}]} />
) : null}
</Button>
) : null}
{desc.help ? (
<>
<Divider style={[a.my_lg]} />
{desc.help.map((line, i) => (
<P key={`help-${i}`}>{line}</P>
))}
</>
) : undefined}
<Divider style={[a.my_lg]} />
<Text style={[a.text_xl, a.font_bold, t.atoms.text]}>
<Trans context="the.lab.dialog">Feedback</Trans>
</Text>
<View style={[a.flex_row, a.gap_sm]}>
<FeedbackButton gate={gate} feedback={FeedbackToken.LoveIt} />
<FeedbackButton gate={gate} feedback={FeedbackToken.HateIt} />
</View>
<View style={[a.flex_col, a.gap_sm]}>
<FeedbackButton gate={gate} feedback={FeedbackToken.Useful} />
<FeedbackButton
gate={gate}
feedback={FeedbackToken.MakesAppLooksBetter}
/>
<FeedbackButton
gate={gate}
feedback={FeedbackToken.MakesThingsEasier}
/>
<FeedbackButton
gate={gate}
feedback={FeedbackToken.PromisingButNeedsWork}
/>
<FeedbackButton
gate={gate}
feedback={FeedbackToken.RemovesImportantFeatures}
/>
<FeedbackButton gate={gate} feedback={FeedbackToken.ConfusedMe} />
<FeedbackButton
gate={gate}
feedback={FeedbackToken.BrokeMyWillToLive}
/>
</View>
</View>
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}
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 (
<Button
variant="solid"
color={pressed ? 'primary' : 'secondary'}
size="large"
onPress={onPress}
style={[
(feedback === FeedbackToken.LoveIt ||
feedback === FeedbackToken.HateIt) &&
a.flex_1,
]}
label={label}>
{feedback === FeedbackToken.LoveIt && (
<ButtonIcon icon={EmojiSmile} position="left" />
)}
{feedback === FeedbackToken.HateIt && (
<ButtonIcon icon={EmojiSad} position="left" />
)}
<ButtonText>{label}</ButtonText>
</Button>
)
}
@@ -0,0 +1,85 @@
import {View} from 'react-native'
import {Trans} from '@lingui/macro'
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {type CommonNavigatorParams} from '#/lib/routes/types'
import {type Gate, useGateDescriptions} from '#/lib/statsig/gates'
import {useGate} from '#/lib/statsig/statsig'
import * as SettingsList from '#/screens/Settings/components/SettingsList'
import {atoms as a, useTheme} from '#/alf'
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<CommonNavigatorParams, 'LabSettings'>
export function LabSettingsScreen({}: Props) {
const t = useTheme()
const descriptions = useGateDescriptions()
const gates: Gate[] = Object.entries(descriptions)
.filter(([_k, v]) => !!v)
.map(([k, _v]) => k as Gate)
return (
<Layout.Screen>
<Layout.Header.Outer>
<Layout.Header.BackButton />
<Layout.Header.Content>
<Layout.Header.TitleText>
<Trans>The Lab</Trans>
</Layout.Header.TitleText>
</Layout.Header.Content>
<Layout.Header.Slot />
</Layout.Header.Outer>
<Layout.Content>
<View style={[a.p_lg, a.pb_0]}>
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
<Trans>Experimental features on Bluesky.</Trans>
</Text>
</View>
<SettingsList.Container>
{gates.map(gate => (
<ExperimentButton key={gate} gate={gate} />
))}
</SettingsList.Container>
</Layout.Content>
</Layout.Screen>
)
}
function ExperimentButton({gate}: {gate: Gate}) {
const t = useTheme()
const ctrl = useDialogControl()
const descriptions = useGateDescriptions()
const gateApi = useGate()
const enabled = gateApi(gate)
return (
<>
<SettingsList.Divider />
<SettingsList.PressableItem
label={descriptions[gate]?.title || ''}
onPress={() => ctrl.open()}>
<SettingsList.ItemIcon icon={BeakerIcon} />
<SettingsList.ItemText>
{descriptions[gate]?.title}
</SettingsList.ItemText>
<SettingsList.BadgeText
style={[
a.flex_1,
enabled && {
color:
t.scheme === 'dark'
? t.palette.positive_400
: t.palette.positive_600,
},
]}>
{enabled ? <Trans>Enabled</Trans> : <Trans>Disabled</Trans>}
</SettingsList.BadgeText>
</SettingsList.PressableItem>
<FeatureGateDialog control={ctrl} gate={gate} />
</>
)
}
+14 -4
View File
@@ -2,23 +2,26 @@ 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 +35,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'
@@ -202,6 +206,12 @@ export function SettingsScreen({}: Props) {
<Trans>Languages</Trans>
</SettingsList.ItemText>
</SettingsList.LinkItem>
<SettingsList.LinkItem to="/settings/lab" label={_(msg`The Lab`)}>
<SettingsList.ItemIcon icon={BeakerIcon} />
<SettingsList.ItemText>
<Trans>The Lab</Trans>
</SettingsList.ItemText>
</SettingsList.LinkItem>
<SettingsList.PressableItem
onPress={() => Linking.openURL(HELP_DESK_URL)}
label={_(msg`Help`)}
@@ -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<ViewStyle>
style?: StyleProp<TextStyle>
}) {
const t = useTheme()
return (
+2
View File
@@ -127,6 +127,7 @@ const schema = z.object({
mutedThreads: z.array(z.string()),
trendingDisabled: z.boolean().optional(),
trendingVideoDisabled: z.boolean().optional(),
gateOverrides: z.object({}).catchall(z.boolean()).optional(),
})
export type Schema = z.infer<typeof schema>
@@ -174,6 +175,7 @@ export const defaults: Schema = {
subtitlesEnabled: true,
trendingDisabled: false,
trendingVideoDisabled: false,
gateOverrides: {},
}
export function tryParse(rawData: string): Schema | undefined {