From ebd279ed6839965af5e033ed7f430aa7401fd77d Mon Sep 17 00:00:00 2001
From: Eric Bailey <git@esb.lol>
Date: Mon, 4 Mar 2024 11:25:47 -0600
Subject: [PATCH 1/8] Don't warn on internal links (#3089)

* Don't want on internal links

* Just disable mismatch for this inline link
---
 src/view/com/notifications/FeedItem.tsx | 1 +
 1 file changed, 1 insertion(+)

diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx
index f037097d..45166fe3 100644
--- a/src/view/com/notifications/FeedItem.tsx
+++ b/src/view/com/notifications/FeedItem.tsx
@@ -228,6 +228,7 @@ let FeedItem = ({
               text={sanitizeDisplayName(
                 authors[0].displayName || authors[0].handle,
               )}
+              disableMismatchWarning
             />
             {authors.length > 1 ? (
               <>

From 6c9d6f5b05953988cb4fb1556bf435805479e07e Mon Sep 17 00:00:00 2001
From: Eric Bailey <git@esb.lol>
Date: Mon, 4 Mar 2024 15:37:11 -0600
Subject: [PATCH 2/8] Improve dialogs a11y (#3094)

* Improve a11y on ios

* Format

* Remove android

* Fix android
---
 src/components/Dialog/index.tsx     | 83 ++++++++++++++++-------------
 src/components/Dialog/index.web.tsx | 12 ++---
 src/state/dialogs/index.tsx         | 34 ++++++++----
 src/view/shell/index.tsx            | 16 +++++-
 4 files changed, 91 insertions(+), 54 deletions(-)

diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx
index ef4f4741..fa375b0f 100644
--- a/src/components/Dialog/index.tsx
+++ b/src/components/Dialog/index.tsx
@@ -15,7 +15,7 @@ import {useTheme, atoms as a, flatten} from '#/alf'
 import {Portal} from '#/components/Portal'
 import {createInput} from '#/components/forms/TextField'
 import {logger} from '#/logger'
-import {useDialogStateContext} from '#/state/dialogs'
+import {useDialogStateControlContext} from '#/state/dialogs'
 
 import {
   DialogOuterProps,
@@ -82,7 +82,7 @@ export function Outer({
   const hasSnapPoints = !!sheetOptions.snapPoints
   const insets = useSafeAreaInsets()
   const closeCallback = React.useRef<() => void>()
-  const {openDialogs} = useDialogStateContext()
+  const {setDialogIsOpen} = useDialogStateControlContext()
 
   /*
    * Used to manage open/closed, but index is otherwise handled internally by `BottomSheet`
@@ -96,11 +96,11 @@ export function Outer({
 
   const open = React.useCallback<DialogControlProps['open']>(
     ({index} = {}) => {
-      openDialogs.current.add(control.id)
+      setDialogIsOpen(control.id, true)
       // can be set to any index of `snapPoints`, but `0` is the first i.e. "open"
       setOpenIndex(index || 0)
     },
-    [setOpenIndex, openDialogs, control.id],
+    [setOpenIndex, setDialogIsOpen, control.id],
   )
 
   const close = React.useCallback<DialogControlProps['close']>(cb => {
@@ -133,12 +133,12 @@ export function Outer({
           closeCallback.current = undefined
         }
 
-        openDialogs.current.delete(control.id)
+        setDialogIsOpen(control.id, false)
         onClose?.()
         setOpenIndex(-1)
       }
     },
-    [onClose, setOpenIndex, openDialogs, control.id],
+    [onClose, setOpenIndex, setDialogIsOpen, control.id],
   )
 
   const context = React.useMemo(() => ({close}), [close])
@@ -146,38 +146,45 @@ export function Outer({
   return (
     isOpen && (
       <Portal>
-        <BottomSheet
-          enableDynamicSizing={!hasSnapPoints}
-          enablePanDownToClose
-          keyboardBehavior="interactive"
-          android_keyboardInputMode="adjustResize"
-          keyboardBlurBehavior="restore"
-          topInset={insets.top}
-          {...sheetOptions}
-          snapPoints={sheetOptions.snapPoints || ['100%']}
-          ref={sheet}
-          index={openIndex}
-          backgroundStyle={{backgroundColor: 'transparent'}}
-          backdropComponent={Backdrop}
-          handleIndicatorStyle={{backgroundColor: t.palette.primary_500}}
-          handleStyle={{display: 'none'}}
-          onChange={onChange}>
-          <Context.Provider value={context}>
-            <View
-              style={[
-                a.absolute,
-                a.inset_0,
-                t.atoms.bg,
-                {
-                  borderTopLeftRadius: 40,
-                  borderTopRightRadius: 40,
-                  height: Dimensions.get('window').height * 2,
-                },
-              ]}
-            />
-            {children}
-          </Context.Provider>
-        </BottomSheet>
+        <View
+          // iOS
+          accessibilityViewIsModal
+          // Android
+          importantForAccessibility="yes"
+          style={[a.absolute, a.inset_0]}>
+          <BottomSheet
+            enableDynamicSizing={!hasSnapPoints}
+            enablePanDownToClose
+            keyboardBehavior="interactive"
+            android_keyboardInputMode="adjustResize"
+            keyboardBlurBehavior="restore"
+            topInset={insets.top}
+            {...sheetOptions}
+            snapPoints={sheetOptions.snapPoints || ['100%']}
+            ref={sheet}
+            index={openIndex}
+            backgroundStyle={{backgroundColor: 'transparent'}}
+            backdropComponent={Backdrop}
+            handleIndicatorStyle={{backgroundColor: t.palette.primary_500}}
+            handleStyle={{display: 'none'}}
+            onChange={onChange}>
+            <Context.Provider value={context}>
+              <View
+                style={[
+                  a.absolute,
+                  a.inset_0,
+                  t.atoms.bg,
+                  {
+                    borderTopLeftRadius: 40,
+                    borderTopRightRadius: 40,
+                    height: Dimensions.get('window').height * 2,
+                  },
+                ]}
+              />
+              {children}
+            </Context.Provider>
+          </BottomSheet>
+        </View>
       </Portal>
     )
   )
diff --git a/src/components/Dialog/index.web.tsx b/src/components/Dialog/index.web.tsx
index 32163e73..3a7f7334 100644
--- a/src/components/Dialog/index.web.tsx
+++ b/src/components/Dialog/index.web.tsx
@@ -12,7 +12,7 @@ import {DialogOuterProps, DialogInnerProps} from '#/components/Dialog/types'
 import {Context} from '#/components/Dialog/context'
 import {Button, ButtonIcon} from '#/components/Button'
 import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
-import {useDialogStateContext} from '#/state/dialogs'
+import {useDialogStateControlContext} from '#/state/dialogs'
 
 export {useDialogControl, useDialogContext} from '#/components/Dialog/context'
 export * from '#/components/Dialog/types'
@@ -30,21 +30,21 @@ export function Outer({
   const {gtMobile} = useBreakpoints()
   const [isOpen, setIsOpen] = React.useState(false)
   const [isVisible, setIsVisible] = React.useState(true)
-  const {openDialogs} = useDialogStateContext()
+  const {setDialogIsOpen} = useDialogStateControlContext()
 
   const open = React.useCallback(() => {
     setIsOpen(true)
-    openDialogs.current.add(control.id)
-  }, [setIsOpen, openDialogs, control.id])
+    setDialogIsOpen(control.id, true)
+  }, [setIsOpen, setDialogIsOpen, control.id])
 
   const close = React.useCallback(async () => {
     setIsVisible(false)
     await new Promise(resolve => setTimeout(resolve, 150))
     setIsOpen(false)
     setIsVisible(true)
-    openDialogs.current.delete(control.id)
+    setDialogIsOpen(control.id, false)
     onClose?.()
-  }, [onClose, setIsOpen, openDialogs, control.id])
+  }, [onClose, setIsOpen, setDialogIsOpen, control.id])
 
   useImperativeHandle(
     control.ref,
diff --git a/src/state/dialogs/index.tsx b/src/state/dialogs/index.tsx
index 9fc70c17..90aaca4f 100644
--- a/src/state/dialogs/index.tsx
+++ b/src/state/dialogs/index.tsx
@@ -13,20 +13,20 @@ const DialogContext = React.createContext<{
    * The currently open dialogs, referenced by their IDs, generated from
    * `useId`.
    */
-  openDialogs: React.MutableRefObject<Set<string>>
+  openDialogs: string[]
 }>({
   activeDialogs: {
     current: new Map(),
   },
-  openDialogs: {
-    current: new Set(),
-  },
+  openDialogs: [],
 })
 
 const DialogControlContext = React.createContext<{
   closeAllDialogs(): boolean
+  setDialogIsOpen(id: string, isOpen: boolean): void
 }>({
   closeAllDialogs: () => false,
+  setDialogIsOpen: () => {},
 })
 
 export function useDialogStateContext() {
@@ -41,15 +41,31 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
   const activeDialogs = React.useRef<
     Map<string, React.MutableRefObject<DialogControlRefProps>>
   >(new Map())
-  const openDialogs = React.useRef<Set<string>>(new Set())
+  const [openDialogs, setOpenDialogs] = React.useState<string[]>([])
 
   const closeAllDialogs = React.useCallback(() => {
     activeDialogs.current.forEach(dialog => dialog.current.close())
-    return openDialogs.current.size > 0
-  }, [])
+    return openDialogs.length > 0
+  }, [openDialogs])
 
-  const context = React.useMemo(() => ({activeDialogs, openDialogs}), [])
-  const controls = React.useMemo(() => ({closeAllDialogs}), [closeAllDialogs])
+  const setDialogIsOpen = React.useCallback(
+    (id: string, isOpen: boolean) => {
+      setOpenDialogs(prev => {
+        const filtered = prev.filter(dialogId => dialogId !== id) as string[]
+        return isOpen ? [...filtered, id] : filtered
+      })
+    },
+    [setOpenDialogs],
+  )
+
+  const context = React.useMemo(
+    () => ({activeDialogs, openDialogs}),
+    [openDialogs],
+  )
+  const controls = React.useMemo(
+    () => ({closeAllDialogs, setDialogIsOpen}),
+    [closeAllDialogs, setDialogIsOpen],
+  )
 
   return (
     <DialogContext.Provider value={context}>
diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx
index d895d885..bdba7917 100644
--- a/src/view/shell/index.tsx
+++ b/src/view/shell/index.tsx
@@ -30,6 +30,7 @@ import {useCloseAnyActiveElement} from '#/state/util'
 import * as notifications from 'lib/notifications/notifications'
 import {Outlet as PortalOutlet} from '#/components/Portal'
 import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
+import {useDialogStateContext} from '#/state/dialogs'
 
 function ShellInner() {
   const isDrawerOpen = useIsDrawerOpen()
@@ -55,6 +56,7 @@ function ShellInner() {
   const closeAnyActiveElement = useCloseAnyActiveElement()
   // start undefined
   const currentAccountDid = React.useRef<string | undefined>(undefined)
+  const {openDialogs} = useDialogStateContext()
 
   React.useEffect(() => {
     let listener = {remove() {}}
@@ -78,9 +80,21 @@ function ShellInner() {
     }
   }, [currentAccount])
 
+  /**
+   * The counterpart to `accessibilityViewIsModal` for Android. This property
+   * applies to the parent of all non-modal views, and prevents TalkBack from
+   * navigating within content beneath an open dialog.
+   *
+   * @see https://reactnative.dev/docs/accessibility#importantforaccessibility-android
+   */
+  const importantForAccessibility =
+    openDialogs.length > 0 ? 'no-hide-descendants' : undefined
+
   return (
     <>
-      <View style={containerPadding}>
+      <View
+        style={containerPadding}
+        importantForAccessibility={importantForAccessibility}>
         <ErrorBoundary>
           <Drawer
             renderDrawerContent={renderDrawerContent}

From 20b88f43e26e1fba5ede9632a1672047640289fb Mon Sep 17 00:00:00 2001
From: dan <dan.abramov@gmail.com>
Date: Tue, 5 Mar 2024 16:45:08 +0000
Subject: [PATCH 3/8] Hide feeds topbar on scroll (#3108)

---
 src/view/com/home/HomeHeaderLayout.web.tsx | 19 +++++++++++++++++--
 1 file changed, 17 insertions(+), 2 deletions(-)

diff --git a/src/view/com/home/HomeHeaderLayout.web.tsx b/src/view/com/home/HomeHeaderLayout.web.tsx
index 6145081a..9818b56f 100644
--- a/src/view/com/home/HomeHeaderLayout.web.tsx
+++ b/src/view/com/home/HomeHeaderLayout.web.tsx
@@ -1,5 +1,6 @@
 import React from 'react'
 import {StyleSheet, View} from 'react-native'
+import Animated from 'react-native-reanimated'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {HomeHeaderLayoutMobile} from './HomeHeaderLayoutMobile'
@@ -12,6 +13,8 @@ import {
 import {useLingui} from '@lingui/react'
 import {msg} from '@lingui/macro'
 import {CogIcon} from '#/lib/icons'
+import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
+import {useShellLayout} from '#/state/shell/shell-layout'
 
 export function HomeHeaderLayout(props: {
   children: React.ReactNode
@@ -33,6 +36,8 @@ function HomeHeaderLayoutDesktopAndTablet({
   tabBarAnchor: JSX.Element | null | undefined
 }) {
   const pal = usePalette('default')
+  const {headerMinimalShellTransform} = useMinimalShellMode()
+  const {headerHeight} = useShellLayout()
   const {_} = useLingui()
 
   return (
@@ -60,9 +65,19 @@ function HomeHeaderLayoutDesktopAndTablet({
         </Link>
       </View>
       {tabBarAnchor}
-      <View style={[pal.view, pal.border, styles.bar, styles.tabBar]}>
+      <Animated.View
+        onLayout={e => {
+          headerHeight.value = e.nativeEvent.layout.height
+        }}
+        style={[
+          pal.view,
+          pal.border,
+          styles.bar,
+          styles.tabBar,
+          headerMinimalShellTransform,
+        ]}>
         {children}
-      </View>
+      </Animated.View>
     </>
   )
 }

From 57854e6fc28affbe28315e3d8f2276ab85272cbb Mon Sep 17 00:00:00 2001
From: Hailey <me@haileyok.com>
Date: Tue, 5 Mar 2024 16:55:20 -0800
Subject: [PATCH 4/8] use `onClose` instead of `onChange` for close callback
 (#3116)

---
 src/components/Dialog/index.tsx | 38 ++++++++++++++-------------------
 1 file changed, 16 insertions(+), 22 deletions(-)

diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx
index fa375b0f..f0e7b7e8 100644
--- a/src/components/Dialog/index.tsx
+++ b/src/components/Dialog/index.tsx
@@ -119,27 +119,21 @@ export function Outer({
     [open, close],
   )
 
-  const onChange = React.useCallback(
-    (index: number) => {
-      if (index === -1) {
-        Keyboard.dismiss()
-        try {
-          closeCallback.current?.()
-        } catch (e: any) {
-          logger.error(`Dialog closeCallback failed`, {
-            message: e.message,
-          })
-        } finally {
-          closeCallback.current = undefined
-        }
-
-        setDialogIsOpen(control.id, false)
-        onClose?.()
-        setOpenIndex(-1)
-      }
-    },
-    [onClose, setOpenIndex, setDialogIsOpen, control.id],
-  )
+  const onCloseInner = React.useCallback(() => {
+    Keyboard.dismiss()
+    try {
+      closeCallback.current?.()
+    } catch (e: any) {
+      logger.error(`Dialog closeCallback failed`, {
+        message: e.message,
+      })
+    } finally {
+      closeCallback.current = undefined
+    }
+    setDialogIsOpen(control.id, false)
+    onClose?.()
+    setOpenIndex(-1)
+  }, [control.id, onClose, setDialogIsOpen])
 
   const context = React.useMemo(() => ({close}), [close])
 
@@ -167,7 +161,7 @@ export function Outer({
             backdropComponent={Backdrop}
             handleIndicatorStyle={{backgroundColor: t.palette.primary_500}}
             handleStyle={{display: 'none'}}
-            onChange={onChange}>
+            onClose={onCloseInner}>
             <Context.Provider value={context}>
               <View
                 style={[

From e721f84a2cd64bd98f54049bd17925ddf1b194c8 Mon Sep 17 00:00:00 2001
From: Hailey <me@haileyok.com>
Date: Tue, 5 Mar 2024 18:55:23 -0800
Subject: [PATCH 5/8] add `newArchEnabled: false` flag to app.config.js (#3115)

---
 app.config.js | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/app.config.js b/app.config.js
index a18addcc..9691cab2 100644
--- a/app.config.js
+++ b/app.config.js
@@ -121,12 +121,14 @@ module.exports = function (config) {
           {
             ios: {
               deploymentTarget: '13.4',
+              newArchEnabled: false,
             },
             android: {
               compileSdkVersion: 34,
               targetSdkVersion: 34,
               buildToolsVersion: '34.0.0',
               kotlinVersion: '1.8.0',
+              newArchEnabled: false,
             },
           },
         ],

From 317e0cda7a30d21f35229c096b6ef3284819d19a Mon Sep 17 00:00:00 2001
From: Eric Bailey <git@esb.lol>
Date: Tue, 5 Mar 2024 21:15:42 -0600
Subject: [PATCH 6/8] Add `Menu` component (#3097)

* Add POC menu abstraction

* Better platform handling

* Remove ignore

* Add some menu items

* Add controlled dropdown

* Pass through a11y props

* Ignore uninitialized context

* Tweaks

* Usability improvements

* Rename handlers to props

* Add radix comment

* Ignore known type

* Remove todo

* Move storybook item

* Improve Group matching

* Adjust theming
---
 bskyweb/templates/base.html          |   1 +
 package.json                         |   2 +
 src/components/Dialog/context.ts     |  27 +--
 src/components/Dialog/types.ts       |   1 +
 src/components/Menu/context.tsx      |   8 +
 src/components/Menu/index.tsx        | 190 +++++++++++++++++++++
 src/components/Menu/index.web.tsx    | 247 +++++++++++++++++++++++++++
 src/components/Menu/types.ts         |  72 ++++++++
 src/view/screens/Storybook/Menus.tsx |  79 +++++++++
 src/view/screens/Storybook/index.tsx |   2 +
 web/index.html                       |   1 +
 yarn.lock                            |  93 ++++++++++
 12 files changed, 712 insertions(+), 11 deletions(-)
 create mode 100644 src/components/Menu/context.tsx
 create mode 100644 src/components/Menu/index.tsx
 create mode 100644 src/components/Menu/index.web.tsx
 create mode 100644 src/components/Menu/types.ts
 create mode 100644 src/view/screens/Storybook/Menus.tsx

diff --git a/bskyweb/templates/base.html b/bskyweb/templates/base.html
index 413d7ff6..55447552 100644
--- a/bskyweb/templates/base.html
+++ b/bskyweb/templates/base.html
@@ -213,6 +213,7 @@
     }
 
     /* NativeDropdown component */
+    .radix-dropdown-item:focus,
     .nativeDropdown-item:focus {
       outline: none;
     }
diff --git a/package.json b/package.json
index d694d26c..cd8cc765 100644
--- a/package.json
+++ b/package.json
@@ -58,6 +58,7 @@
     "@lingui/react": "^4.5.0",
     "@mattermost/react-native-paste-input": "^0.6.4",
     "@miblanchard/react-native-slider": "^2.3.1",
+    "@radix-ui/react-dropdown-menu": "^2.0.6",
     "@react-native-async-storage/async-storage": "1.21.0",
     "@react-native-camera-roll/camera-roll": "^5.2.2",
     "@react-native-clipboard/clipboard": "^1.10.0",
@@ -148,6 +149,7 @@
     "react-avatar-editor": "^13.0.0",
     "react-circular-progressbar": "^2.1.0",
     "react-dom": "^18.2.0",
+    "react-keyed-flatten-children": "^3.0.0",
     "react-native": "0.73.2",
     "react-native-appstate-hook": "^1.0.6",
     "react-native-drawer-layout": "^4.0.0-alpha.3",
diff --git a/src/components/Dialog/context.ts b/src/components/Dialog/context.ts
index eb717d8e..9b571e8e 100644
--- a/src/components/Dialog/context.ts
+++ b/src/components/Dialog/context.ts
@@ -21,7 +21,8 @@ export function useDialogControl(): DialogOuterProps['control'] {
     open: () => {},
     close: () => {},
   })
-  const {activeDialogs} = useDialogStateContext()
+  const {activeDialogs, openDialogs} = useDialogStateContext()
+  const isOpen = openDialogs.includes(id)
 
   React.useEffect(() => {
     activeDialogs.current.set(id, control)
@@ -31,14 +32,18 @@ export function useDialogControl(): DialogOuterProps['control'] {
     }
   }, [id, activeDialogs])
 
-  return {
-    id,
-    ref: control,
-    open: () => {
-      control.current.open()
-    },
-    close: cb => {
-      control.current.close(cb)
-    },
-  }
+  return React.useMemo<DialogOuterProps['control']>(
+    () => ({
+      id,
+      ref: control,
+      isOpen,
+      open: () => {
+        control.current.open()
+      },
+      close: cb => {
+        control.current.close(cb)
+      },
+    }),
+    [id, control, isOpen],
+  )
 }
diff --git a/src/components/Dialog/types.ts b/src/components/Dialog/types.ts
index 78dfedf5..fa9398fe 100644
--- a/src/components/Dialog/types.ts
+++ b/src/components/Dialog/types.ts
@@ -22,6 +22,7 @@ export type DialogControlRefProps = {
 export type DialogControlProps = DialogControlRefProps & {
   id: string
   ref: React.RefObject<DialogControlRefProps>
+  isOpen: boolean
 }
 
 export type DialogContextProps = {
diff --git a/src/components/Menu/context.tsx b/src/components/Menu/context.tsx
new file mode 100644
index 00000000..9fc91f68
--- /dev/null
+++ b/src/components/Menu/context.tsx
@@ -0,0 +1,8 @@
+import React from 'react'
+
+import type {ContextType} from '#/components/Menu/types'
+
+export const Context = React.createContext<ContextType>({
+  // @ts-ignore
+  control: null,
+})
diff --git a/src/components/Menu/index.tsx b/src/components/Menu/index.tsx
new file mode 100644
index 00000000..ee96a566
--- /dev/null
+++ b/src/components/Menu/index.tsx
@@ -0,0 +1,190 @@
+import React from 'react'
+import {View, Pressable} from 'react-native'
+import flattenReactChildren from 'react-keyed-flatten-children'
+
+import {atoms as a, useTheme} from '#/alf'
+import * as Dialog from '#/components/Dialog'
+import {useInteractionState} from '#/components/hooks/useInteractionState'
+import {Text} from '#/components/Typography'
+
+import {Context} from '#/components/Menu/context'
+import {
+  ContextType,
+  TriggerProps,
+  ItemProps,
+  GroupProps,
+  ItemTextProps,
+  ItemIconProps,
+} from '#/components/Menu/types'
+
+export {useDialogControl as useMenuControl} from '#/components/Dialog'
+
+export function useMemoControlContext() {
+  return React.useContext(Context)
+}
+
+export function Root({
+  children,
+  control,
+}: React.PropsWithChildren<{
+  control?: Dialog.DialogOuterProps['control']
+}>) {
+  const defaultControl = Dialog.useDialogControl()
+  const context = React.useMemo<ContextType>(
+    () => ({
+      control: control || defaultControl,
+    }),
+    [control, defaultControl],
+  )
+
+  return <Context.Provider value={context}>{children}</Context.Provider>
+}
+
+export function Trigger({children, label}: TriggerProps) {
+  const {control} = React.useContext(Context)
+  const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
+  const {
+    state: pressed,
+    onIn: onPressIn,
+    onOut: onPressOut,
+  } = useInteractionState()
+
+  return children({
+    isNative: true,
+    control,
+    state: {
+      hovered: false,
+      focused,
+      pressed,
+    },
+    props: {
+      onPress: control.open,
+      onFocus,
+      onBlur,
+      onPressIn,
+      onPressOut,
+      accessibilityLabel: label,
+    },
+  })
+}
+
+export function Outer({children}: React.PropsWithChildren<{}>) {
+  const context = React.useContext(Context)
+
+  return (
+    <Dialog.Outer control={context.control}>
+      <Dialog.Handle />
+
+      {/* Re-wrap with context since Dialogs are portal-ed to root */}
+      <Context.Provider value={context}>
+        <Dialog.ScrollableInner label="Menu TODO">
+          <View style={[a.gap_lg]}>{children}</View>
+          <View style={{height: a.gap_lg.gap}} />
+        </Dialog.ScrollableInner>
+      </Context.Provider>
+    </Dialog.Outer>
+  )
+}
+
+export function Item({children, label, style, onPress, ...rest}: ItemProps) {
+  const t = useTheme()
+  const {control} = React.useContext(Context)
+  const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
+  const {
+    state: pressed,
+    onIn: onPressIn,
+    onOut: onPressOut,
+  } = useInteractionState()
+
+  return (
+    <Pressable
+      {...rest}
+      accessibilityHint=""
+      accessibilityLabel={label}
+      onPress={e => {
+        onPress(e)
+
+        if (!e.defaultPrevented) {
+          control?.close()
+        }
+      }}
+      onFocus={onFocus}
+      onBlur={onBlur}
+      onPressIn={onPressIn}
+      onPressOut={onPressOut}
+      style={[
+        a.flex_row,
+        a.align_center,
+        a.gap_sm,
+        a.px_md,
+        a.rounded_md,
+        a.border,
+        t.atoms.bg_contrast_25,
+        t.atoms.border_contrast_low,
+        {minHeight: 44, paddingVertical: 10},
+        style,
+        (focused || pressed) && [t.atoms.bg_contrast_50],
+      ]}>
+      {children}
+    </Pressable>
+  )
+}
+
+export function ItemText({children, style}: ItemTextProps) {
+  const t = useTheme()
+  return (
+    <Text
+      numberOfLines={1}
+      ellipsizeMode="middle"
+      style={[
+        a.flex_1,
+        a.text_md,
+        a.font_bold,
+        t.atoms.text_contrast_medium,
+        {paddingTop: 3},
+        style,
+      ]}>
+      {children}
+    </Text>
+  )
+}
+
+export function ItemIcon({icon: Comp}: ItemIconProps) {
+  const t = useTheme()
+  return <Comp size="lg" fill={t.atoms.text_contrast_medium.color} />
+}
+
+export function Group({children, style}: GroupProps) {
+  const t = useTheme()
+  return (
+    <View
+      style={[
+        a.rounded_md,
+        a.overflow_hidden,
+        a.border,
+        t.atoms.border_contrast_low,
+        style,
+      ]}>
+      {flattenReactChildren(children).map((child, i) => {
+        return React.isValidElement(child) && child.type === Item ? (
+          <React.Fragment key={i}>
+            {i > 0 ? (
+              <View style={[a.border_b, t.atoms.border_contrast_low]} />
+            ) : null}
+            {React.cloneElement(child, {
+              // @ts-ignore
+              style: {
+                borderRadius: 0,
+                borderWidth: 0,
+              },
+            })}
+          </React.Fragment>
+        ) : null
+      })}
+    </View>
+  )
+}
+
+export function Divider() {
+  return null
+}
diff --git a/src/components/Menu/index.web.tsx b/src/components/Menu/index.web.tsx
new file mode 100644
index 00000000..ca2e4056
--- /dev/null
+++ b/src/components/Menu/index.web.tsx
@@ -0,0 +1,247 @@
+import React from 'react'
+import {View, Pressable} from 'react-native'
+import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
+
+import * as Dialog from '#/components/Dialog'
+import {useInteractionState} from '#/components/hooks/useInteractionState'
+import {atoms as a, useTheme, flatten, web} from '#/alf'
+import {Text} from '#/components/Typography'
+
+import {
+  ContextType,
+  TriggerProps,
+  ItemProps,
+  GroupProps,
+  ItemTextProps,
+  ItemIconProps,
+} from '#/components/Menu/types'
+import {Context} from '#/components/Menu/context'
+
+export function useMenuControl(): Dialog.DialogControlProps {
+  const id = React.useId()
+  const [isOpen, setIsOpen] = React.useState(false)
+
+  return React.useMemo(
+    () => ({
+      id,
+      ref: {current: null},
+      isOpen,
+      open() {
+        setIsOpen(true)
+      },
+      close() {
+        setIsOpen(false)
+      },
+    }),
+    [id, isOpen, setIsOpen],
+  )
+}
+
+export function useMemoControlContext() {
+  return React.useContext(Context)
+}
+
+export function Root({
+  children,
+  control,
+}: React.PropsWithChildren<{
+  control?: Dialog.DialogOuterProps['control']
+}>) {
+  const defaultControl = useMenuControl()
+  const context = React.useMemo<ContextType>(
+    () => ({
+      control: control || defaultControl,
+    }),
+    [control, defaultControl],
+  )
+  const onOpenChange = React.useCallback(
+    (open: boolean) => {
+      if (context.control.isOpen && !open) {
+        context.control.close()
+      } else if (!context.control.isOpen && open) {
+        context.control.open()
+      }
+    },
+    [context.control],
+  )
+
+  return (
+    <Context.Provider value={context}>
+      <DropdownMenu.Root
+        open={context.control.isOpen}
+        onOpenChange={onOpenChange}>
+        {children}
+      </DropdownMenu.Root>
+    </Context.Provider>
+  )
+}
+
+export function Trigger({children, label, style}: TriggerProps) {
+  const {control} = React.useContext(Context)
+  const {
+    state: hovered,
+    onIn: onMouseEnter,
+    onOut: onMouseLeave,
+  } = useInteractionState()
+  const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
+
+  return (
+    <DropdownMenu.Trigger asChild>
+      <Pressable
+        accessibilityHint=""
+        accessibilityLabel={label}
+        onFocus={onFocus}
+        onBlur={onBlur}
+        style={flatten([style, web({outline: 0})])}
+        onPointerDown={() => {
+          control.open()
+        }}
+        {...web({
+          onMouseEnter,
+          onMouseLeave,
+        })}>
+        {children({
+          isNative: false,
+          control,
+          state: {
+            hovered,
+            focused,
+            pressed: false,
+          },
+          props: {},
+        })}
+      </Pressable>
+    </DropdownMenu.Trigger>
+  )
+}
+
+export function Outer({children}: React.PropsWithChildren<{}>) {
+  const t = useTheme()
+
+  return (
+    <DropdownMenu.Portal>
+      <DropdownMenu.Content sideOffset={5} loop aria-label="Test">
+        <View
+          style={[
+            a.rounded_sm,
+            a.p_xs,
+            t.name === 'light' ? t.atoms.bg : t.atoms.bg_contrast_25,
+            t.atoms.shadow_md,
+          ]}>
+          {children}
+        </View>
+
+        <DropdownMenu.Arrow
+          className="DropdownMenuArrow"
+          fill={
+            (t.name === 'light' ? t.atoms.bg : t.atoms.bg_contrast_25)
+              .backgroundColor
+          }
+        />
+      </DropdownMenu.Content>
+    </DropdownMenu.Portal>
+  )
+}
+
+export function Item({children, label, onPress, ...rest}: ItemProps) {
+  const t = useTheme()
+  const {control} = React.useContext(Context)
+  const {
+    state: hovered,
+    onIn: onMouseEnter,
+    onOut: onMouseLeave,
+  } = useInteractionState()
+  const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
+
+  return (
+    <DropdownMenu.Item asChild>
+      <Pressable
+        {...rest}
+        className="radix-dropdown-item"
+        accessibilityHint=""
+        accessibilityLabel={label}
+        onPress={e => {
+          onPress(e)
+
+          /**
+           * Ported forward from Radix
+           * @see https://www.radix-ui.com/primitives/docs/components/dropdown-menu#item
+           */
+          if (!e.defaultPrevented) {
+            control.close()
+          }
+        }}
+        onFocus={onFocus}
+        onBlur={onBlur}
+        // need `flatten` here for Radix compat
+        style={flatten([
+          a.flex_row,
+          a.align_center,
+          a.gap_sm,
+          a.py_sm,
+          a.rounded_xs,
+          {minHeight: 32, paddingHorizontal: 10},
+          web({outline: 0}),
+          (hovered || focused) && [
+            web({outline: '0 !important'}),
+            t.name === 'light'
+              ? t.atoms.bg_contrast_25
+              : t.atoms.bg_contrast_50,
+          ],
+        ])}
+        {...web({
+          onMouseEnter,
+          onMouseLeave,
+        })}>
+        {children}
+      </Pressable>
+    </DropdownMenu.Item>
+  )
+}
+
+export function ItemText({children, style}: ItemTextProps) {
+  const t = useTheme()
+  return (
+    <Text style={[a.flex_1, a.font_bold, t.atoms.text_contrast_high, style]}>
+      {children}
+    </Text>
+  )
+}
+
+export function ItemIcon({icon: Comp, position = 'left'}: ItemIconProps) {
+  const t = useTheme()
+  return (
+    <Comp
+      size="md"
+      fill={t.atoms.text_contrast_medium.color}
+      style={[
+        position === 'left' && {
+          marginLeft: -2,
+        },
+        position === 'right' && {
+          marginRight: -2,
+          marginLeft: 12,
+        },
+      ]}
+    />
+  )
+}
+
+export function Group({children}: GroupProps) {
+  return children
+}
+
+export function Divider() {
+  const t = useTheme()
+  return (
+    <DropdownMenu.Separator
+      style={flatten([
+        a.my_xs,
+        t.atoms.bg_contrast_100,
+        {
+          height: 1,
+        },
+      ])}
+    />
+  )
+}
diff --git a/src/components/Menu/types.ts b/src/components/Menu/types.ts
new file mode 100644
index 00000000..2f52e639
--- /dev/null
+++ b/src/components/Menu/types.ts
@@ -0,0 +1,72 @@
+import React from 'react'
+import {GestureResponderEvent, PressableProps} from 'react-native'
+
+import {Props as SVGIconProps} from '#/components/icons/common'
+import * as Dialog from '#/components/Dialog'
+import {TextStyleProp, ViewStyleProp} from '#/alf'
+
+export type ContextType = {
+  control: Dialog.DialogOuterProps['control']
+}
+
+export type TriggerProps = ViewStyleProp & {
+  children(props: TriggerChildProps): React.ReactNode
+  label: string
+}
+export type TriggerChildProps =
+  | {
+      isNative: true
+      control: Dialog.DialogOuterProps['control']
+      state: {
+        /**
+         * Web only, `false` on native
+         */
+        hovered: false
+        focused: boolean
+        pressed: boolean
+      }
+      /**
+       * We don't necessarily know what these will be spread on to, so we
+       * should add props one-by-one.
+       *
+       * On web, these properties are applied to a parent `Pressable`, so this
+       * object is empty.
+       */
+      props: {
+        onPress: () => void
+        onFocus: () => void
+        onBlur: () => void
+        onPressIn: () => void
+        onPressOut: () => void
+        accessibilityLabel: string
+      }
+    }
+  | {
+      isNative: false
+      control: Dialog.DialogOuterProps['control']
+      state: {
+        hovered: boolean
+        focused: boolean
+        /**
+         * Native only, `false` on web
+         */
+        pressed: false
+      }
+      props: {}
+    }
+
+export type ItemProps = React.PropsWithChildren<
+  Omit<PressableProps, 'style'> &
+    ViewStyleProp & {
+      label: string
+      onPress: (e: GestureResponderEvent) => void
+    }
+>
+
+export type ItemTextProps = React.PropsWithChildren<TextStyleProp & {}>
+export type ItemIconProps = React.PropsWithChildren<{
+  icon: React.ComponentType<SVGIconProps>
+  position?: 'left' | 'right'
+}>
+
+export type GroupProps = React.PropsWithChildren<ViewStyleProp & {}>
diff --git a/src/view/screens/Storybook/Menus.tsx b/src/view/screens/Storybook/Menus.tsx
new file mode 100644
index 00000000..082fb2b6
--- /dev/null
+++ b/src/view/screens/Storybook/Menus.tsx
@@ -0,0 +1,79 @@
+import React from 'react'
+import {View} from 'react-native'
+
+import {atoms as a, useTheme} from '#/alf'
+import {Text} from '#/components/Typography'
+import * as Menu from '#/components/Menu'
+import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
+// import {useDialogStateControlContext} from '#/state/dialogs'
+
+export function Menus() {
+  const t = useTheme()
+  const menuControl = Menu.useMenuControl()
+  // const {closeAllDialogs} = useDialogStateControlContext()
+
+  return (
+    <View style={[a.gap_md]}>
+      <View style={[a.flex_row, a.align_start]}>
+        <Menu.Root control={menuControl}>
+          <Menu.Trigger label="Open basic menu" style={[a.flex_1]}>
+            {({state, props}) => {
+              return (
+                <Text
+                  {...props}
+                  style={[
+                    a.py_sm,
+                    a.px_md,
+                    a.rounded_sm,
+                    t.atoms.bg_contrast_50,
+                    (state.hovered || state.focused || state.pressed) && [
+                      t.atoms.bg_contrast_200,
+                    ],
+                  ]}>
+                  Open
+                </Text>
+              )
+            }}
+          </Menu.Trigger>
+
+          <Menu.Outer>
+            <Menu.Group>
+              <Menu.Item label="Click me" onPress={() => {}}>
+                <Menu.ItemIcon icon={Search} />
+                <Menu.ItemText>Click me</Menu.ItemText>
+              </Menu.Item>
+
+              <Menu.Item
+                label="Another item"
+                onPress={() => menuControl.close()}>
+                <Menu.ItemText>Another item</Menu.ItemText>
+              </Menu.Item>
+            </Menu.Group>
+
+            <Menu.Divider />
+
+            <Menu.Group>
+              <Menu.Item label="Click me" onPress={() => {}}>
+                <Menu.ItemIcon icon={Search} />
+                <Menu.ItemText>Click me</Menu.ItemText>
+              </Menu.Item>
+
+              <Menu.Item
+                label="Another item"
+                onPress={() => menuControl.close()}>
+                <Menu.ItemText>Another item</Menu.ItemText>
+              </Menu.Item>
+            </Menu.Group>
+
+            <Menu.Divider />
+
+            <Menu.Item label="Click me" onPress={() => {}}>
+              <Menu.ItemIcon icon={Search} />
+              <Menu.ItemText>Click me</Menu.ItemText>
+            </Menu.Item>
+          </Menu.Outer>
+        </Menu.Root>
+      </View>
+    </View>
+  )
+}
diff --git a/src/view/screens/Storybook/index.tsx b/src/view/screens/Storybook/index.tsx
index 40929555..e43d756d 100644
--- a/src/view/screens/Storybook/index.tsx
+++ b/src/view/screens/Storybook/index.tsx
@@ -16,6 +16,7 @@ import {Dialogs} from './Dialogs'
 import {Breakpoints} from './Breakpoints'
 import {Shadows} from './Shadows'
 import {Icons} from './Icons'
+import {Menus} from './Menus'
 
 export function Storybook() {
   const t = useTheme()
@@ -84,6 +85,7 @@ export function Storybook() {
           <Links />
           <Forms />
           <Dialogs />
+          <Menus />
           <Breakpoints />
         </View>
       </CenteredView>
diff --git a/web/index.html b/web/index.html
index 8f2275a7..b6e01ba4 100644
--- a/web/index.html
+++ b/web/index.html
@@ -217,6 +217,7 @@
       }
 
       /* NativeDropdown component */
+      .radix-dropdown-item:focus,
       .nativeDropdown-item:focus {
         outline: none;
       }
diff --git a/yarn.lock b/yarn.lock
index ceb712ce..c3ddb8ee 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4467,6 +4467,18 @@
     "@radix-ui/react-use-callback-ref" "1.0.1"
     "@radix-ui/react-use-escape-keydown" "1.0.3"
 
+"@radix-ui/react-dismissable-layer@1.0.5":
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.5.tgz#3f98425b82b9068dfbab5db5fff3df6ebf48b9d4"
+  integrity sha512-aJeDjQhywg9LBu2t/At58hCvr7pEm0o2Ke1x33B+MhjNmmZ17sy4KImo0KPLgsnc/zN7GPdce8Cnn0SWvwZO7g==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+    "@radix-ui/primitive" "1.0.1"
+    "@radix-ui/react-compose-refs" "1.0.1"
+    "@radix-ui/react-primitive" "1.0.3"
+    "@radix-ui/react-use-callback-ref" "1.0.1"
+    "@radix-ui/react-use-escape-keydown" "1.0.3"
+
 "@radix-ui/react-dropdown-menu@^2.0.1":
   version "2.0.5"
   resolved "https://registry.yarnpkg.com/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.0.5.tgz#19bf4de8ffa348b4eb6a86842f14eff93d741170"
@@ -4481,6 +4493,20 @@
     "@radix-ui/react-primitive" "1.0.3"
     "@radix-ui/react-use-controllable-state" "1.0.1"
 
+"@radix-ui/react-dropdown-menu@^2.0.6":
+  version "2.0.6"
+  resolved "https://registry.yarnpkg.com/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.0.6.tgz#cdf13c956c5e263afe4e5f3587b3071a25755b63"
+  integrity sha512-i6TuFOoWmLWq+M/eCLGd/bQ2HfAX1RJgvrBQ6AQLmzfvsLdefxbWu8G9zczcPFfcSPehz9GcpF6K9QYreFV8hA==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+    "@radix-ui/primitive" "1.0.1"
+    "@radix-ui/react-compose-refs" "1.0.1"
+    "@radix-ui/react-context" "1.0.1"
+    "@radix-ui/react-id" "1.0.1"
+    "@radix-ui/react-menu" "2.0.6"
+    "@radix-ui/react-primitive" "1.0.3"
+    "@radix-ui/react-use-controllable-state" "1.0.1"
+
 "@radix-ui/react-focus-guards@1.0.1":
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.0.1.tgz#1ea7e32092216b946397866199d892f71f7f98ad"
@@ -4498,6 +4524,16 @@
     "@radix-ui/react-primitive" "1.0.3"
     "@radix-ui/react-use-callback-ref" "1.0.1"
 
+"@radix-ui/react-focus-scope@1.0.4":
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.0.4.tgz#2ac45fce8c5bb33eb18419cdc1905ef4f1906525"
+  integrity sha512-sL04Mgvf+FmyvZeYfNu1EPAaaxD+aw7cYeIB9L9Fvq8+urhltTRaEo5ysKOpHuKPclsZcSUMKlN05x4u+CINpA==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+    "@radix-ui/react-compose-refs" "1.0.1"
+    "@radix-ui/react-primitive" "1.0.3"
+    "@radix-ui/react-use-callback-ref" "1.0.1"
+
 "@radix-ui/react-id@1.0.1":
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/@radix-ui/react-id/-/react-id-1.0.1.tgz#73cdc181f650e4df24f0b6a5b7aa426b912c88c0"
@@ -4531,6 +4567,31 @@
     aria-hidden "^1.1.1"
     react-remove-scroll "2.5.5"
 
+"@radix-ui/react-menu@2.0.6":
+  version "2.0.6"
+  resolved "https://registry.yarnpkg.com/@radix-ui/react-menu/-/react-menu-2.0.6.tgz#2c9e093c1a5d5daa87304b2a2f884e32288ae79e"
+  integrity sha512-BVkFLS+bUC8HcImkRKPSiVumA1VPOOEC5WBMiT+QAVsPzW1FJzI9KnqgGxVDPBcql5xXrHkD3JOVoXWEXD8SYA==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+    "@radix-ui/primitive" "1.0.1"
+    "@radix-ui/react-collection" "1.0.3"
+    "@radix-ui/react-compose-refs" "1.0.1"
+    "@radix-ui/react-context" "1.0.1"
+    "@radix-ui/react-direction" "1.0.1"
+    "@radix-ui/react-dismissable-layer" "1.0.5"
+    "@radix-ui/react-focus-guards" "1.0.1"
+    "@radix-ui/react-focus-scope" "1.0.4"
+    "@radix-ui/react-id" "1.0.1"
+    "@radix-ui/react-popper" "1.1.3"
+    "@radix-ui/react-portal" "1.0.4"
+    "@radix-ui/react-presence" "1.0.1"
+    "@radix-ui/react-primitive" "1.0.3"
+    "@radix-ui/react-roving-focus" "1.0.4"
+    "@radix-ui/react-slot" "1.0.2"
+    "@radix-ui/react-use-callback-ref" "1.0.1"
+    aria-hidden "^1.1.1"
+    react-remove-scroll "2.5.5"
+
 "@radix-ui/react-popper@1.1.2":
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/@radix-ui/react-popper/-/react-popper-1.1.2.tgz#4c0b96fcd188dc1f334e02dba2d538973ad842e9"
@@ -4548,6 +4609,23 @@
     "@radix-ui/react-use-size" "1.0.1"
     "@radix-ui/rect" "1.0.1"
 
+"@radix-ui/react-popper@1.1.3":
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/@radix-ui/react-popper/-/react-popper-1.1.3.tgz#24c03f527e7ac348fabf18c89795d85d21b00b42"
+  integrity sha512-cKpopj/5RHZWjrbF2846jBNacjQVwkP068DfmgrNJXpvVWrOvlAmE9xSiy5OqeE+Gi8D9fP+oDhUnPqNMY8/5w==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+    "@floating-ui/react-dom" "^2.0.0"
+    "@radix-ui/react-arrow" "1.0.3"
+    "@radix-ui/react-compose-refs" "1.0.1"
+    "@radix-ui/react-context" "1.0.1"
+    "@radix-ui/react-primitive" "1.0.3"
+    "@radix-ui/react-use-callback-ref" "1.0.1"
+    "@radix-ui/react-use-layout-effect" "1.0.1"
+    "@radix-ui/react-use-rect" "1.0.1"
+    "@radix-ui/react-use-size" "1.0.1"
+    "@radix-ui/rect" "1.0.1"
+
 "@radix-ui/react-portal@1.0.3":
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/@radix-ui/react-portal/-/react-portal-1.0.3.tgz#ffb961244c8ed1b46f039e6c215a6c4d9989bda1"
@@ -4556,6 +4634,14 @@
     "@babel/runtime" "^7.13.10"
     "@radix-ui/react-primitive" "1.0.3"
 
+"@radix-ui/react-portal@1.0.4":
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/@radix-ui/react-portal/-/react-portal-1.0.4.tgz#df4bfd353db3b1e84e639e9c63a5f2565fb00e15"
+  integrity sha512-Qki+C/EuGUVCQTOTD5vzJzJuMUlewbzuKyUy+/iHM2uwGiru9gZeBJtHAPKAEkB5KWGi9mP/CHKcY0wt1aW45Q==
+  dependencies:
+    "@babel/runtime" "^7.13.10"
+    "@radix-ui/react-primitive" "1.0.3"
+
 "@radix-ui/react-presence@1.0.1":
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/@radix-ui/react-presence/-/react-presence-1.0.1.tgz#491990ba913b8e2a5db1b06b203cb24b5cdef9ba"
@@ -18372,6 +18458,13 @@ react-is@^17.0.1:
   resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0"
   integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==
 
+react-keyed-flatten-children@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/react-keyed-flatten-children/-/react-keyed-flatten-children-3.0.0.tgz#b6ad0bde437d3ab86c8af3a1902d164be2a29d67"
+  integrity sha512-tSH6gvOyQjt3qtjG+kU9sTypclL1672yjpVufcE3aHNM0FhvjBUQZqsb/awIux4zEuVC3k/DP4p0GdTT/QUt/Q==
+  dependencies:
+    react-is "^18.2.0"
+
 react-native-appstate-hook@^1.0.6:
   version "1.0.6"
   resolved "https://registry.yarnpkg.com/react-native-appstate-hook/-/react-native-appstate-hook-1.0.6.tgz#cbc16e7b89cfaea034cabd999f00e99053cabd06"

From 26fc0cf66d0cde33e8105495785a1ce4248fb9f7 Mon Sep 17 00:00:00 2001
From: Eiichi Yoshikawa <edo@bari-ikutsu.com>
Date: Wed, 6 Mar 2024 14:28:27 +0900
Subject: [PATCH 7/8] Improve splash display on android (#3105)

* Set window background during startup on android

* Set statusbar color to transparent and fine-tune it
---
 app.config.js                                 | 20 +++++++++++++++----
 ...withAndroidStylesWindowBackgroundPlugin.js | 20 +++++++++++++++++++
 src/App.native.tsx                            |  3 +++
 3 files changed, 39 insertions(+), 4 deletions(-)
 create mode 100644 plugins/withAndroidStylesWindowBackgroundPlugin.js

diff --git a/app.config.js b/app.config.js
index 9691cab2..530e07b9 100644
--- a/app.config.js
+++ b/app.config.js
@@ -11,6 +11,17 @@ const DARK_SPLASH_CONFIG = {
   resizeMode: 'cover',
 }
 
+const SPLASH_CONFIG_ANDROID = {
+  backgroundColor: '#0c7cff',
+  image: './assets/splash.png',
+  resizeMode: 'cover',
+}
+const DARK_SPLASH_CONFIG_ANDROID = {
+  backgroundColor: '#0f141b',
+  image: './assets/splash-dark.png',
+  resizeMode: 'cover',
+}
+
 module.exports = function (config) {
   /**
    * App version number. Should be incremented as part of a release cycle.
@@ -70,8 +81,8 @@ module.exports = function (config) {
         },
       },
       androidStatusBar: {
-        barStyle: 'dark-content',
-        backgroundColor: '#ffffff',
+        barStyle: 'light-content',
+        backgroundColor: '#00000000',
       },
       android: {
         icon: './assets/icon.png',
@@ -101,8 +112,8 @@ module.exports = function (config) {
           },
         ],
         splash: {
-          ...SPLASH_CONFIG,
-          dark: DARK_SPLASH_CONFIG,
+          ...SPLASH_CONFIG_ANDROID,
+          dark: DARK_SPLASH_CONFIG_ANDROID,
         },
       },
       web: {
@@ -146,6 +157,7 @@ module.exports = function (config) {
           },
         ],
         './plugins/withAndroidManifestPlugin.js',
+        './plugins/withAndroidStylesWindowBackgroundPlugin.js',
         './plugins/shareExtension/withShareExtensions.js',
       ].filter(Boolean),
       extra: {
diff --git a/plugins/withAndroidStylesWindowBackgroundPlugin.js b/plugins/withAndroidStylesWindowBackgroundPlugin.js
new file mode 100644
index 00000000..427f43df
--- /dev/null
+++ b/plugins/withAndroidStylesWindowBackgroundPlugin.js
@@ -0,0 +1,20 @@
+const {withAndroidStyles, AndroidConfig} = require('@expo/config-plugins')
+
+module.exports = function withAndroidStylesWindowBackgroundPlugin(appConfig) {
+  return withAndroidStyles(appConfig, function (decoratedAppConfig) {
+    try {
+      decoratedAppConfig.modResults = AndroidConfig.Styles.assignStylesValue(
+        decoratedAppConfig.modResults,
+        {
+          add: true,
+          parent: AndroidConfig.Styles.getAppThemeLightNoActionBarGroup(),
+          name: 'android:windowBackground',
+          value: '@drawable/splashscreen',
+        },
+      )
+    } catch (e) {
+      console.error(`withAndroidStylesWindowBackgroundPlugin failed`, e)
+    }
+    return decoratedAppConfig
+  })
+}
diff --git a/src/App.native.tsx b/src/App.native.tsx
index f08a6235..4da3f85f 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -46,6 +46,8 @@ import {Provider as PortalProvider} from '#/components/Portal'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useIntentHandler} from 'lib/hooks/useIntentHandler'
+import {StatusBar} from 'expo-status-bar'
+import {isAndroid} from 'platform/detection'
 
 SplashScreen.preventAutoHideAsync()
 
@@ -69,6 +71,7 @@ function InnerApp() {
 
   return (
     <SafeAreaProvider initialMetrics={initialWindowMetrics}>
+      {isAndroid && <StatusBar />}
       <Alf theme={theme}>
         <Splash isReady={!isInitialLoad}>
           <React.Fragment

From eb298d2e60a0ddf26ebaf8f27373418bbf7769e3 Mon Sep 17 00:00:00 2001
From: dan <dan.abramov@gmail.com>
Date: Wed, 6 Mar 2024 05:55:34 +0000
Subject: [PATCH 8/8] Initial feature gating and A/B testing integration
 (#3122)

* Add statsig dependency

* Add SDK provider

* Move to separate file, add tier and hashing

* Disable local storage for now

* Add initial gate testing fixture

* Fork for web just in case

* More WIP

* wip

* Rm test gate

* Add shim on native

* Clarify
---
 package.json                    |   2 +
 src/App.native.tsx              |  33 ++++---
 src/App.web.tsx                 |  33 ++++---
 src/lib/statsig/statsig.tsx     |  11 +++
 src/lib/statsig/statsig.web.tsx |  51 ++++++++++
 yarn.lock                       | 162 +++++++++++++++++++++++++++++++-
 6 files changed, 259 insertions(+), 33 deletions(-)
 create mode 100644 src/lib/statsig/statsig.tsx
 create mode 100644 src/lib/statsig/statsig.web.tsx

diff --git a/package.json b/package.json
index cd8cc765..59ee3319 100644
--- a/package.json
+++ b/package.json
@@ -179,6 +179,8 @@
     "react-responsive": "^9.0.2",
     "rn-fetch-blob": "^0.12.0",
     "sentry-expo": "~7.0.1",
+    "statsig-react": "^1.36.0",
+    "statsig-react-native-expo": "^4.6.1",
     "tippy.js": "^6.3.7",
     "tlds": "^1.234.0",
     "use-deep-compare": "^1.1.0",
diff --git a/src/App.native.tsx b/src/App.native.tsx
index 4da3f85f..eff8ab09 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -43,6 +43,7 @@ import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unre
 import * as persisted from '#/state/persisted'
 import {Splash} from '#/Splash'
 import {Provider as PortalProvider} from '#/components/Portal'
+import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useIntentHandler} from 'lib/hooks/useIntentHandler'
@@ -77,21 +78,23 @@ function InnerApp() {
           <React.Fragment
             // Resets the entire tree below when it changes:
             key={currentAccount?.did}>
-            <LoggedOutViewProvider>
-              <SelectedFeedProvider>
-                <UnreadNotifsProvider>
-                  <ThemeProvider theme={theme}>
-                    {/* All components should be within this provider */}
-                    <RootSiblingParent>
-                      <GestureHandlerRootView style={s.h100pct}>
-                        <TestCtrls />
-                        <Shell />
-                      </GestureHandlerRootView>
-                    </RootSiblingParent>
-                  </ThemeProvider>
-                </UnreadNotifsProvider>
-              </SelectedFeedProvider>
-            </LoggedOutViewProvider>
+            <StatsigProvider>
+              <LoggedOutViewProvider>
+                <SelectedFeedProvider>
+                  <UnreadNotifsProvider>
+                    <ThemeProvider theme={theme}>
+                      {/* All components should be within this provider */}
+                      <RootSiblingParent>
+                        <GestureHandlerRootView style={s.h100pct}>
+                          <TestCtrls />
+                          <Shell />
+                        </GestureHandlerRootView>
+                      </RootSiblingParent>
+                    </ThemeProvider>
+                  </UnreadNotifsProvider>
+                </SelectedFeedProvider>
+              </LoggedOutViewProvider>
+            </StatsigProvider>
           </React.Fragment>
         </Splash>
       </Alf>
diff --git a/src/App.web.tsx b/src/App.web.tsx
index 6ac32a01..eb2e4259 100644
--- a/src/App.web.tsx
+++ b/src/App.web.tsx
@@ -32,6 +32,7 @@ import {
 import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
 import * as persisted from '#/state/persisted'
 import {Provider as PortalProvider} from '#/components/Portal'
+import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
 import {useIntentHandler} from 'lib/hooks/useIntentHandler'
 
 function InnerApp() {
@@ -54,21 +55,23 @@ function InnerApp() {
       <React.Fragment
         // Resets the entire tree below when it changes:
         key={currentAccount?.did}>
-        <LoggedOutViewProvider>
-          <SelectedFeedProvider>
-            <UnreadNotifsProvider>
-              <ThemeProvider theme={theme}>
-                {/* All components should be within this provider */}
-                <RootSiblingParent>
-                  <SafeAreaProvider>
-                    <Shell />
-                  </SafeAreaProvider>
-                </RootSiblingParent>
-                <ToastContainer />
-              </ThemeProvider>
-            </UnreadNotifsProvider>
-          </SelectedFeedProvider>
-        </LoggedOutViewProvider>
+        <StatsigProvider>
+          <LoggedOutViewProvider>
+            <SelectedFeedProvider>
+              <UnreadNotifsProvider>
+                <ThemeProvider theme={theme}>
+                  {/* All components should be within this provider */}
+                  <RootSiblingParent>
+                    <SafeAreaProvider>
+                      <Shell />
+                    </SafeAreaProvider>
+                  </RootSiblingParent>
+                  <ToastContainer />
+                </ThemeProvider>
+              </UnreadNotifsProvider>
+            </SelectedFeedProvider>
+          </LoggedOutViewProvider>
+        </StatsigProvider>
       </React.Fragment>
     </Alf>
   )
diff --git a/src/lib/statsig/statsig.tsx b/src/lib/statsig/statsig.tsx
new file mode 100644
index 00000000..88a57c3f
--- /dev/null
+++ b/src/lib/statsig/statsig.tsx
@@ -0,0 +1,11 @@
+import React from 'react'
+
+export function useGate(_gateName: string) {
+  // Not enabled for native yet.
+  return false
+}
+
+export function Provider({children}: {children: React.ReactNode}) {
+  // Not enabled for native yet.
+  return children
+}
diff --git a/src/lib/statsig/statsig.web.tsx b/src/lib/statsig/statsig.web.tsx
new file mode 100644
index 00000000..6508131c
--- /dev/null
+++ b/src/lib/statsig/statsig.web.tsx
@@ -0,0 +1,51 @@
+import React from 'react'
+import {StatsigProvider, useGate as useStatsigGate} from 'statsig-react'
+import {useSession} from '../../state/session'
+import {sha256} from 'js-sha256'
+
+const statsigOptions = {
+  environment: {
+    tier: process.env.NODE_ENV === 'development' ? 'development' : 'production',
+  },
+  // Don't block on waiting for network. The fetched config will kick in on next load.
+  // This ensures the UI is always consistent and doesn't update mid-session.
+  // Note this makes cold load (no local storage) and private mode return `false` for all gates.
+  initTimeoutMs: 1,
+}
+
+export function useGate(gateName: string) {
+  const {isLoading, value} = useStatsigGate(gateName)
+  if (isLoading) {
+    // This should not happen because of waitForInitialization={true}.
+    console.error('Did not expected isLoading to ever be true.')
+  }
+  return value
+}
+
+function toStatsigUser(did: string | undefined) {
+  let userID: string | undefined
+  if (did) {
+    userID = sha256(did)
+  }
+  return {userID}
+}
+
+export function Provider({children}: {children: React.ReactNode}) {
+  const {currentAccount} = useSession()
+  const currentStatsigUser = React.useMemo(
+    () => toStatsigUser(currentAccount?.did),
+    [currentAccount?.did],
+  )
+  return (
+    <StatsigProvider
+      sdkKey="client-SXJakO39w9vIhl3D44u8UupyzFl4oZ2qPIkjwcvuPsV"
+      mountKey={currentStatsigUser.userID}
+      user={currentStatsigUser}
+      // This isn't really blocking due to short initTimeoutMs above.
+      // However, it ensures `isLoading` is always `false`.
+      waitForInitialization={true}
+      options={statsigOptions}>
+      {children}
+    </StatsigProvider>
+  )
+}
diff --git a/yarn.lock b/yarn.lock
index c3ddb8ee..3add1af6 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3115,6 +3115,27 @@
     xcode "^3.0.1"
     xml2js "0.6.0"
 
+"@expo/config-plugins@~5.0.3":
+  version "5.0.4"
+  resolved "https://registry.yarnpkg.com/@expo/config-plugins/-/config-plugins-5.0.4.tgz#216fea6558fe66615af1370de55193f4181cb23e"
+  integrity sha512-vzUcVpqOMs3h+hyRdhGwk+eGIOhXa5xYdd92yO17RMNHav3v/+ekMbs7XA2c3lepMO8Yd4/5hqmRw9ZTL6jGzg==
+  dependencies:
+    "@expo/config-types" "^47.0.0"
+    "@expo/json-file" "8.2.36"
+    "@expo/plist" "0.0.18"
+    "@expo/sdk-runtime-versions" "^1.0.0"
+    "@react-native/normalize-color" "^2.0.0"
+    chalk "^4.1.2"
+    debug "^4.3.1"
+    find-up "~5.0.0"
+    getenv "^1.0.0"
+    glob "7.1.6"
+    resolve-from "^5.0.0"
+    semver "^7.3.5"
+    slash "^3.0.0"
+    xcode "^3.0.1"
+    xml2js "0.4.23"
+
 "@expo/config-plugins@~7.8.2":
   version "7.8.2"
   resolved "https://registry.yarnpkg.com/@expo/config-plugins/-/config-plugins-7.8.2.tgz#c00ce93c4d6c2cb9e345ed9cd56ceeea05ab8ddb"
@@ -3138,6 +3159,11 @@
     xcode "^3.0.1"
     xml2js "0.6.0"
 
+"@expo/config-types@^47.0.0":
+  version "47.0.0"
+  resolved "https://registry.yarnpkg.com/@expo/config-types/-/config-types-47.0.0.tgz#99eeabe0bba7a776e0f252b78beb0c574692c38d"
+  integrity sha512-r0pWfuhkv7KIcXMUiNACJmJKKwlTBGMw9VZHNdppS8/0Nve8HZMTkNRFQzTHW1uH3pBj8jEXpyw/2vSWDHex9g==
+
 "@expo/config-types@^50.0.0", "@expo/config-types@^50.0.0-alpha.1":
   version "50.0.0"
   resolved "https://registry.yarnpkg.com/@expo/config-types/-/config-types-50.0.0.tgz#b534d3ec997ec60f8af24f6ad56244c8afc71a0b"
@@ -3160,6 +3186,23 @@
     slugify "^1.3.4"
     sucrase "^3.20.0"
 
+"@expo/config@~7.0.0":
+  version "7.0.3"
+  resolved "https://registry.yarnpkg.com/@expo/config/-/config-7.0.3.tgz#c9c634e76186de25e296485e51418f1e52966e6e"
+  integrity sha512-joVtB5o+NF40Tmsdp65UzryRtbnCuMbXkVO4wJnNJO4aaK0EYLdHCYSewORVqNcDfGN0LphQr8VTG2npbd9CJA==
+  dependencies:
+    "@babel/code-frame" "~7.10.4"
+    "@expo/config-plugins" "~5.0.3"
+    "@expo/config-types" "^47.0.0"
+    "@expo/json-file" "8.2.36"
+    getenv "^1.0.0"
+    glob "7.1.6"
+    require-from-string "^2.0.2"
+    resolve-from "^5.0.0"
+    semver "7.3.2"
+    slugify "^1.3.4"
+    sucrase "^3.20.0"
+
 "@expo/config@~8.5.0":
   version "8.5.0"
   resolved "https://registry.yarnpkg.com/@expo/config/-/config-8.5.0.tgz#c618e016c3272335e33fec02fb7fd67e4dcc3342"
@@ -3259,6 +3302,15 @@
     semver "7.3.2"
     tempy "0.3.0"
 
+"@expo/json-file@8.2.36":
+  version "8.2.36"
+  resolved "https://registry.yarnpkg.com/@expo/json-file/-/json-file-8.2.36.tgz#62a505cb7f30a34d097386476794680a3f7385ff"
+  integrity sha512-tOZfTiIFA5KmMpdW9KF7bc6CFiGjb0xnbieJhTGlHrLL+ps2G0OkqmuZ3pFEXBOMnJYUVpnSy++52LFxvpa5ZQ==
+  dependencies:
+    "@babel/code-frame" "~7.10.4"
+    json5 "^1.0.1"
+    write-file-atomic "^2.3.0"
+
 "@expo/json-file@^8.2.37":
   version "8.2.37"
   resolved "https://registry.yarnpkg.com/@expo/json-file/-/json-file-8.2.37.tgz#9c02d3b42134907c69cc0a027b18671b69344049"
@@ -3328,6 +3380,15 @@
     split "^1.0.1"
     sudo-prompt "9.1.1"
 
+"@expo/plist@0.0.18":
+  version "0.0.18"
+  resolved "https://registry.yarnpkg.com/@expo/plist/-/plist-0.0.18.tgz#9abcde78df703a88f6d9fa1a557ee2f045d178b0"
+  integrity sha512-+48gRqUiz65R21CZ/IXa7RNBXgAI/uPSdvJqoN9x1hfL44DNbUoWHgHiEXTx7XelcATpDwNTz6sHLfy0iNqf+w==
+  dependencies:
+    "@xmldom/xmldom" "~0.7.0"
+    base64-js "^1.2.3"
+    xmlbuilder "^14.0.0"
+
 "@expo/plist@^0.1.0":
   version "0.1.0"
   resolved "https://registry.yarnpkg.com/@expo/plist/-/plist-0.1.0.tgz#eabc95f951d14e10c87fd0443ee01d567371f058"
@@ -4750,6 +4811,13 @@
   dependencies:
     merge-options "^3.0.4"
 
+"@react-native-async-storage/async-storage@^1.15.2":
+  version "1.22.0"
+  resolved "https://registry.yarnpkg.com/@react-native-async-storage/async-storage/-/async-storage-1.22.0.tgz#202a9afd15a5b829c39b709d0ca3942612441efc"
+  integrity sha512-b5KD010iiZnot86RbAaHpLuHwmPW2qA3SSN/OSZhd1kBoINEQEVBuv+uFtcaTxAhX27bT0wd13GOb2IOSDUXSA==
+  dependencies:
+    merge-options "^3.0.4"
+
 "@react-native-camera-roll/camera-roll@^5.2.2":
   version "5.7.2"
   resolved "https://registry.yarnpkg.com/@react-native-camera-roll/camera-roll/-/camera-roll-5.7.2.tgz#db11525ae26c8a61630c424aebd323a7c784a921"
@@ -8124,7 +8192,7 @@
   resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.8.10.tgz#a1337ca426aa61cef9fe15b5b28e340a72f6fa99"
   integrity sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==
 
-"@xmldom/xmldom@~0.7.7":
+"@xmldom/xmldom@~0.7.0", "@xmldom/xmldom@~0.7.7":
   version "0.7.13"
   resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.7.13.tgz#ff34942667a4e19a9f4a0996a76814daac364cf3"
   integrity sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g==
@@ -11737,6 +11805,14 @@ expo-camera@~14.0.1:
   dependencies:
     invariant "^2.2.4"
 
+expo-constants@^13.0.2:
+  version "13.2.4"
+  resolved "https://registry.yarnpkg.com/expo-constants/-/expo-constants-13.2.4.tgz#eab4a553f074b2c60ad7a158d3b82e3484a94606"
+  integrity sha512-Zobau8EuTk2GgafwkfGnWM6CmSLB7X8qnQXVuXe0nd3v92hfQUmRWGhJwH88uxXj3LrfqctM6PaJ8taG1vxfBw==
+  dependencies:
+    "@expo/config" "~7.0.0"
+    uuid "^3.3.2"
+
 expo-constants@~15.4.0:
   version "15.4.1"
   resolved "https://registry.yarnpkg.com/expo-constants/-/expo-constants-15.4.1.tgz#f76f347cf687b6630e1e3b9a385a4e42771671a4"
@@ -11786,6 +11862,13 @@ expo-dev-menu@4.5.3:
     expo-dev-menu-interface "1.7.2"
     semver "^7.5.3"
 
+expo-device@~4.1.1:
+  version "4.1.1"
+  resolved "https://registry.yarnpkg.com/expo-device/-/expo-device-4.1.1.tgz#5de94144113ffb7fa0f37fa3d70e452113954c10"
+  integrity sha512-It0SGtKcvzQSf+Co6zdPdB63zZvG2/rDolB1lqswMNKj03Y7KVU41s5tcQCqNczj7tmeN3CJy7A8YhYGKdb7gA==
+  dependencies:
+    ua-parser-js "^0.7.19"
+
 expo-device@~5.9.2:
   version "5.9.2"
   resolved "https://registry.yarnpkg.com/expo-device/-/expo-device-5.9.2.tgz#697e96f52d213a141b6f265f1e274e9d5e98c92c"
@@ -14978,6 +15061,16 @@ js-queue@2.0.2:
   dependencies:
     easy-stack "^1.0.1"
 
+js-sha256@^0.10.1:
+  version "0.10.1"
+  resolved "https://registry.yarnpkg.com/js-sha256/-/js-sha256-0.10.1.tgz#b40104ba1368e823fdd5f41b66b104b15a0da60d"
+  integrity sha512-5obBtsz9301ULlsgggLg542s/jqtddfOpV5KJc4hajc9JV8GeY2gZHSVpYBn4nWqAUTJ9v+xwtbJ1mIBgIH5Vw==
+
+js-sha256@^0.11.0:
+  version "0.11.0"
+  resolved "https://registry.yarnpkg.com/js-sha256/-/js-sha256-0.11.0.tgz#256a921d9292f7fe98905face82e367abaca9576"
+  integrity sha512-6xNlKayMZvds9h1Y1VWc0fQHQ82BxTXizWPEtEeGvmOUYpBRy4gbWroHLpzowe6xiQhHpelCQiE7HEdznyBL9Q==
+
 js-sha256@^0.9.0:
   version "0.9.0"
   resolved "https://registry.yarnpkg.com/js-sha256/-/js-sha256-0.9.0.tgz#0b89ac166583e91ef9123644bd3c5334ce9d0966"
@@ -15162,7 +15255,7 @@ json-stable-stringify-without-jsonify@^1.0.1:
   resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651"
   integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==
 
-json5@^1.0.2:
+json5@^1.0.1, json5@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.2.tgz#63d98d60f21b313b77c4d6da18bfa69d80e1d593"
   integrity sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==
@@ -18503,6 +18596,13 @@ react-native-gesture-handler@~2.14.0:
     lodash "^4.17.21"
     prop-types "^15.7.2"
 
+react-native-get-random-values@^1.6.0:
+  version "1.10.0"
+  resolved "https://registry.yarnpkg.com/react-native-get-random-values/-/react-native-get-random-values-1.10.0.tgz#c2c5f12a4ef8b1175145347b4a4b9f9a40d9ffc8"
+  integrity sha512-gZ1zbXhbb8+Jy9qYTV8c4Nf45/VB4g1jmXuavY5rPfUn7x3ok9Vl3FTl0dnE92Z4FFtfbUNNwtSfcmomdtWg+A==
+  dependencies:
+    fast-base64-decode "^1.0.0"
+
 react-native-get-random-values@~1.8.0:
   version "1.8.0"
   resolved "https://registry.yarnpkg.com/react-native-get-random-values/-/react-native-get-random-values-1.8.0.tgz#1cb4bd4bd3966a356e59697b8f372999fe97cb16"
@@ -19976,6 +20076,49 @@ standard-as-callback@^2.1.0:
   resolved "https://registry.yarnpkg.com/standard-as-callback/-/standard-as-callback-2.1.0.tgz#8953fc05359868a77b5b9739a665c5977bb7df45"
   integrity sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==
 
+statsig-js@4.45.1:
+  version "4.45.1"
+  resolved "https://registry.yarnpkg.com/statsig-js/-/statsig-js-4.45.1.tgz#b1f5b9c52adc4a8aece376fb011416c89227932f"
+  integrity sha512-h94RzFQsJCQCNwQXpZ9OBXcvCxDnkXF6OrCekd81ySvY2l4JSowpxMWX3Iw6IDFzfTfKdER9JQzFLhMSQbT+YQ==
+  dependencies:
+    js-sha256 "^0.10.1"
+    uuid "^8.3.2"
+
+statsig-js@4.49.0:
+  version "4.49.0"
+  resolved "https://registry.yarnpkg.com/statsig-js/-/statsig-js-4.49.0.tgz#8470a9ac218a93d36f4b7b306ff9377e48064740"
+  integrity sha512-N4drx6fzI168Q4NndFY3IJbSDqpWSBWvS290H/RnT/g3Et58SvtXzG5qqgzmqy4CwcmwH+IL8K15pL7hPnfvUQ==
+  dependencies:
+    js-sha256 "^0.11.0"
+    uuid "^8.3.2"
+
+statsig-react-native-expo@^4.6.1:
+  version "4.6.1"
+  resolved "https://registry.yarnpkg.com/statsig-react-native-expo/-/statsig-react-native-expo-4.6.1.tgz#0bdf49fee7112f7f28bff2405f4ba0c1727bb3d6"
+  integrity sha512-rB60c+WSrQPmjW9j75d+acUtwSOe38PE2KTDHiOv1Mf+0TCcFtGYlJmKCibWvbeXR7ZAyjjGeroh23bCSEZauQ==
+  dependencies:
+    "@react-native-async-storage/async-storage" "^1.15.2"
+    expo-constants "^13.0.2"
+    expo-device "~4.1.1"
+    js-sha256 "^0.9.0"
+    react-native-get-random-values "^1.6.0"
+    statsig-react "^1.21.1"
+    uuid "^8.3.2"
+
+statsig-react@^1.21.1:
+  version "1.35.0"
+  resolved "https://registry.yarnpkg.com/statsig-react/-/statsig-react-1.35.0.tgz#ad5730b83f564c640623e954fcbcbe848e939946"
+  integrity sha512-KLN7dhq6FvAl25Z0QN6IINFBgM3yn0GMafoE698tYZqRf911xvevFaR7qUXiTz3W9vmFYrmFRouqVMfCv7DW0A==
+  dependencies:
+    statsig-js "4.45.1"
+
+statsig-react@^1.36.0:
+  version "1.36.0"
+  resolved "https://registry.yarnpkg.com/statsig-react/-/statsig-react-1.36.0.tgz#c2171268a6c76eee534849ec9556b836baba04b6"
+  integrity sha512-QcTHla3ypfn2RvrnHGNlqWbiC2W/ZjcMM5LT6ExNV4skH7Xhspto3dMS3JVzBhOb74OEDZK4DbxQj9Wdz6XW0w==
+  dependencies:
+    statsig-js "4.49.0"
+
 statuses@2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63"
@@ -20920,6 +21063,11 @@ typescript@^5.3.3:
   resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.3.3.tgz#b3ce6ba258e72e6305ba66f5c9b452aaee3ffe37"
   integrity sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==
 
+ua-parser-js@^0.7.19:
+  version "0.7.37"
+  resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.37.tgz#e464e66dac2d33a7a1251d7d7a99d6157ec27832"
+  integrity sha512-xV8kqRKM+jhMvcHWUKthV9fNebIzrNy//2O9ZwWcfiBFR5f25XVZPLlEajk/sf3Ra15V92isyQqnIEXRDaZWEA==
+
 ua-parser-js@^0.7.33:
   version "0.7.35"
   resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.35.tgz#8bda4827be4f0b1dda91699a29499575a1f1d307"
@@ -21201,7 +21349,7 @@ utils-merge@1.0.1:
   resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
   integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==
 
-uuid@^3.0.1:
+uuid@^3.0.1, uuid@^3.3.2:
   version "3.4.0"
   resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"
   integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==
@@ -21953,6 +22101,14 @@ xml-name-validator@^4.0.0:
   resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-4.0.0.tgz#79a006e2e63149a8600f15430f0a4725d1524835"
   integrity sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==
 
+xml2js@0.4.23:
+  version "0.4.23"
+  resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.23.tgz#a0c69516752421eb2ac758ee4d4ccf58843eac66"
+  integrity sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==
+  dependencies:
+    sax ">=0.6.0"
+    xmlbuilder "~11.0.0"
+
 xml2js@0.6.0:
   version "0.6.0"
   resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.6.0.tgz#07afc447a97d2bd6507a1f76eeadddb09f7a8282"