about summary refs log tree commit diff
path: root/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/components')
-rw-r--r--src/components/Button.tsx4
-rw-r--r--src/components/Dialog/context.ts23
-rw-r--r--src/components/Dialog/index.tsx115
-rw-r--r--src/components/Dialog/index.web.tsx12
-rw-r--r--src/components/Dialog/types.ts1
-rw-r--r--src/components/Lists.tsx13
-rw-r--r--src/components/Menu/context.tsx8
-rw-r--r--src/components/Menu/index.tsx190
-rw-r--r--src/components/Menu/index.web.tsx247
-rw-r--r--src/components/Menu/types.ts72
-rw-r--r--src/components/Prompt.tsx25
-rw-r--r--src/components/TagMenu/index.tsx4
-rw-r--r--src/components/TagMenu/index.web.tsx4
-rw-r--r--src/components/forms/DateField/index.android.tsx39
-rw-r--r--src/components/forms/DateField/index.tsx20
-rw-r--r--src/components/icons/Bubble.tsx5
-rw-r--r--src/components/icons/Filter.tsx5
-rw-r--r--src/components/icons/Speaker.tsx5
-rw-r--r--src/components/icons/Trash.tsx5
-rw-r--r--src/components/icons/Warning.tsx5
20 files changed, 680 insertions, 122 deletions
diff --git a/src/components/Button.tsx b/src/components/Button.tsx
index 5361be963..d3bf73cc3 100644
--- a/src/components/Button.tsx
+++ b/src/components/Button.tsx
@@ -27,7 +27,7 @@ export type ButtonColor =
   | 'gradient_sunset'
   | 'gradient_nordic'
   | 'gradient_bonfire'
