diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/App.native.tsx | 36 | ||||
-rw-r--r-- | src/App.web.tsx | 33 | ||||
-rw-r--r-- | src/components/Dialog/context.ts | 27 | ||||
-rw-r--r-- | src/components/Dialog/index.tsx | 115 | ||||
-rw-r--r-- | src/components/Dialog/index.web.tsx | 12 | ||||
-rw-r--r-- | src/components/Dialog/types.ts | 1 | ||||
-rw-r--r-- | src/components/Menu/context.tsx | 8 | ||||
-rw-r--r-- | src/components/Menu/index.tsx | 190 | ||||
-rw-r--r-- | src/components/Menu/index.web.tsx | 247 | ||||
-rw-r--r-- | src/components/Menu/types.ts | 72 | ||||
-rw-r--r-- | src/lib/statsig/statsig.tsx | 11 | ||||
-rw-r--r-- | src/lib/statsig/statsig.web.tsx | 51 | ||||
-rw-r--r-- | src/state/dialogs/index.tsx | 34 | ||||
-rw-r--r-- | src/view/com/home/HomeHeaderLayout.web.tsx | 19 | ||||
-rw-r--r-- | src/view/com/notifications/FeedItem.tsx | 1 | ||||
-rw-r--r-- | src/view/screens/Storybook/Menus.tsx | 79 | ||||
-rw-r--r-- | src/view/screens/Storybook/index.tsx | 2 | ||||
-rw-r--r-- | src/view/shell/index.tsx | 16 |
18 files changed, 838 insertions, 116 deletions
diff --git a/src/App.native.tsx b/src/App.native.tsx index f08a6235b..eff8ab099 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -43,9 +43,12 @@ 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' +import {StatusBar} from 'expo-status-bar' +import {isAndroid} from 'platform/detection' SplashScreen.preventAutoHideAsync() @@ -69,26 +72,29 @@ function InnerApp() { return ( <SafeAreaProvider initialMetrics={initialWindowMetrics}> + {isAndroid && <StatusBar />} <Alf theme={theme}> <Splash isReady={!isInitialLoad}> <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 6ac32a011..eb2e42593 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/components/Dialog/context.ts b/src/components/Dialog/context.ts index eb717d8e2..9b571e8e9 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/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..fa9398fe0 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 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..ca2e40566 --- /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 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/lib/statsig/statsig.tsx b/src/lib/statsig/statsig.tsx new file mode 100644 index 000000000..88a57c3fc --- /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 000000000..6508131c4 --- /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/src/state/dialogs/index.tsx b/src/state/dialogs/index.tsx index 9fc70c178..90aaca4f8 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 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}), []) - const controls = React.useMemo(() => ({closeAllDialogs}), [closeAllDialogs]) + 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/com/home/HomeHeaderLayout.web.tsx b/src/view/com/home/HomeHeaderLayout.web.tsx index 6145081af..9818b56f6 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> </> ) } diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index f037097df..45166fe3c 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 ? ( <> diff --git a/src/view/screens/Storybook/Menus.tsx b/src/view/screens/Storybook/Menus.tsx new file mode 100644 index 000000000..082fb2b6e --- /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 40929555e..e43d756de 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/src/view/shell/index.tsx b/src/view/shell/index.tsx index d895d8851..bdba79174 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} |