diff options
Diffstat (limited to 'src')
53 files changed, 1354 insertions, 598 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/Navigation.tsx b/src/Navigation.tsx index b30f8f982..8a9f69b5d 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -78,6 +78,7 @@ import {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStack import {msg} from '@lingui/macro' import {i18n, MessageDescriptor} from '@lingui/core' import HashtagScreen from '#/screens/Hashtag' +import {logEvent} from './lib/statsig/statsig' const navigationRef = createNavigationContainerRef<AllNavigatorParams>() @@ -649,11 +650,14 @@ function logModuleInitTime() { return } didInit = true + const initMs = Math.round( // @ts-ignore Emitted by Metro in the bundle prelude performance.now() - global.__BUNDLE_START_TIME__, ) console.log(`Time to first paint: ${initMs} ms`) + logEvent('init', initMs) + if (__DEV__) { // This log is noisy, so keep false committed const shouldLog = false 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', +}) diff --git a/src/lib/hooks/useInitialNumToRender.ts b/src/lib/hooks/useInitialNumToRender.ts new file mode 100644 index 000000000..942f0404a --- /dev/null +++ b/src/lib/hooks/useInitialNumToRender.ts @@ -0,0 +1,11 @@ +import React from 'react' +import {Dimensions} from 'react-native' + +const MIN_POST_HEIGHT = 100 + +export function useInitialNumToRender(minItemHeight: number = MIN_POST_HEIGHT) { + return React.useMemo(() => { + const screenHeight = Dimensions.get('window').height + return Math.ceil(screenHeight / minItemHeight) + 1 + }, [minItemHeight]) +} diff --git a/src/lib/routes/router.ts b/src/lib/routes/router.ts index 00defaeda..8c8be3739 100644 --- a/src/lib/routes/router.ts +++ b/src/lib/routes/router.ts @@ -2,9 +2,15 @@ import {RouteParams, Route} from './types' export class Router { routes: [string, Route][] = [] - constructor(description: Record<string, string>) { + constructor(description: Record<string, string | string[]>) { for (const [screen, pattern] of Object.entries(description)) { - this.routes.push([screen, createRoute(pattern)]) + if (typeof pattern === 'string') { + this.routes.push([screen, createRoute(pattern)]) + } else { + pattern.forEach(subPattern => { + this.routes.push([screen, createRoute(subPattern)]) + }) + } } } diff --git a/src/lib/statsig/statsig.tsx b/src/lib/statsig/statsig.tsx new file mode 100644 index 000000000..6d9ebeb09 --- /dev/null +++ b/src/lib/statsig/statsig.tsx @@ -0,0 +1,75 @@ +import React from 'react' +import { + Statsig, + StatsigProvider, + useGate as useStatsigGate, +} from 'statsig-react-native-expo' +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 logEvent( + eventName: string, + value?: string | number | null, + metadata?: Record<string, string> | null, +) { + Statsig.logEvent(eventName, value, metadata) +} + +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], + ) + + React.useEffect(() => { + function refresh() { + // Intentionally refetching the config using the JS SDK rather than React SDK + // so that the new config is stored in cache but isn't used during this session. + // It will kick in for the next reload. + Statsig.updateUser(currentStatsigUser) + } + const id = setInterval(refresh, 3 * 60e3 /* 3 min */) + return () => clearInterval(id) + }, [currentStatsigUser]) + + 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/lib/statsig/statsig.web.tsx b/src/lib/statsig/statsig.web.tsx new file mode 100644 index 000000000..d1c912019 --- /dev/null +++ b/src/lib/statsig/statsig.web.tsx @@ -0,0 +1,75 @@ +import React from 'react' +import { + Statsig, + 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 logEvent( + eventName: string, + value?: string | number | null, + metadata?: Record<string, string> | null, +) { + Statsig.logEvent(eventName, value, metadata) +} + +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], + ) + + React.useEffect(() => { + function refresh() { + // Intentionally refetching the config using the JS SDK rather than React SDK + // so that the new config is stored in cache but isn't used during this session. + // It will kick in for the next reload. + Statsig.updateUser(currentStatsigUser) + } + const id = setInterval(refresh, 3 * 60e3 /* 3 min */) + return () => clearInterval(id) + }, [currentStatsigUser]) + + 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/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts index ba2cdb39b..820311e4e 100644 --- a/src/lib/strings/url-helpers.ts +++ b/src/lib/strings/url-helpers.ts @@ -3,6 +3,8 @@ import {BSKY_SERVICE} from 'lib/constants' import TLDs from 'tlds' import psl from 'psl' +export const BSKY_APP_HOST = 'https://bsky.app' + export function isValidDomain(str: string): boolean { return !!TLDs.find(tld => { let i = str.lastIndexOf(tld) @@ -67,8 +69,21 @@ export function isBskyAppUrl(url: string): boolean { return url.startsWith('https://bsky.app/') } +export function isRelativeUrl(url: string): boolean { + return /^\/[^/]/.test(url) +} + +export function isBskyRSSUrl(url: string): boolean { + return ( + (url.startsWith('https://bsky.app/') || isRelativeUrl(url)) && + /\/rss\/?$/.test(url) + ) +} + export function isExternalUrl(url: string): boolean { - return !isBskyAppUrl(url) && url.startsWith('http') + const external = !isBskyAppUrl(url) && url.startsWith('http') + const rss = isBskyRSSUrl(url) + return external || rss } export function isBskyPostUrl(url: string): boolean { @@ -148,6 +163,11 @@ export function feedUriToHref(url: string): string { export function linkRequiresWarning(uri: string, label: string) { const labelDomain = labelToDomain(label) + // If the uri started with a / we know it is internal. + if (isRelativeUrl(uri)) { + return false + } + let urip try { urip = new URL(uri) @@ -156,9 +176,12 @@ export function linkRequiresWarning(uri: string, label: string) { } const host = urip.hostname.toLowerCase() - // Hosts that end with bsky.app or bsky.social should be trusted by default. - if (host.endsWith('bsky.app') || host.endsWith('bsky.social')) { + if ( + host.endsWith('bsky.app') || + host.endsWith('bsky.social') || + host.endsWith('blueskyweb.xyz') + ) { // if this is a link to internal content, // warn if it represents itself as a URL to another app return !!labelDomain && labelDomain !== host && isPossiblyAUrl(labelDomain) @@ -214,3 +237,8 @@ export function splitApexDomain(hostname: string): [string, string] { hostnamep.domain, ] } + +export function createBskyAppAbsoluteUrl(path: string): string { + const sanitizedPath = path.replace(BSKY_APP_HOST, '').replace(/^\/+/, '') + return `${BSKY_APP_HOST.replace(/\/$/, '')}/${sanitizedPath}` +} diff --git a/src/platform/markBundleStartTime.web.ts b/src/platform/markBundleStartTime.web.ts new file mode 100644 index 000000000..cd64c9f1c --- /dev/null +++ b/src/platform/markBundleStartTime.web.ts @@ -0,0 +1,2 @@ +// @ts-ignore Web-only. On RN, this is set by Metro. +window.__BUNDLE_START_TIME__ = performance.now() diff --git a/src/routes.ts b/src/routes.ts index 3fc908b48..5c263fd6f 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -12,7 +12,7 @@ export const router = new Router({ ModerationModlists: '/moderation/modlists', ModerationMutedAccounts: '/moderation/muted-accounts', ModerationBlockedAccounts: '/moderation/blocked-accounts', - Profile: '/profile/:name', + Profile: ['/profile/:name', '/profile/:name/rss'], ProfileFollowers: '/profile/:name/followers', ProfileFollows: '/profile/:name/follows', ProfileList: '/profile/:name/lists/:rkey', diff --git a/src/screens/Hashtag.tsx b/src/screens/Hashtag.tsx index 09a1f2824..776cc585e 100644 --- a/src/screens/Hashtag.tsx +++ b/src/screens/Hashtag.tsx @@ -1,6 +1,5 @@ import React from 'react' import {ListRenderItemInfo, Pressable} from 'react-native' -import {atoms as a, useBreakpoints} from '#/alf' import {useFocusEffect} from '@react-navigation/native' import {useSetMinimalShellMode} from 'state/shell' import {ViewHeader} from 'view/com/util/ViewHeader' @@ -19,11 +18,11 @@ import {List} from 'view/com/util/List' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {sanitizeHandle} from 'lib/strings/handles' -import {CenteredView} from 'view/com/util/Views' import {ArrowOutOfBox_Stroke2_Corner0_Rounded} from '#/components/icons/ArrowOutOfBox' import {shareUrl} from 'lib/sharing' import {HITSLOP_10} from 'lib/constants' import {isNative} from 'platform/detection' +import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender' const renderItem = ({item}: ListRenderItemInfo<PostView>) => { return <Post post={item} /> @@ -38,12 +37,12 @@ export default function HashtagScreen({ }: NativeStackScreenProps<CommonNavigatorParams, 'Hashtag'>) { const {tag, author} = route.params const setMinimalShellMode = useSetMinimalShellMode() - const {gtMobile} = useBreakpoints() const {_} = useLingui() + const initialNumToRender = useInitialNumToRender() const [isPTR, setIsPTR] = React.useState(false) const fullTag = React.useMemo(() => { - return `#${tag.replaceAll('%23', '#')}` + return `#${decodeURIComponent(tag)}` }, [tag]) const queryParam = React.useMemo(() => { @@ -84,7 +83,7 @@ export default function HashtagScreen({ const onShare = React.useCallback(() => { const url = new URL('https://bsky.app') - url.pathname = `/hashtag/${tag}` + url.pathname = `/hashtag/${decodeURIComponent(tag)}` if (author) { url.searchParams.set('author', author) } @@ -103,7 +102,7 @@ export default function HashtagScreen({ }, [isFetching, hasNextPage, error, fetchNextPage]) return ( - <CenteredView style={a.flex_1} sideBorders={gtMobile}> + <> <ViewHeader title={headerTitle} subtitle={author ? _(msg`From @${sanitizedAuthor}`) : undefined} @@ -157,8 +156,10 @@ export default function HashtagScreen({ onRetry={fetchNextPage} /> } + initialNumToRender={initialNumToRender} + windowSize={11} /> )} - </CenteredView> + </> ) } diff --git a/src/state/dialogs/index.tsx b/src/state/dialogs/index.tsx index 9fc70c178..951105a50 100644 --- a/src/state/dialogs/index.tsx +++ b/src/state/dialogs/index.tsx @@ -1,8 +1,9 @@ import React from 'react' +import {SharedValue, useSharedValue} from 'react-native-reanimated' import {DialogControlRefProps} from '#/components/Dialog' import {Provider as GlobalDialogsProvider} from '#/components/dialogs/Context' -const DialogContext = React.createContext<{ +interface IDialogContext { /** * The currently active `useDialogControl` hooks. */ @@ -14,19 +15,24 @@ const DialogContext = React.createContext<{ * `useId`. */ openDialogs: React.MutableRefObject<Set<string>> -}>({ - activeDialogs: { - current: new Map(), - }, - openDialogs: { - current: new Set(), - }, -}) + /** + * 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 + */ + importantForAccessibility: SharedValue<'auto' | 'no-hide-descendants'> +} + +const DialogContext = React.createContext<IDialogContext>({} as IDialogContext) const DialogControlContext = React.createContext<{ closeAllDialogs(): boolean + setDialogIsOpen(id: string, isOpen: boolean): void }>({ closeAllDialogs: () => false, + setDialogIsOpen: () => {}, }) export function useDialogStateContext() { @@ -42,14 +48,46 @@ export function Provider({children}: React.PropsWithChildren<{}>) { Map<string, React.MutableRefObject<DialogControlRefProps>> >(new Map()) const openDialogs = React.useRef<Set<string>>(new Set()) + const importantForAccessibility = useSharedValue< + 'auto' | 'no-hide-descendants' + >('auto') const closeAllDialogs = React.useCallback(() => { activeDialogs.current.forEach(dialog => dialog.current.close()) return openDialogs.current.size > 0 }, []) - const context = React.useMemo(() => ({activeDialogs, openDialogs}), []) - const controls = React.useMemo(() => ({closeAllDialogs}), [closeAllDialogs]) + const setDialogIsOpen = React.useCallback( + (id: string, isOpen: boolean) => { + if (isOpen) { + openDialogs.current.add(id) + importantForAccessibility.value = 'no-hide-descendants' + } else { + openDialogs.current.delete(id) + if (openDialogs.current.size < 1) { + importantForAccessibility.value = 'auto' + } + } + }, + [importantForAccessibility], + ) + + const context = React.useMemo<IDialogContext>( + () => ({ + activeDialogs: { + current: new Map(), + }, + openDialogs: { + current: new Set(), + }, + importantForAccessibility, + }), + [importantForAccessibility], + ) + const controls = React.useMemo( + () => ({closeAllDialogs, setDialogIsOpen}), + [closeAllDialogs, setDialogIsOpen], + ) return ( <DialogContext.Provider value={context}> diff --git a/src/state/preferences/in-app-browser.tsx b/src/state/preferences/in-app-browser.tsx index 4f033db65..2398f1f81 100644 --- a/src/state/preferences/in-app-browser.tsx +++ b/src/state/preferences/in-app-browser.tsx @@ -5,6 +5,11 @@ import * as WebBrowser from 'expo-web-browser' import {isNative} from '#/platform/detection' import {useModalControls} from '../modals' import {usePalette} from 'lib/hooks/usePalette' +import { + isBskyRSSUrl, + isRelativeUrl, + createBskyAppAbsoluteUrl, +} from 'lib/strings/url-helpers' type StateContext = persisted.Schema['useInAppBrowser'] type SetContext = (v: persisted.Schema['useInAppBrowser']) => void @@ -57,6 +62,10 @@ export function useOpenLink() { const openLink = React.useCallback( (url: string, override?: boolean) => { + if (isBskyRSSUrl(url) && isRelativeUrl(url)) { + url = createBskyAppAbsoluteUrl(url) + } + if (isNative && !url.startsWith('mailto:')) { if (override === undefined && enabled === undefined) { openModal({ diff --git a/src/state/queries/preferences/const.ts b/src/state/queries/preferences/const.ts index 25d284998..53c9e482a 100644 --- a/src/state/queries/preferences/const.ts +++ b/src/state/queries/preferences/const.ts @@ -7,7 +7,7 @@ import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/ export const DEFAULT_HOME_FEED_PREFS: UsePreferencesQueryResponse['feedViewPrefs'] = { hideReplies: false, - hideRepliesByUnfollowed: false, + hideRepliesByUnfollowed: true, hideRepliesByLikeCount: 0, hideReposts: false, hideQuotePosts: false, diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts index 07198de77..37ef10ae0 100644 --- a/src/state/queries/preferences/index.ts +++ b/src/state/queries/preferences/index.ts @@ -169,7 +169,7 @@ export function usePreferencesSetBirthDateMutation() { return useMutation<void, unknown, {birthDate: Date}>({ mutationFn: async ({birthDate}: {birthDate: Date}) => { - await getAgent().setPersonalDetails({birthDate}) + await getAgent().setPersonalDetails({birthDate: birthDate.toISOString()}) // triggers a refetch await queryClient.invalidateQueries({ queryKey: preferencesQueryKey, diff --git a/src/view/com/auth/create/Step1.tsx b/src/view/com/auth/create/Step1.tsx index 4c7018485..1f6852f8c 100644 --- a/src/view/com/auth/create/Step1.tsx +++ b/src/view/com/auth/create/Step1.tsx @@ -4,7 +4,6 @@ import { Keyboard, StyleSheet, TouchableOpacity, - TouchableWithoutFeedback, View, } from 'react-native' import {CreateAccountState, CreateAccountDispatch, is18} from './state' @@ -19,7 +18,6 @@ import {ErrorMessage} from 'view/com/util/error/ErrorMessage' import {isWeb} from 'platform/detection' import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useModalControls} from '#/state/modals' import {logger} from '#/logger' import { FontAwesomeIcon, @@ -49,7 +47,6 @@ export function Step1({ }) { const pal = usePalette('default') const {_} = useLingui() - const {openModal} = useModalControls() const serverInputControl = useDialogControl() const onPressSelectService = React.useCallback(() => { @@ -57,10 +54,6 @@ export function Step1({ Keyboard.dismiss() }, [serverInputControl]) - const onPressWaitlist = React.useCallback(() => { - openModal({name: 'waitlist'}) - }, [openModal]) - const birthDate = React.useMemo(() => { return sanitizeDate(uiState.birthDate) }, [uiState.birthDate]) @@ -164,23 +157,7 @@ export function Step1({ </View> )} - {!uiState.inviteCode && uiState.isInviteCodeRequired ? ( - <View style={[s.flexRow, s.alignCenter]}> - <Text style={pal.text}> - <Trans>Don't have an invite code?</Trans>{' '} - </Text> - <TouchableWithoutFeedback - onPress={onPressWaitlist} - accessibilityLabel={_(msg`Join the waitlist.`)} - accessibilityHint=""> - <View style={styles.touchable}> - <Text style={pal.link}> - <Trans>Join the waitlist.</Trans> - </Text> - </View> - </TouchableWithoutFeedback> - </View> - ) : ( + {!uiState.isInviteCodeRequired || uiState.inviteCode ? ( <> <View style={s.pb20}> <Text @@ -260,7 +237,7 @@ export function Step1({ /> )} </> - )} + ) : undefined} </> )} </View> 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/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index 8da91c75c..100444ff5 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -20,7 +20,6 @@ import * as ReportModal from './report/Modal' import * as AppealLabelModal from './AppealLabel' import * as DeleteAccountModal from './DeleteAccount' import * as ChangeHandleModal from './ChangeHandle' -import * as WaitlistModal from './Waitlist' import * as InviteCodesModal from './InviteCodes' import * as AddAppPassword from './AddAppPasswords' import * as ContentFilteringSettingsModal from './ContentFilteringSettings' @@ -109,9 +108,6 @@ export function ModalsContainer() { } else if (activeModal?.name === 'change-handle') { snapPoints = ChangeHandleModal.snapPoints element = <ChangeHandleModal.Component {...activeModal} /> - } else if (activeModal?.name === 'waitlist') { - snapPoints = WaitlistModal.snapPoints - element = <WaitlistModal.Component /> } else if (activeModal?.name === 'invite-codes') { snapPoints = InviteCodesModal.snapPoints element = <InviteCodesModal.Component /> diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index 97a60be91..0ced894a1 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -22,7 +22,6 @@ import * as CropImageModal from './crop-image/CropImage.web' import * as AltTextImageModal from './AltImage' import * as EditImageModal from './EditImage' import * as ChangeHandleModal from './ChangeHandle' -import * as WaitlistModal from './Waitlist' import * as InviteCodesModal from './InviteCodes' import * as AddAppPassword from './AddAppPasswords' import * as ContentFilteringSettingsModal from './ContentFilteringSettings' @@ -105,8 +104,6 @@ function Modal({modal}: {modal: ModalIface}) { element = <ThreadgateModal.Component {...modal} /> } else if (modal.name === 'change-handle') { element = <ChangeHandleModal.Component {...modal} /> - } else if (modal.name === 'waitlist') { - element = <WaitlistModal.Component /> } else if (modal.name === 'invite-codes') { element = <InviteCodesModal.Component /> } else if (modal.name === 'add-app-password') { diff --git a/src/view/com/modals/Waitlist.tsx b/src/view/com/modals/Waitlist.tsx deleted file mode 100644 index 263dd27a2..000000000 --- a/src/view/com/modals/Waitlist.tsx +++ /dev/null @@ -1,190 +0,0 @@ -import React from 'react' -import { - ActivityIndicator, - StyleSheet, - TouchableOpacity, - View, -} from 'react-native' -import {TextInput} from './util' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import LinearGradient from 'react-native-linear-gradient' -import {Text} from '../util/text/Text' -import {s, gradients} from 'lib/styles' -import {usePalette} from 'lib/hooks/usePalette' -import {useTheme} from 'lib/ThemeContext' -import {ErrorMessage} from '../util/error/ErrorMessage' -import {cleanError} from 'lib/strings/errors' -import {Trans, msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useModalControls} from '#/state/modals' - -export const snapPoints = ['80%'] - -export function Component({}: {}) { - const pal = usePalette('default') - const theme = useTheme() - const {_} = useLingui() - const {closeModal} = useModalControls() - const [email, setEmail] = React.useState<string>('') - const [isEmailSent, setIsEmailSent] = React.useState<boolean>(false) - const [isProcessing, setIsProcessing] = React.useState<boolean>(false) - const [error, setError] = React.useState<string>('') - - const onPressSignup = async () => { - setError('') - setIsProcessing(true) - try { - const res = await fetch('https://bsky.app/api/waitlist', { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({email}), - }) - const resBody = await res.json() - if (resBody.success) { - setIsEmailSent(true) - } else { - setError( - resBody.error || - _(msg`Something went wrong. Check your email and try again.`), - ) - } - } catch (e: any) { - setError(cleanError(e)) - } - setIsProcessing(false) - } - const onCancel = () => { - closeModal() - } - - return ( - <View style={[styles.container, pal.view]}> - <View style={[styles.innerContainer, pal.view]}> - <Text type="title-xl" style={[styles.title, pal.text]}> - <Trans>Join the waitlist</Trans> - </Text> - <Text type="lg" style={[styles.description, pal.text]}> - <Trans> - Bluesky uses invites to build a healthier community. If you don't - know anybody with an invite, you can sign up for the waitlist and - we'll send one soon. - </Trans> - </Text> - <TextInput - style={[styles.textInput, pal.borderDark, pal.text, s.mb10, s.mt10]} - placeholder={_(msg`Enter your email`)} - placeholderTextColor={pal.textLight.color} - autoCapitalize="none" - autoCorrect={false} - keyboardAppearance={theme.colorScheme} - value={email} - onChangeText={setEmail} - onSubmitEditing={onPressSignup} - enterKeyHint="done" - accessible={true} - accessibilityLabel={_(msg`Email`)} - accessibilityHint={_( - msg`Input your email to get on the Bluesky waitlist`, - )} - /> - {error ? ( - <View style={s.mt10}> - <ErrorMessage message={error} style={styles.error} /> - </View> - ) : undefined} - {isProcessing ? ( - <View style={[styles.btn, s.mt10]}> - <ActivityIndicator /> - </View> - ) : isEmailSent ? ( - <View style={[styles.btn, s.mt10]}> - <FontAwesomeIcon - icon="check" - style={pal.text as FontAwesomeIconStyle} - /> - <Text style={[s.ml10, pal.text]}> - <Trans> - Your email has been saved! We'll be in touch soon. - </Trans> - </Text> - </View> - ) : ( - <> - <TouchableOpacity - onPress={onPressSignup} - accessibilityRole="button" - accessibilityHint={_( - msg`Confirms signing up ${email} to the waitlist`, - )}> - <LinearGradient - colors={[gradients.blueLight.start, gradients.blueLight.end]} - start={{x: 0, y: 0}} - end={{x: 1, y: 1}} - style={[styles.btn]}> - <Text type="button-lg" style={[s.white, s.bold]}> - <Trans>Join Waitlist</Trans> - </Text> - </LinearGradient> - </TouchableOpacity> - <TouchableOpacity - style={[styles.btn, s.mt10]} - onPress={onCancel} - accessibilityRole="button" - accessibilityLabel={_(msg`Cancel waitlist signup`)} - accessibilityHint={_( - msg`Exits signing up for waitlist with ${email}`, - )} - onAccessibilityEscape={onCancel}> - <Text type="button-lg" style={pal.textLight}> - <Trans>Cancel</Trans> - </Text> - </TouchableOpacity> - </> - )} - </View> - </View> - ) -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - innerContainer: { - paddingBottom: 20, - }, - title: { - textAlign: 'center', - marginTop: 12, - marginBottom: 12, - }, - description: { - textAlign: 'center', - paddingHorizontal: 22, - marginBottom: 10, - }, - textInput: { - borderWidth: 1, - borderRadius: 6, - paddingHorizontal: 16, - paddingVertical: 12, - fontSize: 20, - marginHorizontal: 20, - }, - btn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - borderRadius: 32, - padding: 14, - marginHorizontal: 20, - }, - error: { - borderRadius: 6, - marginHorizontal: 20, - marginBottom: 20, - }, -}) 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/com/posts/Feed.tsx b/src/view/com/posts/Feed.tsx index 54d8aa224..cd3e98785 100644 --- a/src/view/com/posts/Feed.tsx +++ b/src/view/com/posts/Feed.tsx @@ -32,6 +32,7 @@ import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {DiscoverFallbackHeader} from './DiscoverFallbackHeader' import {FALLBACK_MARKER_POST} from '#/lib/api/feed/home' +import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender' const LOADING_ITEM = {_reactKey: '__loading__'} const EMPTY_FEED_ITEM = {_reactKey: '__empty__'} @@ -84,6 +85,7 @@ let Feed = ({ const {_} = useLingui() const queryClient = useQueryClient() const {currentAccount} = useSession() + const initialNumToRender = useInitialNumToRender() const [isPTRing, setIsPTRing] = React.useState(false) const checkForNewRef = React.useRef<(() => void) | null>(null) const lastFetchRef = React.useRef<number>(Date.now()) @@ -327,6 +329,8 @@ let Feed = ({ desktopFixedHeight={ desktopFixedHeightOffset ? desktopFixedHeightOffset : true } + initialNumToRender={initialNumToRender} + windowSize={11} /> </View> ) diff --git a/src/view/com/util/EventStopper.tsx b/src/view/com/util/EventStopper.tsx index e743e89bb..8f5f5cf54 100644 --- a/src/view/com/util/EventStopper.tsx +++ b/src/view/com/util/EventStopper.tsx @@ -8,7 +8,14 @@ import {View, ViewStyle} from 'react-native' export function EventStopper({ children, style, -}: React.PropsWithChildren<{style?: ViewStyle | ViewStyle[]}>) { + onKeyDown = true, +}: React.PropsWithChildren<{ + style?: ViewStyle | ViewStyle[] + /** + * Default `true`. Set to `false` to allow onKeyDown to propagate + */ + onKeyDown?: boolean +}>) { const stop = (e: any) => { e.stopPropagation() } @@ -18,7 +25,7 @@ export function EventStopper({ onTouchEnd={stop} // @ts-ignore web only -prf onClick={stop} - onKeyDown={stop} + onKeyDown={onKeyDown ? stop : undefined} style={style}> {children} </View> diff --git a/src/view/com/util/Views.d.ts b/src/view/com/util/Views.d.ts index 6a90cc229..16713921f 100644 --- a/src/view/com/util/Views.d.ts +++ b/src/view/com/util/Views.d.ts @@ -5,4 +5,6 @@ export function CenteredView({ style, sideBorders, ...props -}: React.PropsWithChildren<ViewProps & {sideBorders?: boolean}>) +}: React.PropsWithChildren< + ViewProps & {sideBorders?: boolean; topBorder?: boolean} +>) diff --git a/src/view/com/util/Views.web.tsx b/src/view/com/util/Views.web.tsx index db3b9de0d..ae165077c 100644 --- a/src/view/com/util/Views.web.tsx +++ b/src/view/com/util/Views.web.tsx @@ -32,8 +32,11 @@ interface AddedProps { export function CenteredView({ style, sideBorders, + topBorder, ...props -}: React.PropsWithChildren<ViewProps & {sideBorders?: boolean}>) { +}: React.PropsWithChildren< + ViewProps & {sideBorders?: boolean; topBorder?: boolean} +>) { const pal = usePalette('default') const {isMobile} = useWebMediaQueries() if (!isMobile) { @@ -46,6 +49,12 @@ export function CenteredView({ }) style = addStyle(style, pal.border) } + if (topBorder) { + style = addStyle(style, { + borderTopWidth: 1, + }) + style = addStyle(style, pal.border) + } return <View style={style} {...props} /> } diff --git a/src/view/com/util/forms/DateInput.tsx b/src/view/com/util/forms/DateInput.tsx index c5f0afc8f..0104562aa 100644 --- a/src/view/com/util/forms/DateInput.tsx +++ b/src/view/com/util/forms/DateInput.tsx @@ -1,8 +1,5 @@ import React, {useState, useCallback} from 'react' import {StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native' -import DateTimePicker, { - DateTimePickerEvent, -} from '@react-native-community/datetimepicker' import { FontAwesomeIcon, FontAwesomeIconStyle, @@ -14,6 +11,7 @@ import {TypographyVariant} from 'lib/ThemeContext' import {useTheme} from 'lib/ThemeContext' import {usePalette} from 'lib/hooks/usePalette' import {getLocales} from 'expo-localization' +import DatePicker from 'react-native-date-picker' const LOCALE = getLocales()[0] @@ -43,11 +41,9 @@ export function DateInput(props: Props) { }, [props.handleAsUTC]) const onChangeInternal = useCallback( - (event: DateTimePickerEvent, date: Date | undefined) => { + (date: Date) => { setShow(false) - if (date) { - props.onChange(date) - } + props.onChange(date) }, [setShow, props], ) @@ -56,6 +52,10 @@ export function DateInput(props: Props) { setShow(true) }, [setShow]) + const onCancel = useCallback(() => { + setShow(false) + }, []) + return ( <View> {isAndroid && ( @@ -80,15 +80,17 @@ export function DateInput(props: Props) { </Button> )} {(isIOS || show) && ( - <DateTimePicker - testID={props.testID ? `${props.testID}-datepicker` : undefined} + <DatePicker + timeZoneOffsetInMinutes={0} + modal={isAndroid} + open={isAndroid} + theme={theme.colorScheme} + date={props.value} + onDateChange={onChangeInternal} + onConfirm={onChangeInternal} + onCancel={onCancel} mode="date" - timeZoneName={props.handleAsUTC ? 'Etc/UTC' : undefined} - display="spinner" - // @ts-ignore applies in iOS only -prf - themeVariant={theme.colorScheme} - value={props.value} - onChange={onChangeInternal} + testID={props.testID ? `${props.testID}-datepicker` : undefined} accessibilityLabel={props.accessibilityLabel} accessibilityHint={props.accessibilityHint} accessibilityLabelledBy={props.accessibilityLabelledBy} diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx index 09850a7f5..6f2ae55b2 100644 --- a/src/view/com/util/forms/PostDropdownBtn.tsx +++ b/src/view/com/util/forms/PostDropdownBtn.tsx @@ -1,5 +1,11 @@ import React, {memo} from 'react' -import {StyleProp, View, ViewStyle} from 'react-native' +import { + StyleProp, + ViewStyle, + Pressable, + View, + PressableProps, +} from 'react-native' import Clipboard from '@react-native-clipboard/clipboard' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {useNavigation} from '@react-navigation/native' @@ -12,10 +18,6 @@ import { import {toShareUrl} from 'lib/strings/url-helpers' import {useTheme} from 'lib/ThemeContext' import {shareUrl} from 'lib/sharing' -import { - NativeDropdown, - DropdownItem as NativeDropdownItem, -} from './NativeDropdown' import * as Toast from '../Toast' import {EventStopper} from '../EventStopper' import {useModalControls} from '#/state/modals' @@ -36,6 +38,19 @@ import {isWeb} from '#/platform/detection' import {richTextToString} from '#/lib/strings/rich-text-helpers' import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' +import {atoms as a, useTheme as useAlf, web} from '#/alf' +import * as Menu from '#/components/Menu' +import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' +import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter' +import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox' +import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash' +import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' +import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker' +import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble' +import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' +import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' +import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' + let PostDropdownBtn = ({ testID, postAuthor, @@ -45,6 +60,7 @@ let PostDropdownBtn = ({ richText, style, showAppealLabelItem, + hitSlop, }: { testID: string postAuthor: AppBskyActorDefs.ProfileViewBasic @@ -54,9 +70,11 @@ let PostDropdownBtn = ({ richText: RichTextAPI style?: StyleProp<ViewStyle> showAppealLabelItem?: boolean + hitSlop?: PressableProps['hitSlop'] }): React.ReactNode => { const {hasSession, currentAccount} = useSession() const theme = useTheme() + const alf = useAlf() const {_} = useLingui() const defaultCtrlColor = theme.palette.default.postCtrl const {openModal} = useModalControls() @@ -151,173 +169,189 @@ let PostDropdownBtn = ({ hidePost({uri: postUri}) }, [postUri, hidePost]) - const dropdownItems: NativeDropdownItem[] = [ - { - label: _(msg`Translate`), - onPress() { - onOpenTranslate() - }, - testID: 'postDropdownTranslateBtn', - icon: { - ios: { - name: 'character.book.closed', - }, - android: 'ic_menu_sort_alphabetically', - web: 'language', - }, - }, - { - label: _(msg`Copy post text`), - onPress() { - onCopyPostText() - }, - testID: 'postDropdownCopyTextBtn', - icon: { - ios: { - name: 'doc.on.doc', - }, - android: 'ic_menu_edit', - web: ['far', 'paste'], - }, - }, - { - label: isWeb ? _(msg`Copy link to post`) : _(msg`Share`), - onPress() { - const url = toShareUrl(href) - shareUrl(url) - }, - testID: 'postDropdownShareBtn', - icon: { - ios: { - name: 'square.and.arrow.up', - }, - android: 'ic_menu_share', - web: 'share', - }, - }, - hasSession && { - label: 'separator', - }, - hasSession && { - label: isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`), - onPress() { - onToggleThreadMute() - }, - testID: 'postDropdownMuteThreadBtn', - icon: { - ios: { - name: 'speaker.slash', - }, - android: 'ic_lock_silent_mode', - web: 'comment-slash', - }, - }, - hasSession && { - label: _(msg`Mute words & tags`), - onPress() { - mutedWordsDialogControl.open() - }, - testID: 'postDropdownMuteWordsBtn', - icon: { - ios: { - name: 'speaker.slash', - }, - android: 'ic_lock_silent_mode', - web: 'filter', - }, - }, - hasSession && - !isAuthor && - !isPostHidden && { - label: _(msg`Hide post`), - onPress() { - openModal({ - name: 'confirm', - title: _(msg`Hide this post?`), - message: _(msg`This will hide this post from your feeds.`), - onPressConfirm: onHidePost, - }) - }, - testID: 'postDropdownHideBtn', - icon: { - ios: { - name: 'eye.slash', - }, - android: 'ic_menu_delete', - web: ['far', 'eye-slash'], - }, - }, - { - label: 'separator', - }, - !isAuthor && - hasSession && { - label: _(msg`Report post`), - onPress() { - openModal({ - name: 'report', - uri: postUri, - cid: postCid, - }) - }, - testID: 'postDropdownReportBtn', - icon: { - ios: { - name: 'exclamationmark.triangle', - }, - android: 'ic_menu_report_image', - web: 'circle-exclamation', - }, - }, - isAuthor && { - label: _(msg`Delete post`), - onPress() { - openModal({ - name: 'confirm', - title: _(msg`Delete this post?`), - message: _(msg`Are you sure? This cannot be undone.`), - onPressConfirm: onDeletePost, - }) - }, - testID: 'postDropdownDeleteBtn', - icon: { - ios: { - name: 'trash', - }, - android: 'ic_menu_delete', - web: ['far', 'trash-can'], - }, - }, - showAppealLabelItem && { - label: 'separator', - }, - showAppealLabelItem && { - label: _(msg`Appeal content warning`), - onPress() { - openModal({name: 'appeal-label', uri: postUri, cid: postCid}) - }, - testID: 'postDropdownAppealBtn', - icon: { - ios: { - name: 'exclamationmark.triangle', - }, - android: 'ic_menu_report_image', - web: 'circle-exclamation', - }, - }, - ].filter(Boolean) as NativeDropdownItem[] - return ( - <EventStopper> - <NativeDropdown - testID={testID} - items={dropdownItems} - accessibilityLabel={_(msg`More post options`)} - accessibilityHint=""> - <View style={style}> - <FontAwesomeIcon icon="ellipsis" size={20} color={defaultCtrlColor} /> - </View> - </NativeDropdown> + <EventStopper onKeyDown={false}> + <Menu.Root> + <Menu.Trigger label={_(msg`Open post options menu`)}> + {({props, state}) => { + const styles = [ + style, + a.rounded_full, + (state.hovered || state.focused || state.pressed) && [ + web({outline: 0}), + alf.atoms.bg_contrast_25, + ], + ] + return isWeb ? ( + <View {...props} testID={testID} style={styles}> + <FontAwesomeIcon + icon="ellipsis" + size={20} + color={defaultCtrlColor} + style={{pointerEvents: 'none'}} + /> + </View> + ) : ( + <Pressable + {...props} + hitSlop={hitSlop} + testID={testID} + style={styles}> + <FontAwesomeIcon + icon="ellipsis" + size={20} + color={defaultCtrlColor} + style={{pointerEvents: 'none'}} + /> + </Pressable> + ) + }} + </Menu.Trigger> + + <Menu.Outer> + <Menu.Group> + <Menu.Item + testID="postDropdownTranslateBtn" + label={_(msg`Translate`)} + onPress={onOpenTranslate}> + <Menu.ItemText>{_(msg`Translate`)}</Menu.ItemText> + <Menu.ItemIcon icon={Translate} position="right" /> + </Menu.Item> + + <Menu.Item + testID="postDropdownCopyTextBtn" + label={_(msg`Copy post text`)} + onPress={onCopyPostText}> + <Menu.ItemText>{_(msg`Copy post text`)}</Menu.ItemText> + <Menu.ItemIcon icon={ClipboardIcon} position="right" /> + </Menu.Item> + + <Menu.Item + testID="postDropdownShareBtn" + label={isWeb ? _(msg`Copy link to post`) : _(msg`Share`)} + onPress={() => { + const url = toShareUrl(href) + shareUrl(url) + }}> + <Menu.ItemText> + {isWeb ? _(msg`Copy link to post`) : _(msg`Share`)} + </Menu.ItemText> + <Menu.ItemIcon icon={Share} position="right" /> + </Menu.Item> + </Menu.Group> + + {hasSession && ( + <> + <Menu.Divider /> + + <Menu.Group> + <Menu.Item + testID="postDropdownMuteThreadBtn" + label={ + isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`) + } + onPress={onToggleThreadMute}> + <Menu.ItemText> + {isThreadMuted + ? _(msg`Unmute thread`) + : _(msg`Mute thread`)} + </Menu.ItemText> + <Menu.ItemIcon + icon={isThreadMuted ? Unmute : Mute} + position="right" + /> + </Menu.Item> + + <Menu.Item + testID="postDropdownMuteWordsBtn" + label={_(msg`Mute words & tags`)} + onPress={() => mutedWordsDialogControl.open()}> + <Menu.ItemText>{_(msg`Mute words & tags`)}</Menu.ItemText> + <Menu.ItemIcon icon={Filter} position="right" /> + </Menu.Item> + + {!isAuthor && !isPostHidden && ( + <Menu.Item + testID="postDropdownHideBtn" + label={_(msg`Hide post`)} + onPress={() => { + openModal({ + name: 'confirm', + title: _(msg`Hide this post?`), + message: _( + msg`This will hide this post from your feeds.`, + ), + onPressConfirm: onHidePost, + }) + }}> + <Menu.ItemText>{_(msg`Hide post`)}</Menu.ItemText> + <Menu.ItemIcon icon={EyeSlash} position="right" /> + </Menu.Item> + )} + </Menu.Group> + </> + )} + + <Menu.Divider /> + + <Menu.Group> + {!isAuthor && ( + <Menu.Item + testID="postDropdownReportBtn" + label={_(msg`Report post`)} + onPress={() => { + openModal({ + name: 'report', + uri: postUri, + cid: postCid, + }) + }}> + <Menu.ItemText>{_(msg`Report post`)}</Menu.ItemText> + <Menu.ItemIcon icon={Warning} position="right" /> + </Menu.Item> + )} + + {isAuthor && ( + <Menu.Item + testID="postDropdownDeleteBtn" + label={_(msg`Delete post`)} + onPress={() => { + openModal({ + name: 'confirm', + title: _(msg`Delete this post?`), + message: _(msg`Are you sure? This cannot be undone.`), + onPressConfirm: onDeletePost, + }) + }}> + <Menu.ItemText>{_(msg`Delete post`)}</Menu.ItemText> + <Menu.ItemIcon icon={Trash} position="right" /> + </Menu.Item> + )} + + {showAppealLabelItem && ( + <> + <Menu.Divider /> + + <Menu.Item + testID="postDropdownAppealBtn" + label={_(msg`Appeal content warning`)} + onPress={() => { + openModal({ + name: 'appeal-label', + uri: postUri, + cid: postCid, + }) + }}> + <Menu.ItemText> + {_(msg`Appeal content warning`)} + </Menu.ItemText> + <Menu.ItemIcon icon={CircleInfo} position="right" /> + </Menu.Item> + </> + )} + </Menu.Group> + </Menu.Outer> + </Menu.Root> </EventStopper> ) } diff --git a/src/view/com/util/forms/SelectableBtn.tsx b/src/view/com/util/forms/SelectableBtn.tsx index f09d063a1..e577e155d 100644 --- a/src/view/com/util/forms/SelectableBtn.tsx +++ b/src/view/com/util/forms/SelectableBtn.tsx @@ -57,6 +57,7 @@ const styles = StyleSheet.create({ btn: { flexDirection: 'row', justifyContent: 'center', + flexGrow: 1, borderWidth: 1, borderLeftWidth: 0, paddingHorizontal: 10, diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx index bd21ddda2..1e26eecce 100644 --- a/src/view/com/util/post-ctrls/PostCtrls.tsx +++ b/src/view/com/util/post-ctrls/PostCtrls.tsx @@ -212,9 +212,7 @@ let PostCtrls = ({ style={[styles.btn]} onPress={onShare} accessibilityRole="button" - accessibilityLabel={`${ - post.viewer?.like ? _(msg`Unlike`) : _(msg`Like`) - } (${post.likeCount} ${pluralize(post.likeCount || 0, 'like')})`} + accessibilityLabel={`${_(msg`Share`)}`} accessibilityHint="" hitSlop={big ? HITSLOP_20 : HITSLOP_10}> <ArrowOutOfBox style={[defaultCtrlColor, styles.mt1]} width={22} /> @@ -231,6 +229,7 @@ let PostCtrls = ({ richText={richText} showAppealLabelItem={showAppealLabelItem} style={styles.btnPad} + hitSlop={big ? HITSLOP_20 : HITSLOP_10} /> </View> </View> 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/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx index def0333c7..c56ba941e 100644 --- a/src/view/shell/desktop/LeftNav.tsx +++ b/src/view/shell/desktop/LeftNav.tsx @@ -391,7 +391,7 @@ export function DesktopLeftNav() { <FontAwesomeIcon icon="hand" style={pal.text as FontAwesomeIconStyle} - size={isDesktop ? 20 : 26} + size={isDesktop ? 23 : 26} /> } label={_(msg`Moderation`)} diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx index d895d8851..76a7f8fb3 100644 --- a/src/view/shell/index.tsx +++ b/src/view/shell/index.tsx @@ -30,6 +30,8 @@ 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' +import Animated from 'react-native-reanimated' function ShellInner() { const isDrawerOpen = useIsDrawerOpen() @@ -53,6 +55,7 @@ function ShellInner() { const canGoBack = useNavigationState(state => !isStateAtTabRoot(state)) const {hasSession, currentAccount} = useSession() const closeAnyActiveElement = useCloseAnyActiveElement() + const {importantForAccessibility} = useDialogStateContext() // start undefined const currentAccountDid = React.useRef<string | undefined>(undefined) @@ -80,7 +83,9 @@ function ShellInner() { return ( <> - <View style={containerPadding}> + <Animated.View + style={containerPadding} + importantForAccessibility={importantForAccessibility}> <ErrorBoundary> <Drawer renderDrawerContent={renderDrawerContent} @@ -92,7 +97,7 @@ function ShellInner() { <TabsNavigator /> </Drawer> </ErrorBoundary> - </View> + </Animated.View> <Composer winHeight={winDim.height} /> <ModalsContainer /> <MutedWordsDialog /> |