-export type ButtonSize = 'tiny' | 'small' | 'large'
+export type ButtonSize = 'tiny' | 'small' | 'medium' | 'large'
 export type ButtonShape = 'round' | 'square' | 'default'
 export type VariantProps = {
   /**
@@ -274,6 +274,8 @@ export function Button({
     if (shape === 'default') {
       if (size === 'large') {
         baseStyles.push({paddingVertical: 15}, a.px_2xl, a.rounded_sm, a.gap_md)
+      } else if (size === 'medium') {
+        baseStyles.push({paddingVertical: 12}, a.px_2xl, a.rounded_sm, a.gap_md)
       } else if (size === 'small') {
         baseStyles.push({paddingVertical: 9}, a.px_lg, a.rounded_sm, a.gap_sm)
       } else if (size === 'tiny') {
diff --git a/src/components/Dialog/context.ts b/src/components/Dialog/context.ts
index eb717d8e2..859f8edd7 100644
--- a/src/components/Dialog/context.ts
+++ b/src/components/Dialog/context.ts
@@ -31,14 +31,17 @@ 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,
+      open: () => {
+        control.current.open()
+      },
+      close: cb => {
+        control.current.close(cb)
+      },
+    }),
+    [id, control],
+  )
 }
diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx
index ef4f4741b..f0e7b7e82 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 => {
@@ -119,65 +119,66 @@ 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
-        }
-
-        openDialogs.current.delete(control.id)
-        onClose?.()
-        setOpenIndex(-1)
-      }
-    },
-    [onClose, setOpenIndex, openDialogs, 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])
 
   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'}}
+            onClose={onCloseInner}>
+            <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 32163e735..3a7f73342 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/components/Dialog/types.ts b/src/components/Dialog/types.ts
index 78dfedf5a..4fc60ec39 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/Lists.tsx b/src/components/Lists.tsx
index 12a935807..58aa74b38 100644
--- a/src/components/Lists.tsx
+++ b/src/components/Lists.tsx
@@ -1,6 +1,7 @@
 import React from 'react'
 import {atoms as a, useBreakpoints, useTheme} from '#/alf'
 import {View} from 'react-native'
+import {CenteredView} from 'view/com/util/Views'
 import {Loader} from '#/components/Loader'
 import {Trans} from '@lingui/macro'
 import {cleanError} from 'lib/strings/errors'
@@ -143,7 +144,7 @@ export function ListMaybePlaceholder({
 }) {
   const navigation = useNavigation<NavigationProp>()
   const t = useTheme()
-  const {gtMobile} = useBreakpoints()
+  const {gtMobile, gtTablet} = useBreakpoints()
 
   const canGoBack = navigation.canGoBack()
   const onGoBack = React.useCallback(() => {
@@ -165,14 +166,16 @@ export function ListMaybePlaceholder({
   if (!isEmpty) return null
 
   return (
-    <View
+    <CenteredView
       style={[
         a.flex_1,
         a.align_center,
-        !gtMobile ? [a.justify_between, a.border_t] : a.gap_5xl,
+        !gtMobile ? a.justify_between : a.gap_5xl,
         t.atoms.border_contrast_low,
         {paddingTop: 175, paddingBottom: 110},
-      ]}>
+      ]}
+      sideBorders={gtMobile}
+      topBorder={!gtTablet}>
       {isLoading ? (
         <View style={[a.w_full, a.align_center, {top: 100}]}>
           <Loader size="xl" />
@@ -241,6 +244,6 @@ export function ListMaybePlaceholder({
           </View>
         </>
       )}
-    </View>
+    </CenteredView>
   )
 }
diff --git a/src/components/Menu/context.tsx b/src/components/Menu/context.tsx
new file mode 100644
index 000000000..9fc91f681
--- /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 000000000..ee96a5667
--- /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 000000000..054e51b01
--- /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, focused && 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>
+
+        {/* Disabled until we can fix positioning
+        <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 000000000..2f52e6390
--- /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/components/Prompt.tsx b/src/components/Prompt.tsx
index 8e55bd834..3b245c440 100644
--- a/src/components/Prompt.tsx
+++ b/src/components/Prompt.tsx
@@ -3,7 +3,7 @@ import {View, PressableProps} from 'react-native'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
-import {useTheme, atoms as a} from '#/alf'
+import {useTheme, atoms as a, useBreakpoints} from '#/alf'
 import {Text} from '#/components/Typography'
 import {Button} from '#/components/Button'
 
@@ -25,6 +25,7 @@ export function Outer({
 }: React.PropsWithChildren<{
   control: Dialog.DialogOuterProps['control']
 }>) {
+  const {gtMobile} = useBreakpoints()
   const titleId = React.useId()
   const descriptionId = React.useId()
 
@@ -38,12 +39,12 @@ export function Outer({
       <Context.Provider value={context}>
         <Dialog.Handle />
 
-        <Dialog.Inner
+        <Dialog.ScrollableInner
           accessibilityLabelledBy={titleId}
           accessibilityDescribedBy={descriptionId}
-          style={[{width: 'auto', maxWidth: 400}]}>
+          style={[gtMobile ? {width: 'auto', maxWidth: 400} : a.w_full]}>
           {children}
-        </Dialog.Inner>
+        </Dialog.ScrollableInner>
       </Context.Provider>
     </Dialog.Outer>
   )
@@ -71,8 +72,16 @@ export function Description({children}: React.PropsWithChildren<{}>) {
 }
 
 export function Actions({children}: React.PropsWithChildren<{}>) {
+  const {gtMobile} = useBreakpoints()
+
   return (
-    <View style={[a.w_full, a.flex_row, a.gap_sm, a.justify_end]}>
+    <View
+      style={[
+        a.w_full,
+        a.gap_sm,
+        a.justify_end,
+        gtMobile ? [a.flex_row] : [a.flex_col, a.pt_md, a.pb_4xl],
+      ]}>
       {children}
     </View>
   )
@@ -82,12 +91,13 @@ export function Cancel({
   children,
 }: React.PropsWithChildren<{onPress?: PressableProps['onPress']}>) {
   const {_} = useLingui()
+  const {gtMobile} = useBreakpoints()
   const {close} = Dialog.useDialogContext()
   return (
     <Button
       variant="solid"
       color="secondary"
-      size="small"
+      size={gtMobile ? 'small' : 'medium'}
       label={_(msg`Cancel`)}
       onPress={() => close()}>
       {children}
@@ -100,6 +110,7 @@ export function Action({
   onPress,
 }: React.PropsWithChildren<{onPress?: () => void}>) {
   const {_} = useLingui()
+  const {gtMobile} = useBreakpoints()
   const {close} = Dialog.useDialogContext()
   const handleOnPress = React.useCallback(() => {
     close()
@@ -109,7 +120,7 @@ export function Action({
     <Button
       variant="solid"
       color="primary"
-      size="small"
+      size={gtMobile ? 'small' : 'medium'}
       label={_(msg`Confirm`)}
       onPress={handleOnPress}>
       {children}
diff --git a/src/components/TagMenu/index.tsx b/src/components/TagMenu/index.tsx
index c9ced9a54..849a3f42d 100644
--- a/src/components/TagMenu/index.tsx
+++ b/src/components/TagMenu/index.tsx
@@ -98,7 +98,7 @@ export function TagMenu({
 
                     control.close(() => {
                       navigation.push('Hashtag', {
-                        tag: tag.replaceAll('#', '%23'),
+                        tag: encodeURIComponent(tag),
                       })
                     })
 
@@ -153,7 +153,7 @@ export function TagMenu({
 
                         control.close(() => {
                           navigation.push('Hashtag', {
-                            tag: tag.replaceAll('#', '%23'),
+                            tag: encodeURIComponent(tag),
                             author: authorHandle,
                           })
                         })
diff --git a/src/components/TagMenu/index.web.tsx b/src/components/TagMenu/index.web.tsx
index a0dc2bce6..8245bd019 100644
--- a/src/components/TagMenu/index.web.tsx
+++ b/src/components/TagMenu/index.web.tsx
@@ -66,7 +66,7 @@ export function TagMenu({
         label: _(msg`See ${truncatedTag} posts`),
         onPress() {
           navigation.push('Hashtag', {
-            tag: tag.replaceAll('#', '%23'),
+            tag: encodeURIComponent(tag),
           })
         },
         testID: 'tagMenuSearch',
@@ -83,7 +83,7 @@ export function TagMenu({
           label: _(msg`See ${truncatedTag} posts by user`),
           onPress() {
             navigation.push('Hashtag', {
-              tag: tag.replaceAll('#', '%23'),
+              tag: encodeURIComponent(tag),
               author: authorHandle,
             })
           },
diff --git a/src/components/forms/DateField/index.android.tsx b/src/components/forms/DateField/index.android.tsx
index cddb643d6..451810a5e 100644
--- a/src/components/forms/DateField/index.android.tsx
+++ b/src/components/forms/DateField/index.android.tsx
@@ -1,8 +1,5 @@
 import React from 'react'
 import {View, Pressable} from 'react-native'
-import DateTimePicker, {
-  BaseProps as DateTimePickerProps,
-} from '@react-native-community/datetimepicker'
 
 import {useTheme, atoms} from '#/alf'
 import {Text} from '#/components/Typography'
@@ -15,6 +12,8 @@ import {
   localizeDate,
   toSimpleDateString,
 } from '#/components/forms/DateField/utils'
+import DatePicker from 'react-native-date-picker'
+import {isAndroid} from 'platform/detection'
 
 export * as utils from '#/components/forms/DateField/utils'
 export const Label = TextField.Label
@@ -38,20 +37,20 @@ export function DateField({
   const {chromeFocus, chromeError, chromeErrorHover} =
     TextField.useSharedInputStyles()
 
-  const onChangeInternal = React.useCallback<
-    Required<DateTimePickerProps>['onChange']
-  >(
-    (_event, date) => {
+  const onChangeInternal = React.useCallback(
+    (date: Date) => {
       setOpen(false)
 
-      if (date) {
-        const formatted = toSimpleDateString(date)
-        onChangeDate(formatted)
-      }
+      const formatted = toSimpleDateString(date)
+      onChangeDate(formatted)
     },
     [onChangeDate, setOpen],
   )
 
+  const onCancel = React.useCallback(() => {
+    setOpen(false)
+  }, [])
+
   return (
     <View style={[atoms.relative, atoms.w_full]}>
       <Pressable
@@ -89,18 +88,18 @@ export function DateField({
       </Pressable>
 
       {open && (
-        <DateTimePicker
+        <DatePicker
+          modal={isAndroid}
+          open={isAndroid}
+          theme={t.name === 'light' ? 'light' : 'dark'}
+          date={new Date(value)}
+          onConfirm={onChangeInternal}
+          onCancel={onCancel}
+          mode="date"
+          testID={`${testID}-datepicker`}
           aria-label={label}
           accessibilityLabel={label}
           accessibilityHint={undefined}
-          testID={`${testID}-datepicker`}
-          mode="date"
-          timeZoneName={'Etc/UTC'}
-          display="spinner"
-          // @ts-ignore applies in iOS only -prf
-          themeVariant={t.name === 'light' ? 'light' : 'dark'}
-          value={new Date(value)}
-          onChange={onChangeInternal}
         />
       )}
     </View>
diff --git a/src/components/forms/DateField/index.tsx b/src/components/forms/DateField/index.tsx
index e65936e0e..49e47a01e 100644
--- a/src/components/forms/DateField/index.tsx
+++ b/src/components/forms/DateField/index.tsx
@@ -1,13 +1,11 @@
 import React from 'react'
 import {View} from 'react-native'
-import DateTimePicker, {
-  DateTimePickerEvent,
-} from '@react-native-community/datetimepicker'
 
 import {useTheme, atoms} from '#/alf'
 import * as TextField from '#/components/forms/TextField'
 import {toSimpleDateString} from '#/components/forms/DateField/utils'
 import {DateFieldProps} from '#/components/forms/DateField/types'
+import DatePicker from 'react-native-date-picker'
 
 export * as utils from '#/components/forms/DateField/utils'
 export const Label = TextField.Label
@@ -28,7 +26,7 @@ export function DateField({
   const t = useTheme()
 
   const onChangeInternal = React.useCallback(
-    (event: DateTimePickerEvent, date: Date | undefined) => {
+    (date: Date | undefined) => {
       if (date) {
         const formatted = toSimpleDateString(date)
         onChangeDate(formatted)
@@ -39,17 +37,15 @@ export function DateField({
 
   return (
     <View style={[atoms.relative, atoms.w_full]}>
-      <DateTimePicker
+      <DatePicker
+        theme={t.name === 'light' ? 'light' : 'dark'}
+        date={new Date(value)}
+        onDateChange={onChangeInternal}
+        mode="date"
+        testID={`${testID}-datepicker`}
         aria-label={label}
         accessibilityLabel={label}
         accessibilityHint={undefined}
-        testID={`${testID}-datepicker`}
-        mode="date"
-        timeZoneName={'Etc/UTC'}
-        display="spinner"
-        themeVariant={t.name === 'light' ? 'light' : 'dark'}
-        value={new Date(value)}
-        onChange={onChangeInternal}
       />
     </View>
   )
diff --git a/src/components/icons/Bubble.tsx b/src/components/icons/Bubble.tsx
new file mode 100644
index 000000000..d4e08f6d2
--- /dev/null
+++ b/src/components/icons/Bubble.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const BubbleQuestion_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M5.002 17.036V5h14v12.036h-3.986a1 1 0 0 0-.639.23l-2.375 1.968-2.344-1.965a1 1 0 0 0-.643-.233H5.002ZM20.002 3h-16a1 1 0 0 0-1 1v14.036a1 1 0 0 0 1 1h4.65l2.704 2.266a1 1 0 0 0 1.28.004l2.74-2.27h4.626a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1Zm-7.878 3.663c-1.39 0-2.5 1.135-2.5 2.515a1 1 0 0 0 2 0c0-.294.232-.515.5-.515a.507.507 0 0 1 .489.6.174.174 0 0 1-.027.048 1.1 1.1 0 0 1-.267.226c-.508.345-1.128.923-1.286 1.978a1 1 0 1 0 1.978.297.762.762 0 0 1 .14-.359c.063-.086.155-.169.293-.262.436-.297 1.18-.885 1.18-2.013 0-1.38-1.11-2.515-2.5-2.515ZM12 15.75a1.25 1.25 0 1 1 0-2.5 1.25 1.25 0 0 1 0 2.5Z',
+})
diff --git a/src/components/icons/Filter.tsx b/src/components/icons/Filter.tsx
new file mode 100644
index 000000000..02ac1c71b
--- /dev/null
+++ b/src/components/icons/Filter.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Filter_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v4a1 1 0 0 1-.293.707L15 14.414V20a1 1 0 0 1-.758.97l-4 1A1 1 0 0 1 9 21v-6.586L3.293 8.707A1 1 0 0 1 3 8V4Zm2 1v2.586l5.707 5.707A1 1 0 0 1 11 14v5.72l2-.5V14a1 1 0 0 1 .293-.707L19 7.586V5H5Z',
+})
diff --git a/src/components/icons/Speaker.tsx b/src/components/icons/Speaker.tsx
new file mode 100644
index 000000000..365d5e114
--- /dev/null
+++ b/src/components/icons/Speaker.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const SpeakerVolumeFull_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M12.472 3.118A1 1 0 0 1 13 4v16a1 1 0 0 1-1.555.832L5.697 17H2a1 1 0 0 1-1-1V8a1 1 0 0 1 1-1h3.697l5.748-3.832a1 1 0 0 1 1.027-.05ZM11 5.868 6.555 8.833A1 1 0 0 1 6 9H3v6h3a1 1 0 0 1 .555.168L11 18.131V5.87Zm7.364-1.645a1 1 0 0 1 1.414 0A10.969 10.969 0 0 1 23 12c0 3.037-1.232 5.788-3.222 7.778a1 1 0 1 1-1.414-1.414A8.969 8.969 0 0 0 21 12a8.969 8.969 0 0 0-2.636-6.364 1 1 0 0 1 0-1.414Zm-3.182 3.181a1 1 0 0 1 1.414 0A6.483 6.483 0 0 1 18.5 12a6.483 6.483 0 0 1-1.904 4.597 1 1 0 0 1-1.414-1.415A4.483 4.483 0 0 0 16.5 12a4.483 4.483 0 0 0-1.318-3.182 1 1 0 0 1 0-1.414Z',
+})
diff --git a/src/components/icons/Trash.tsx b/src/components/icons/Trash.tsx
new file mode 100644
index 000000000..d09a3311f
--- /dev/null
+++ b/src/components/icons/Trash.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Trash_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M7.416 5H3a1 1 0 0 0 0 2h1.064l.938 14.067A1 1 0 0 0 6 22h12a1 1 0 0 0 .998-.933L19.936 7H21a1 1 0 1 0 0-2h-4.416a5 5 0 0 0-9.168 0Zm2.348 0h4.472c-.55-.614-1.348-1-2.236-1-.888 0-1.687.386-2.236 1Zm6.087 2H6.07l.867 13h10.128l.867-13h-2.036a1 1 0 0 1-.044 0ZM10 10a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0v-5a1 1 0 0 1 1-1Zm4 0a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0v-5a1 1 0 0 1 1-1Z',
+})
diff --git a/src/components/icons/Warning.tsx b/src/components/icons/Warning.tsx
new file mode 100644
index 000000000..fc84b2894
--- /dev/null
+++ b/src/components/icons/Warning.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Warning_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M11.14 4.494a.995.995 0 0 1 1.72 0l7.001 12.008a.996.996 0 0 1-.86 1.498H4.999a.996.996 0 0 1-.86-1.498L11.14 4.494Zm3.447-1.007c-1.155-1.983-4.019-1.983-5.174 0L2.41 15.494C1.247 17.491 2.686 20 4.998 20h14.004c2.312 0 3.751-2.509 2.587-4.506L14.587 3.487ZM13 9.019a1 1 0 1 0-2 0v2.994a1 1 0 1 0 2 0V9.02Zm-1 4.731a1.25 1.25 0 1 0 0 2.5 1.25 1.25 0 0 0 0-2.5Z',
+})