diff options
author | Minseo Lee <itoupluk427@gmail.com> | 2024-03-13 10:30:07 +0900 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-03-13 10:30:07 +0900 |
commit | 3ead08ab2649533583c300904bbd85c250292014 (patch) | |
tree | 48366b4d1d847eb59f0187520d2c5fb4150bba3c | |
parent | 2456ca828fc4ba05a085fa03c6f7c37b3edcd45e (diff) | |
parent | 653240bc056236489e8a7882b7b6f902ed0885c2 (diff) | |
download | voidsky-3ead08ab2649533583c300904bbd85c250292014.tar.zst |
Merge branch 'bluesky-social:main' into patch-3
53 files changed, 1566 insertions, 1373 deletions
diff --git a/assets/icons/camera_filled_stroke2_corner0_rounded.svg b/assets/icons/camera_filled_stroke2_corner0_rounded.svg new file mode 100644 index 000000000..fa0101cf0 --- /dev/null +++ b/assets/icons/camera_filled_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M8.371 3.89A2 2 0 0 1 10.035 3h3.93a2 2 0 0 1 1.664.89L17.035 6H20a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h2.965L8.37 3.89ZM12 9a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Z" clip-rule="evenodd"/></svg> \ No newline at end of file diff --git a/assets/icons/camera_stroke2_corner0_rounded.svg b/assets/icons/camera_stroke2_corner0_rounded.svg new file mode 100644 index 000000000..ce0c29ae5 --- /dev/null +++ b/assets/icons/camera_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M8.371 3.89A2 2 0 0 1 10.035 3h3.93a2 2 0 0 1 1.664.89L17.035 6H20a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h2.965L8.37 3.89ZM13.965 5h-3.93L8.63 7.11A2 2 0 0 1 6.965 8H4v11h16V8h-2.965a2 2 0 0 1-1.664-.89L13.965 5ZM12 11a2 2 0 1 0 0 4 2 2 0 0 0 0-4Zm-4 2a4 4 0 1 1 8 0 4 4 0 0 1-8 0Z" clip-rule="evenodd"/></svg> \ No newline at end of file diff --git a/assets/icons/dotGrid1x3Horizontal_stroke2_corner2_rounded.svg b/assets/icons/dotGrid1x3Horizontal_stroke2_corner2_rounded.svg new file mode 100644 index 000000000..c3b456b10 --- /dev/null +++ b/assets/icons/dotGrid1x3Horizontal_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M2 12a2 2 0 1 1 4 0 2 2 0 0 1-4 0Zm16 0a2 2 0 1 1 4 0 2 2 0 0 1-4 0Zm-6-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z" clip-rule="evenodd"/></svg> diff --git a/assets/icons/flag_stroke2_corner0_rounded.svg b/assets/icons/flag_stroke2_corner0_rounded.svg new file mode 100644 index 000000000..9f9cc5cdd --- /dev/null +++ b/assets/icons/flag_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M4 4a2 2 0 0 1 2-2h13.131c1.598 0 2.55 1.78 1.665 3.11L18.202 9l2.594 3.89c.886 1.33-.067 3.11-1.665 3.11H6v5a1 1 0 1 1-2 0V4Zm2 10h13.131l-2.593-3.89a2 2 0 0 1 0-2.22L19.13 4H6v10Z" clip-rule="evenodd"/></svg> \ No newline at end of file diff --git a/assets/icons/peopleRemove2_stroke2_corner0_rounded.svg b/assets/icons/peopleRemove2_stroke2_corner0_rounded.svg new file mode 100644 index 000000000..2e798cbe2 --- /dev/null +++ b/assets/icons/peopleRemove2_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#000" fill-rule="evenodd" d="M10 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM5.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM16 11a1 1 0 0 1 1-1h5a1 1 0 1 1 0 2h-5a1 1 0 0 1-1-1ZM3.678 19h12.644c-.71-2.909-3.092-5-6.322-5s-5.613 2.091-6.322 5Zm-2.174.906C1.917 15.521 5.242 12 10 12c4.758 0 8.083 3.521 8.496 7.906A1 1 0 0 1 17.5 21h-15a1 1 0 0 1-.996-1.094Z" clip-rule="evenodd"/></svg> \ No newline at end of file diff --git a/assets/icons/personCheck_stroke2_corner0_rounded.svg b/assets/icons/personCheck_stroke2_corner0_rounded.svg new file mode 100644 index 000000000..b3231c278 --- /dev/null +++ b/assets/icons/personCheck_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.679 19c.709-2.902 3.079-5 6.321-5a6.69 6.69 0 0 1 2.612.51 1 1 0 0 0 .776-1.844A8.687 8.687 0 0 0 12 12c-4.3 0-7.447 2.884-8.304 6.696-.29 1.29.767 2.304 1.902 2.304H11a1 1 0 1 0 0-2H5.679Zm14.835-4.857a1 1 0 0 1 .344 1.371l-3 5a1 1 0 0 1-1.458.286l-2-1.5a1 1 0 0 1 1.2-1.6l1.113.835 2.43-4.05a1 1 0 0 1 1.372-.342Z" clip-rule="evenodd"/></svg> \ No newline at end of file diff --git a/assets/icons/personX_stroke2_corner0_rounded.svg b/assets/icons/personX_stroke2_corner0_rounded.svg new file mode 100644 index 000000000..073015bc5 --- /dev/null +++ b/assets/icons/personX_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.679 19c.709-2.902 3.079-5 6.321-5 .302 0 .595.018.878.053a1 1 0 0 0 .243-1.985A9.235 9.235 0 0 0 12 12c-4.3 0-7.447 2.884-8.304 6.696-.29 1.29.767 2.304 1.902 2.304H12a1 1 0 1 0 0-2H5.679Zm9.614-3.707a1 1 0 0 1 1.414 0L18 16.586l1.293-1.293a1 1 0 0 1 1.414 1.414L19.414 18l1.293 1.293a1 1 0 0 1-1.414 1.414L18 19.414l-1.293 1.293a1 1 0 0 1-1.414-1.414L16.586 18l-1.293-1.293a1 1 0 0 1 0-1.414Z" clip-rule="evenodd"/></svg> \ No newline at end of file diff --git a/assets/icons/streamingLive_stroke2_corner0_rounded.svg b/assets/icons/streamingLive_stroke2_corner0_rounded.svg new file mode 100644 index 000000000..b6cdd34d7 --- /dev/null +++ b/assets/icons/streamingLive_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M4 4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2H4Zm8 12.5c1.253 0 2.197.609 2.674 1.5H9.326c.477-.891 1.42-1.5 2.674-1.5Zm0-2c2.404 0 4.235 1.475 4.822 3.5H20V6H4v12h3.178c.587-2.025 2.418-3.5 4.822-3.5Zm-1.25-3.75a1.25 1.25 0 1 1 2.5 0 1.25 1.25 0 0 1-2.5 0ZM12 7.5a3.25 3.25 0 1 0 0 6.5 3.25 3.25 0 0 0 0-6.5Zm5.75 2a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Z" clip-rule="evenodd"/></svg> diff --git a/package.json b/package.json index 2571fe772..6e17fcb02 100644 --- a/package.json +++ b/package.json @@ -179,7 +179,6 @@ "react-responsive": "^9.0.2", "rn-fetch-blob": "^0.12.0", "sentry-expo": "~7.0.1", - "statsig-react": "^1.36.0", "statsig-react-native-expo": "^4.6.1", "tippy.js": "^6.3.7", "tlds": "^1.234.0", diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts index fff3a4d8b..5088e3aac 100644 --- a/src/alf/atoms.ts +++ b/src/alf/atoms.ts @@ -1,3 +1,4 @@ +import {Platform} from 'react-native' import {web, native} from '#/alf/util/platform' import * as tokens from '#/alf/tokens' @@ -6,7 +7,7 @@ export const atoms = { * Positioning */ fixed: { - position: 'fixed', + position: Platform.select({web: 'fixed', native: 'absolute'}) as 'absolute', }, absolute: { position: 'absolute', @@ -111,6 +112,12 @@ export const atoms = { flex_row: { flexDirection: 'row', }, + flex_col_reverse: { + flexDirection: 'column-reverse', + }, + flex_row_reverse: { + flexDirection: 'row-reverse', + }, flex_wrap: { flexWrap: 'wrap', }, diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx index f0e7b7e82..5f6edeac7 100644 --- a/src/components/Dialog/index.tsx +++ b/src/components/Dialog/index.tsx @@ -75,6 +75,7 @@ export function Outer({ control, onClose, nativeOptions, + testID, }: React.PropsWithChildren<DialogOuterProps>) { const t = useTheme() const sheet = React.useRef<BottomSheet>(null) @@ -145,7 +146,8 @@ export function Outer({ accessibilityViewIsModal // Android importantForAccessibility="yes" - style={[a.absolute, a.inset_0]}> + style={[a.absolute, a.inset_0]} + testID={testID}> <BottomSheet enableDynamicSizing={!hasSnapPoints} enablePanDownToClose diff --git a/src/components/Dialog/types.ts b/src/components/Dialog/types.ts index 4fc60ec39..9e7ad3c04 100644 --- a/src/components/Dialog/types.ts +++ b/src/components/Dialog/types.ts @@ -46,6 +46,7 @@ export type DialogOuterProps = { sheet?: Omit<BottomSheetProps, 'children'> } webOptions?: {} + testID?: string } type DialogInnerPropsBase<T> = React.PropsWithChildren<ViewStyleProp> & T diff --git a/src/components/Link.tsx b/src/components/Link.tsx index 8c963909b..00e6a56f4 100644 --- a/src/components/Link.tsx +++ b/src/components/Link.tsx @@ -1,17 +1,13 @@ import React from 'react' import {GestureResponderEvent} from 'react-native' -import { - useLinkProps, - useNavigation, - StackActions, -} from '@react-navigation/native' +import {useLinkProps, StackActions} from '@react-navigation/native' import {sanitizeUrl} from '@braintree/sanitize-url' import {useInteractionState} from '#/components/hooks/useInteractionState' import {isWeb} from '#/platform/detection' import {useTheme, web, flatten, TextStyleProp, atoms as a} from '#/alf' import {Button, ButtonProps} from '#/components/Button' -import {AllNavigatorParams, NavigationProp} from '#/lib/routes/types' +import {AllNavigatorParams} from '#/lib/routes/types' import { convertBskyAppUrlIfNeeded, isExternalUrl, @@ -21,6 +17,7 @@ import {useModalControls} from '#/state/modals' import {router} from '#/routes' import {Text, TextProps} from '#/components/Typography' import {useOpenLink} from 'state/preferences/in-app-browser' +import {useNavigationDeduped} from 'lib/hooks/useNavigationDeduped' /** * Only available within a `Link`, since that inherits from `Button`. @@ -74,7 +71,7 @@ export function useLink({ }: BaseLinkProps & { displayText: string }) { - const navigation = useNavigation<NavigationProp>() + const navigation = useNavigationDeduped() const {href} = useLinkProps<AllNavigatorParams>({ to: typeof to === 'string' ? convertBskyAppUrlIfNeeded(sanitizeUrl(to)) : to, @@ -231,6 +228,7 @@ export function InlineLink({ onPress: outerOnPress, download, selectable, + label, ...rest }: InlineLinkProps) { const t = useTheme() @@ -258,7 +256,8 @@ export function InlineLink({ return ( <Text selectable={selectable} - label={href} + accessibilityHint="" + accessibilityLabel={label || href} {...rest} style={[ {color: t.palette.primary_500}, diff --git a/src/components/Lists.tsx b/src/components/Lists.tsx index c879f9411..8a6dfdc7c 100644 --- a/src/components/Lists.tsx +++ b/src/components/Lists.tsx @@ -9,9 +9,8 @@ import {cleanError} from 'lib/strings/errors' import {Button} from '#/components/Button' import {Text} from '#/components/Typography' import {StackActions} from '@react-navigation/native' -import {useNavigation} from '@react-navigation/core' -import {NavigationProp} from 'lib/routes/types' import {router} from '#/routes' +import {useNavigationDeduped} from 'lib/hooks/useNavigationDeduped' export function ListFooter({ isFetching, @@ -144,7 +143,7 @@ export function ListMaybePlaceholder({ notFoundType?: 'page' | 'results' onRetry?: () => Promise<unknown> }) { - const navigation = useNavigation<NavigationProp>() + const navigation = useNavigationDeduped() const t = useTheme() const {gtMobile, gtTablet} = useBreakpoints() const {_} = useLingui() diff --git a/src/components/Menu/index.tsx b/src/components/Menu/index.tsx index ee96a5667..9be9dd86b 100644 --- a/src/components/Menu/index.tsx +++ b/src/components/Menu/index.tsx @@ -1,5 +1,5 @@ import React from 'react' -import {View, Pressable} from 'react-native' +import {View, Pressable, ViewStyle, StyleProp} from 'react-native' import flattenReactChildren from 'react-keyed-flatten-children' import {atoms as a, useTheme} from '#/alf' @@ -16,6 +16,10 @@ import { ItemTextProps, ItemIconProps, } from '#/components/Menu/types' +import {Button, ButtonText} from '#/components/Button' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {isNative} from 'platform/detection' export {useDialogControl as useMenuControl} from '#/components/Dialog' @@ -68,7 +72,13 @@ export function Trigger({children, label}: TriggerProps) { }) } -export function Outer({children}: React.PropsWithChildren<{}>) { +export function Outer({ + children, + showCancel, +}: React.PropsWithChildren<{ + showCancel?: boolean + style?: StyleProp<ViewStyle> +}>) { const context = React.useContext(Context) return ( @@ -78,7 +88,10 @@ export function Outer({children}: React.PropsWithChildren<{}>) { {/* 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={[a.gap_lg]}> + {children} + {isNative && showCancel && <Cancel />} + </View> <View style={{height: a.gap_lg.gap}} /> </Dialog.ScrollableInner> </Context.Provider> @@ -185,6 +198,22 @@ export function Group({children, style}: GroupProps) { ) } +function Cancel() { + const {_} = useLingui() + const {control} = React.useContext(Context) + + return ( + <Button + label={_(msg`Close this dialog`)} + size="small" + variant="ghost" + color="secondary" + onPress={() => control.close()}> + <ButtonText>Cancel</ButtonText> + </Button> + ) +} + export function Divider() { return null } diff --git a/src/components/Menu/index.web.tsx b/src/components/Menu/index.web.tsx index 054e51b01..f4b03f680 100644 --- a/src/components/Menu/index.web.tsx +++ b/src/components/Menu/index.web.tsx @@ -1,6 +1,10 @@ +/* eslint-disable react/prop-types */ + import React from 'react' -import {View, Pressable} from 'react-native' +import {View, Pressable, ViewStyle, StyleProp} from 'react-native' import * as DropdownMenu from '@radix-ui/react-dropdown-menu' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' import * as Dialog from '#/components/Dialog' import {useInteractionState} from '#/components/hooks/useInteractionState' @@ -14,8 +18,10 @@ import { GroupProps, ItemTextProps, ItemIconProps, + RadixPassThroughTriggerProps, } from '#/components/Menu/types' import {Context} from '#/components/Menu/context' +import {Portal} from '#/components/Portal' export function useMenuControl(): Dialog.DialogControlProps { const id = React.useId() @@ -47,6 +53,7 @@ export function Root({ }: React.PropsWithChildren<{ control?: Dialog.DialogOuterProps['control'] }>) { + const {_} = useLingui() const defaultControl = useMenuControl() const context = React.useMemo<ContextType>( () => ({ @@ -67,6 +74,18 @@ export function Root({ return ( <Context.Provider value={context}> + {context.control.isOpen && ( + <Portal> + <Pressable + style={[a.fixed, a.inset_0, a.z_50]} + onPress={() => context.control.close()} + accessibilityHint="" + accessibilityLabel={_( + msg`Context menu backdrop, click to close the menu.`, + )} + /> + </Portal> + )} <DropdownMenu.Root open={context.control.isOpen} onOpenChange={onOpenChange}> @@ -76,7 +95,24 @@ export function Root({ ) } -export function Trigger({children, label, style}: TriggerProps) { +const RadixTriggerPassThrough = React.forwardRef( + ( + props: { + children: ( + props: RadixPassThroughTriggerProps & { + ref: React.Ref<any> + }, + ) => React.ReactNode + }, + ref, + ) => { + // @ts-expect-error Radix provides no types of this stuff + return props.children({...props, ref}) + }, +) +RadixTriggerPassThrough.displayName = 'RadixTriggerPassThrough' + +export function Trigger({children, label}: TriggerProps) { const {control} = React.useContext(Context) const { state: hovered, @@ -87,33 +123,42 @@ export function Trigger({children, label, style}: TriggerProps) { 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> + <RadixTriggerPassThrough> + {props => + children({ + isNative: false, + control, + state: { + hovered, + focused, + pressed: false, + }, + props: { + ...props, + // disable on web, use `onPress` + onPointerDown: () => false, + onPress: () => + control.isOpen ? control.close() : control.open(), + onFocus: onFocus, + onBlur: onBlur, + onMouseEnter, + onMouseLeave, + accessibilityLabel: label, + }, + }) + } + </RadixTriggerPassThrough> </DropdownMenu.Trigger> ) } -export function Outer({children}: React.PropsWithChildren<{}>) { +export function Outer({ + children, + style, +}: React.PropsWithChildren<{ + showCancel?: boolean + style?: StyleProp<ViewStyle> +}>) { const t = useTheme() return ( @@ -125,6 +170,7 @@ export function Outer({children}: React.PropsWithChildren<{}>) { a.p_xs, t.name === 'light' ? t.atoms.bg : t.atoms.bg_contrast_25, t.atoms.shadow_md, + style, ]}> {children} </View> diff --git a/src/components/Menu/types.ts b/src/components/Menu/types.ts index 2f52e6390..e710971ee 100644 --- a/src/components/Menu/types.ts +++ b/src/components/Menu/types.ts @@ -1,5 +1,9 @@ import React from 'react' -import {GestureResponderEvent, PressableProps} from 'react-native' +import { + GestureResponderEvent, + PressableProps, + AccessibilityProps, +} from 'react-native' import {Props as SVGIconProps} from '#/components/icons/common' import * as Dialog from '#/components/Dialog' @@ -9,7 +13,23 @@ export type ContextType = { control: Dialog.DialogOuterProps['control'] } -export type TriggerProps = ViewStyleProp & { +export type RadixPassThroughTriggerProps = { + id: string + type: 'button' + disabled: boolean + ['data-disabled']: boolean + ['data-state']: string + ['aria-controls']?: string + ['aria-haspopup']?: boolean + ['aria-expanded']?: AccessibilityProps['aria-expanded'] + onKeyDown: (e: React.KeyboardEvent) => void + /** + * Radix provides this, but we override on web to use `onPress` instead, + * which is less sensitive while scrolling. + */ + onPointerDown: PressableProps['onPointerDown'] +} +export type TriggerProps = { children(props: TriggerChildProps): React.ReactNode label: string } @@ -52,7 +72,14 @@ export type TriggerChildProps = */ pressed: false } - props: {} + props: RadixPassThroughTriggerProps & { + onPress: () => void + onFocus: () => void + onBlur: () => void + onMouseEnter: () => void + onMouseLeave: () => void + accessibilityLabel: string + } } export type ItemProps = React.PropsWithChildren< diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx index 3b245c440..1b9348d9c 100644 --- a/src/components/Prompt.tsx +++ b/src/components/Prompt.tsx @@ -1,11 +1,12 @@ import React from 'react' -import {View, PressableProps} from 'react-native' +import {View} from 'react-native' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {isNative} from '#/platform/detection' import {useTheme, atoms as a, useBreakpoints} from '#/alf' import {Text} from '#/components/Typography' -import {Button} from '#/components/Button' +import {Button, ButtonColor, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' @@ -22,8 +23,10 @@ const Context = React.createContext<{ export function Outer({ children, control, + testID, }: React.PropsWithChildren<{ control: Dialog.DialogOuterProps['control'] + testID?: string }>) { const {gtMobile} = useBreakpoints() const titleId = React.useId() @@ -35,7 +38,7 @@ export function Outer({ ) return ( - <Dialog.Outer control={control}> + <Dialog.Outer control={control} testID={testID}> <Context.Provider value={context}> <Dialog.Handle /> @@ -80,7 +83,10 @@ export function Actions({children}: React.PropsWithChildren<{}>) { a.w_full, a.gap_sm, a.justify_end, - gtMobile ? [a.flex_row] : [a.flex_col, a.pt_md, a.pb_4xl], + gtMobile + ? [a.flex_row, a.flex_row_reverse, a.justify_start] + : [a.flex_col], + isNative && [a.pb_4xl], ]}> {children} </View> @@ -89,18 +95,29 @@ export function Actions({children}: React.PropsWithChildren<{}>) { export function Cancel({ children, -}: React.PropsWithChildren<{onPress?: PressableProps['onPress']}>) { + cta, +}: React.PropsWithChildren<{ + /** + * Optional i18n string, used in lieu of `children` for simple buttons. If + * undefined (and `children` is undefined), it will default to "Cancel". + */ + cta?: string +}>) { const {_} = useLingui() const {gtMobile} = useBreakpoints() const {close} = Dialog.useDialogContext() + const onPress = React.useCallback(() => { + close() + }, [close]) + return ( <Button variant="solid" color="secondary" size={gtMobile ? 'small' : 'medium'} - label={_(msg`Cancel`)} - onPress={() => close()}> - {children} + label={cta || _(msg`Cancel`)} + onPress={onPress}> + {children ? children : <ButtonText>{cta || _(msg`Cancel`)}</ButtonText>} </Button> ) } @@ -108,22 +125,70 @@ export function Cancel({ export function Action({ children, onPress, -}: React.PropsWithChildren<{onPress?: () => void}>) { + color = 'primary', + cta, + testID, +}: React.PropsWithChildren<{ + onPress: () => void + color?: ButtonColor + /** + * Optional i18n string, used in lieu of `children` for simple buttons. If + * undefined (and `children` is undefined), it will default to "Confirm". + */ + cta?: string + testID?: string +}>) { const {_} = useLingui() const {gtMobile} = useBreakpoints() const {close} = Dialog.useDialogContext() const handleOnPress = React.useCallback(() => { close() - onPress?.() + onPress() }, [close, onPress]) + return ( <Button variant="solid" - color="primary" + color={color} size={gtMobile ? 'small' : 'medium'} - label={_(msg`Confirm`)} - onPress={handleOnPress}> - {children} + label={cta || _(msg`Confirm`)} + onPress={handleOnPress} + testID={testID}> + {children ? children : <ButtonText>{cta || _(msg`Confirm`)}</ButtonText>} </Button> ) } + +export function Basic({ + control, + title, + description, + cancelButtonCta, + confirmButtonCta, + onConfirm, + confirmButtonColor, +}: React.PropsWithChildren<{ + control: Dialog.DialogOuterProps['control'] + title: string + description: string + cancelButtonCta?: string + confirmButtonCta?: string + onConfirm: () => void + confirmButtonColor?: ButtonColor +}>) { + return ( + <Outer control={control} testID="confirmModal"> + <Title>{title}</Title> + <Description>{description}</Description> + <Actions> + <Action + cta={confirmButtonCta} + onPress={onConfirm} + color={confirmButtonColor} + testID="confirmBtn" + /> + <Cancel cta={cancelButtonCta} /> + </Actions> + </Outer> + ) +} diff --git a/src/components/dialogs/MutedWords.tsx b/src/components/dialogs/MutedWords.tsx index 658ba2aae..dcdaccea3 100644 --- a/src/components/dialogs/MutedWords.tsx +++ b/src/components/dialogs/MutedWords.tsx @@ -254,9 +254,9 @@ function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) { </View> {isNative && <View style={{height: 20}} />} - - <Dialog.Close /> </View> + + <Dialog.Close /> </Dialog.ScrollableInner> ) } @@ -277,29 +277,16 @@ function MutedWordRow({ return ( <> - <Prompt.Outer control={control}> - <Prompt.Title> - <Trans>Are you sure?</Trans> - </Prompt.Title> - <Prompt.Description> - <Trans> - This will delete {word.value} from your muted words. You can always - add it back later. - </Trans> - </Prompt.Description> - <Prompt.Actions> - <Prompt.Cancel> - <ButtonText> - <Trans>Nevermind</Trans> - </ButtonText> - </Prompt.Cancel> - <Prompt.Action onPress={remove}> - <ButtonText> - <Trans>Remove</Trans> - </ButtonText> - </Prompt.Action> - </Prompt.Actions> - </Prompt.Outer> + <Prompt.Basic + control={control} + title={_(msg`Are you sure?`)} + description={_( + msg`This will delete ${word.value} from your muted words. You can always add it back later.`, + )} + onConfirm={remove} + confirmButtonCta={_(msg`Remove`)} + confirmButtonColor="negative" + /> <View style={[ diff --git a/src/components/icons/Camera.tsx b/src/components/icons/Camera.tsx new file mode 100644 index 000000000..ced8e7442 --- /dev/null +++ b/src/components/icons/Camera.tsx @@ -0,0 +1,9 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Camera_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M8.371 3.89A2 2 0 0 1 10.035 3h3.93a2 2 0 0 1 1.664.89L17.035 6H20a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h2.965L8.37 3.89ZM13.965 5h-3.93L8.63 7.11A2 2 0 0 1 6.965 8H4v11h16V8h-2.965a2 2 0 0 1-1.664-.89L13.965 5ZM12 11a2 2 0 1 0 0 4 2 2 0 0 0 0-4Zm-4 2a4 4 0 1 1 8 0 4 4 0 0 1-8 0Z', +}) + +export const Camera_Filled_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M8.371 3.89A2 2 0 0 1 10.035 3h3.93a2 2 0 0 1 1.664.89L17.035 6H20a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h2.965L8.37 3.89ZM12 9a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Z', +}) diff --git a/src/components/icons/DotGrid.tsx b/src/components/icons/DotGrid.tsx new file mode 100644 index 000000000..c50d7a440 --- /dev/null +++ b/src/components/icons/DotGrid.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const DotGrid_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M2 12a2 2 0 1 1 4 0 2 2 0 0 1-4 0Zm16 0a2 2 0 1 1 4 0 2 2 0 0 1-4 0Zm-6-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z', +}) diff --git a/src/components/icons/Flag.tsx b/src/components/icons/Flag.tsx new file mode 100644 index 000000000..d986db75a --- /dev/null +++ b/src/components/icons/Flag.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Flag_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M4 4a2 2 0 0 1 2-2h13.131c1.598 0 2.55 1.78 1.665 3.11L18.202 9l2.594 3.89c.886 1.33-.067 3.11-1.665 3.11H6v5a1 1 0 1 1-2 0V4Zm2 10h13.131l-2.593-3.89a2 2 0 0 1 0-2.22L19.13 4H6v10Z', +}) diff --git a/src/components/icons/Heart2.tsx b/src/components/icons/Heart2.tsx new file mode 100644 index 000000000..07f5a1d2c --- /dev/null +++ b/src/components/icons/Heart2.tsx @@ -0,0 +1,9 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Heart2_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M16.734 5.091c-1.238-.276-2.708.047-4.022 1.38a1 1 0 0 1-1.424 0C9.974 5.137 8.504 4.814 7.266 5.09c-1.263.282-2.379 1.206-2.92 2.556C3.33 10.18 4.252 14.84 12 19.348c7.747-4.508 8.67-9.168 7.654-11.7-.541-1.351-1.657-2.275-2.92-2.557Zm4.777 1.812c1.604 4-.494 9.69-9.022 14.47a1 1 0 0 1-.978 0C2.983 16.592.885 10.902 2.49 6.902c.779-1.942 2.414-3.334 4.342-3.764 1.697-.378 3.552.003 5.169 1.286 1.617-1.283 3.472-1.664 5.17-1.286 1.927.43 3.562 1.822 4.34 3.764Z', +}) + +export const Heart2_Filled_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M12.489 21.372c8.528-4.78 10.626-10.47 9.022-14.47-.779-1.941-2.414-3.333-4.342-3.763-1.697-.378-3.552.003-5.169 1.287-1.617-1.284-3.472-1.665-5.17-1.287-1.927.43-3.562 1.822-4.34 3.764-1.605 4 .493 9.69 9.021 14.47a1 1 0 0 0 .978 0Z', +}) diff --git a/src/components/icons/PeopleRemove2.tsx b/src/components/icons/PeopleRemove2.tsx new file mode 100644 index 000000000..3d16ed968 --- /dev/null +++ b/src/components/icons/PeopleRemove2.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const PeopleRemove2_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M10 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM5.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM16 11a1 1 0 0 1 1-1h5a1 1 0 1 1 0 2h-5a1 1 0 0 1-1-1ZM3.678 19h12.644c-.71-2.909-3.092-5-6.322-5s-5.613 2.091-6.322 5Zm-2.174.906C1.917 15.521 5.242 12 10 12c4.758 0 8.083 3.521 8.496 7.906A1 1 0 0 1 17.5 21h-15a1 1 0 0 1-.996-1.094Z', +}) diff --git a/src/components/icons/PersonCheck.tsx b/src/components/icons/PersonCheck.tsx new file mode 100644 index 000000000..097271d89 --- /dev/null +++ b/src/components/icons/PersonCheck.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const PersonCheck_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.679 19c.709-2.902 3.079-5 6.321-5a6.69 6.69 0 0 1 2.612.51 1 1 0 0 0 .776-1.844A8.687 8.687 0 0 0 12 12c-4.3 0-7.447 2.884-8.304 6.696-.29 1.29.767 2.304 1.902 2.304H11a1 1 0 1 0 0-2H5.679Zm14.835-4.857a1 1 0 0 1 .344 1.371l-3 5a1 1 0 0 1-1.458.286l-2-1.5a1 1 0 0 1 1.2-1.6l1.113.835 2.43-4.05a1 1 0 0 1 1.372-.342Z', +}) diff --git a/src/components/icons/PersonX.tsx b/src/components/icons/PersonX.tsx new file mode 100644 index 000000000..a015e1376 --- /dev/null +++ b/src/components/icons/PersonX.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const PersonX_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.679 19c.709-2.902 3.079-5 6.321-5 .302 0 .595.018.878.053a1 1 0 0 0 .243-1.985A9.235 9.235 0 0 0 12 12c-4.3 0-7.447 2.884-8.304 6.696-.29 1.29.767 2.304 1.902 2.304H12a1 1 0 1 0 0-2H5.679Zm9.614-3.707a1 1 0 0 1 1.414 0L18 16.586l1.293-1.293a1 1 0 0 1 1.414 1.414L19.414 18l1.293 1.293a1 1 0 0 1-1.414 1.414L18 19.414l-1.293 1.293a1 1 0 0 1-1.414-1.414L16.586 18l-1.293-1.293a1 1 0 0 1 0-1.414Z', +}) diff --git a/src/components/icons/StreamingLive.tsx b/src/components/icons/StreamingLive.tsx new file mode 100644 index 000000000..8ab5099da --- /dev/null +++ b/src/components/icons/StreamingLive.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const StreamingLive_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M4 4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2H4Zm8 12.5c1.253 0 2.197.609 2.674 1.5H9.326c.477-.891 1.42-1.5 2.674-1.5Zm0-2c2.404 0 4.235 1.475 4.822 3.5H20V6H4v12h3.178c.587-2.025 2.418-3.5 4.822-3.5Zm-1.25-3.75a1.25 1.25 0 1 1 2.5 0 1.25 1.25 0 0 1-2.5 0ZM12 7.5a3.25 3.25 0 1 0 0 6.5 3.25 3.25 0 0 0 0-6.5Zm5.75 2a1.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/useDedupe.ts b/src/lib/hooks/useDedupe.ts new file mode 100644 index 000000000..d9432cb2c --- /dev/null +++ b/src/lib/hooks/useDedupe.ts @@ -0,0 +1,17 @@ +import React from 'react' + +export const useDedupe = () => { + const canDo = React.useRef(true) + + return React.useRef((cb: () => unknown) => { + if (canDo.current) { + canDo.current = false + setTimeout(() => { + canDo.current = true + }, 250) + cb() + return true + } + return false + }).current +} diff --git a/src/lib/hooks/useNavigationDeduped.ts b/src/lib/hooks/useNavigationDeduped.ts new file mode 100644 index 000000000..d913f7f3d --- /dev/null +++ b/src/lib/hooks/useNavigationDeduped.ts @@ -0,0 +1,80 @@ +import React from 'react' +import {useNavigation} from '@react-navigation/core' +import {AllNavigatorParams, NavigationProp} from 'lib/routes/types' +import type {NavigationAction} from '@react-navigation/routers' +import {NavigationState} from '@react-navigation/native' +import {useDedupe} from 'lib/hooks/useDedupe' + +export type DebouncedNavigationProp = Pick< + NavigationProp, + | 'popToTop' + | 'push' + | 'navigate' + | 'canGoBack' + | 'replace' + | 'dispatch' + | 'goBack' + | 'getState' +> + +export function useNavigationDeduped() { + const navigation = useNavigation<NavigationProp>() + const dedupe = useDedupe() + + return React.useMemo( + (): DebouncedNavigationProp => ({ + // Types from @react-navigation/routers/lib/typescript/src/StackRouter.ts + push: <RouteName extends keyof AllNavigatorParams>( + ...args: undefined extends AllNavigatorParams[RouteName] + ? + | [screen: RouteName] + | [screen: RouteName, params: AllNavigatorParams[RouteName]] + : [screen: RouteName, params: AllNavigatorParams[RouteName]] + ) => { + dedupe(() => navigation.push(...args)) + }, + // Types from @react-navigation/core/src/types.tsx + navigate: <RouteName extends keyof AllNavigatorParams>( + ...args: RouteName extends unknown + ? undefined extends AllNavigatorParams[RouteName] + ? + | [screen: RouteName] + | [screen: RouteName, params: AllNavigatorParams[RouteName]] + : [screen: RouteName, params: AllNavigatorParams[RouteName]] + : never + ) => { + dedupe(() => navigation.navigate(...args)) + }, + // Types from @react-navigation/routers/lib/typescript/src/StackRouter.ts + replace: <RouteName extends keyof AllNavigatorParams>( + ...args: undefined extends AllNavigatorParams[RouteName] + ? + | [screen: RouteName] + | [screen: RouteName, params: AllNavigatorParams[RouteName]] + : [screen: RouteName, params: AllNavigatorParams[RouteName]] + ) => { + dedupe(() => navigation.replace(...args)) + }, + dispatch: ( + action: + | NavigationAction + | ((state: NavigationState) => NavigationAction), + ) => { + dedupe(() => navigation.dispatch(action)) + }, + popToTop: () => { + dedupe(() => navigation.popToTop()) + }, + goBack: () => { + dedupe(() => navigation.goBack()) + }, + canGoBack: () => { + return navigation.canGoBack() + }, + getState: () => { + return navigation.getState() + }, + }), + [dedupe, navigation], + ) +} diff --git a/src/lib/hooks/useOTAUpdate.ts b/src/lib/hooks/useOTAUpdate.ts index 53eab300e..d35179256 100644 --- a/src/lib/hooks/useOTAUpdate.ts +++ b/src/lib/hooks/useOTAUpdate.ts @@ -2,25 +2,9 @@ import * as Updates from 'expo-updates' import {useCallback, useEffect} from 'react' import {AppState} from 'react-native' import {logger} from '#/logger' -import {useModalControls} from '#/state/modals' -import {t} from '@lingui/macro' export function useOTAUpdate() { - const {openModal} = useModalControls() - // HELPER FUNCTIONS - const showUpdatePopup = useCallback(() => { - openModal({ - name: 'confirm', - title: t`Update Available`, - message: t`A new version of the app is available. Please update to continue using the app.`, - onPressConfirm: async () => { - Updates.reloadAsync().catch(err => { - throw err - }) - }, - }) - }, [openModal]) const checkForUpdate = useCallback(async () => { logger.debug('useOTAUpdate: Checking for update...') try { @@ -32,32 +16,26 @@ export function useOTAUpdate() { } // Otherwise fetch the update in the background, so even if the user rejects switching to latest version it will be done automatically on next relaunch. await Updates.fetchUpdateAsync() - // show a popup modal - showUpdatePopup() } catch (e) { logger.error('useOTAUpdate: Error while checking for update', { message: e, }) } - }, [showUpdatePopup]) - const updateEventListener = useCallback( - (event: Updates.UpdateEvent) => { - logger.debug('useOTAUpdate: Listening for update...') - if (event.type === Updates.UpdateEventType.ERROR) { - logger.error('useOTAUpdate: Error while listening for update', { - message: event.message, - }) - } else if (event.type === Updates.UpdateEventType.NO_UPDATE_AVAILABLE) { - // Handle no update available - // do nothing - } else if (event.type === Updates.UpdateEventType.UPDATE_AVAILABLE) { - // Handle update available - // open modal, ask for user confirmation, and reload the app - showUpdatePopup() - } - }, - [showUpdatePopup], - ) + }, []) + const updateEventListener = useCallback((event: Updates.UpdateEvent) => { + logger.debug('useOTAUpdate: Listening for update...') + if (event.type === Updates.UpdateEventType.ERROR) { + logger.error('useOTAUpdate: Error while listening for update', { + message: event.message, + }) + } else if (event.type === Updates.UpdateEventType.NO_UPDATE_AVAILABLE) { + // Handle no update available + // do nothing + } else if (event.type === Updates.UpdateEventType.UPDATE_AVAILABLE) { + // Handle update available + // open modal, ask for user confirmation, and reload the app + } + }, []) useEffect(() => { // ADD EVENT LISTENERS diff --git a/src/lib/statsig/statsig.web.tsx b/src/lib/statsig/statsig.web.tsx deleted file mode 100644 index d1c912019..000000000 --- a/src/lib/statsig/statsig.web.tsx +++ /dev/null @@ -1,75 +0,0 @@ -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/state/modals/index.tsx b/src/state/modals/index.tsx index 691add005..a18f6c87c 100644 --- a/src/state/modals/index.tsx +++ b/src/state/modals/index.tsx @@ -1,6 +1,5 @@ import React from 'react' import {AppBskyActorDefs, AppBskyGraphDefs, ModerationUI} from '@atproto/api' -import {StyleProp, ViewStyle} from 'react-native' import {Image as RNImage} from 'react-native-image-crop-picker' import {ImageModel} from '#/state/models/media/image' @@ -9,17 +8,6 @@ import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {EmbedPlayerSource} from '#/lib/strings/embed-player' import {ThreadgateSetting} from '../queries/threadgate' -export interface ConfirmModal { - name: 'confirm' - title: string - message: string | (() => JSX.Element) - onPressConfirm: () => void | Promise<void> - onPressCancel?: () => void | Promise<void> - confirmBtnText?: string - confirmBtnStyle?: StyleProp<ViewStyle> - cancelBtnText?: string -} - export interface EditProfileModal { name: 'edit-profile' profile: AppBskyActorDefs.ProfileViewDetailed @@ -225,7 +213,6 @@ export type Modal = | InviteCodesModal // Generic - | ConfirmModal | LinkWarningModal | EmbedConsentModal | InAppBrowserConsentModal diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 2855d4232..ef965b271 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -49,7 +49,7 @@ import {SuggestedLanguage} from './select-language/SuggestedLanguage' import {insertMentionAt} from 'lib/strings/mention-manip' import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useModals, useModalControls} from '#/state/modals' +import {useModals} from '#/state/modals' import {useRequireAltTextEnabled} from '#/state/preferences' import { useLanguagePrefs, @@ -63,6 +63,8 @@ import {emitPostCreated} from '#/state/events' import {ThreadgateSetting} from '#/state/queries/threadgate' import {logger} from '#/logger' import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo' +import * as Prompt from '#/components/Prompt' +import {useDialogStateControlContext} from 'state/dialogs' type Props = ComposerOpts export const ComposePost = observer(function ComposePost({ @@ -76,8 +78,7 @@ export const ComposePost = observer(function ComposePost({ }: Props) { const {currentAccount} = useSession() const {data: currentProfile} = useProfileQuery({did: currentAccount!.did}) - const {isModalActive, activeModals} = useModals() - const {openModal, closeModal} = useModalControls() + const {isModalActive} = useModals() const {closeComposer} = useComposerControls() const {track} = useAnalytics() const pal = usePalette('default') @@ -87,6 +88,9 @@ export const ComposePost = observer(function ComposePost({ const langPrefs = useLanguagePrefs() const setLangPrefs = useLanguagePrefsApi() const textInput = useRef<TextInputRef>(null) + const discardPromptControl = Prompt.usePromptControl() + const {closeAllDialogs} = useDialogStateControlContext() + const [isKeyboardVisible] = useIsKeyboardVisible({iosUseWillEvents: true}) const [isProcessing, setIsProcessing] = useState(false) const [processingState, setProcessingState] = useState('') @@ -134,27 +138,21 @@ export const ComposePost = observer(function ComposePost({ const onPressCancel = useCallback(() => { if (graphemeLength > 0 || !gallery.isEmpty) { - if (activeModals.some(modal => modal.name === 'confirm')) { - closeModal() - } + closeAllDialogs() if (Keyboard) { Keyboard.dismiss() } - openModal({ - name: 'confirm', - title: _(msg`Discard draft`), - onPressConfirm: onClose, - onPressCancel: () => { - closeModal() - }, - message: _(msg`Are you sure you'd like to discard this draft?`), - confirmBtnText: _(msg`Discard`), - confirmBtnStyle: {backgroundColor: colors.red4}, - }) + discardPromptControl.open() } else { onClose() } - }, [openModal, closeModal, activeModals, onClose, graphemeLength, gallery, _]) + }, [ + graphemeLength, + gallery.isEmpty, + closeAllDialogs, + discardPromptControl, + onClose, + ]) // android back button useEffect(() => { if (!isAndroid) { @@ -488,6 +486,15 @@ export const ComposePost = observer(function ComposePost({ <CharProgress count={graphemeLength} /> </View> </View> + + <Prompt.Basic + control={discardPromptControl} + title={_(msg`Discard draft?`)} + description={_(msg`Are you sure you'd like to discard this draft?`)} + onConfirm={onClose} + confirmButtonCta={_(msg`Discard`)} + confirmButtonColor="negative" + /> </KeyboardAvoidingView> ) }) diff --git a/src/view/com/composer/photos/OpenCameraBtn.tsx b/src/view/com/composer/photos/OpenCameraBtn.tsx index a288e7310..4353704d5 100644 --- a/src/view/com/composer/photos/OpenCameraBtn.tsx +++ b/src/view/com/composer/photos/OpenCameraBtn.tsx @@ -1,5 +1,6 @@ import React, {useCallback} from 'react' import {TouchableOpacity, StyleSheet} from 'react-native' +import * as MediaLibrary from 'expo-media-library' import { FontAwesomeIcon, FontAwesomeIconStyle, @@ -24,6 +25,8 @@ export function OpenCameraBtn({gallery}: Props) { const {track} = useAnalytics() const {_} = useLingui() const {requestCameraAccessIfNeeded} = useCameraPermission() + const [mediaPermissionRes, requestMediaPermission] = + MediaLibrary.usePermissions() const onPressTakePicture = useCallback(async () => { track('Composer:CameraOpened') @@ -31,6 +34,9 @@ export function OpenCameraBtn({gallery}: Props) { if (!(await requestCameraAccessIfNeeded())) { return } + if (!mediaPermissionRes?.granted && mediaPermissionRes?.canAskAgain) { + await requestMediaPermission() + } const img = await openCamera({ width: POST_IMG_MAX.width, @@ -38,12 +44,23 @@ export function OpenCameraBtn({gallery}: Props) { freeStyleCropEnabled: true, }) + // If we don't have permissions it's fine, we just wont save it. The post itself will still have access to + // the image even without these permissions + if (mediaPermissionRes) { + await MediaLibrary.createAssetAsync(img.path) + } gallery.add(img) } catch (err: any) { // ignore logger.warn('Error using camera', {error: err}) } - }, [gallery, track, requestCameraAccessIfNeeded]) + }, [ + gallery, + track, + requestCameraAccessIfNeeded, + mediaPermissionRes, + requestMediaPermission, + ]) const shouldShowCameraButton = isNative || isMobileWeb if (!shouldShowCameraButton) { diff --git a/src/view/com/feeds/FeedSourceCard.tsx b/src/view/com/feeds/FeedSourceCard.tsx index 9bd7238df..9300b4159 100644 --- a/src/view/com/feeds/FeedSourceCard.tsx +++ b/src/view/com/feeds/FeedSourceCard.tsx @@ -6,14 +6,11 @@ import {RichText} from '#/components/RichText' import {usePalette} from 'lib/hooks/usePalette' import {s} from 'lib/styles' import {UserAvatar} from '../util/UserAvatar' -import {useNavigation} from '@react-navigation/native' -import {NavigationProp} from 'lib/routes/types' import {pluralize} from 'lib/strings/helpers' import {AtUri} from '@atproto/api' import * as Toast from 'view/com/util/Toast' import {sanitizeHandle} from 'lib/strings/handles' import {logger} from '#/logger' -import {useModalControls} from '#/state/modals' import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import { @@ -26,6 +23,8 @@ import { import {useFeedSourceInfoQuery, FeedSourceInfo} from '#/state/queries/feed' import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' import {useTheme} from '#/alf' +import * as Prompt from '#/components/Prompt' +import {useNavigationDeduped} from 'lib/hooks/useNavigationDeduped' export function FeedSourceCard({ feedUri, @@ -86,8 +85,8 @@ export function FeedSourceCardLoaded({ const t = useTheme() const pal = usePalette('default') const {_} = useLingui() - const navigation = useNavigation<NavigationProp>() - const {openModal} = useModalControls() + const removePromptControl = Prompt.usePromptControl() + const navigation = useNavigationDeduped() const {isPending: isSavePending, mutateAsync: saveFeed} = useSaveFeedMutation() @@ -97,40 +96,45 @@ export function FeedSourceCardLoaded({ const isSaved = Boolean(preferences?.feeds?.saved?.includes(feed?.uri || '')) + const onSave = React.useCallback(async () => { + if (!feed) return + + try { + if (pinOnSave) { + await pinFeed({uri: feed.uri}) + } else { + await saveFeed({uri: feed.uri}) + } + Toast.show(_(msg`Added to my feeds`)) + } catch (e) { + Toast.show(_(msg`There was an issue contacting your server`)) + logger.error('Failed to save feed', {message: e}) + } + }, [_, feed, pinFeed, pinOnSave, saveFeed]) + + const onUnsave = React.useCallback(async () => { + if (!feed) return + + try { + await removeFeed({uri: feed.uri}) + // await item.unsave() + Toast.show(_(msg`Removed from my feeds`)) + } catch (e) { + Toast.show(_(msg`There was an issue contacting your server`)) + logger.error('Failed to unsave feed', {message: e}) + } + }, [_, feed, removeFeed]) + const onToggleSaved = React.useCallback(async () => { // Only feeds can be un/saved, lists are handled elsewhere if (feed?.type !== 'feed') return if (isSaved) { - openModal({ - name: 'confirm', - title: _(msg`Remove from my feeds`), - message: _(msg`Remove ${feed?.displayName} from my feeds?`), - onPressConfirm: async () => { - try { - await removeFeed({uri: feed.uri}) - // await item.unsave() - Toast.show(_(msg`Removed from my feeds`)) - } catch (e) { - Toast.show(_(msg`There was an issue contacting your server`)) - logger.error('Failed to unsave feed', {message: e}) - } - }, - }) + removePromptControl.open() } else { - try { - if (pinOnSave) { - await pinFeed({uri: feed.uri}) - } else { - await saveFeed({uri: feed.uri}) - } - Toast.show(_(msg`Added to my feeds`)) - } catch (e) { - Toast.show(_(msg`There was an issue contacting your server`)) - logger.error('Failed to save feed', {message: e}) - } + await onSave() } - }, [isSaved, openModal, feed, removeFeed, saveFeed, _, pinOnSave, pinFeed]) + }, [feed?.type, isSaved, removePromptControl, onSave]) /* * LOAD STATE @@ -168,25 +172,7 @@ export function FeedSourceCardLoaded({ accessibilityRole="button" accessibilityLabel={_(msg`Remove from my feeds`)} accessibilityHint="" - onPress={() => { - openModal({ - name: 'confirm', - title: _(msg`Remove from my feeds`), - message: _(msg`Remove this feed from my feeds?`), - onPressConfirm: async () => { - try { - await removeFeed({uri: feedUri}) - // await item.unsave() - Toast.show(_(msg`Removed from my feeds`)) - } catch (e) { - Toast.show( - _(msg`There was an issue contacting your server`), - ) - logger.error('Failed to unsave feed', {message: e}) - } - }, - }) - }} + onPress={onToggleSaved} hitSlop={15} style={styles.btn}> <FontAwesomeIcon @@ -200,89 +186,104 @@ export function FeedSourceCardLoaded({ ) return ( - <Pressable - testID={`feed-${feed.displayName}`} - accessibilityRole="button" - style={[styles.container, pal.border, style]} - onPress={() => { - if (feed.type === 'feed') { - navigation.push('ProfileFeed', { - name: feed.creatorDid, - rkey: new AtUri(feed.uri).rkey, - }) - } else if (feed.type === 'list') { - navigation.push('ProfileList', { - name: feed.creatorDid, - rkey: new AtUri(feed.uri).rkey, - }) - } - }} - key={feed.uri}> - <View style={[styles.headerContainer]}> - <View style={[s.mr10]}> - <UserAvatar type="algo" size={36} avatar={feed.avatar} /> - </View> - <View style={[styles.headerTextContainer]}> - <Text style={[pal.text, s.bold]} numberOfLines={3}> - {feed.displayName} - </Text> - <Text style={[pal.textLight]} numberOfLines={3}> - {feed.type === 'feed' ? ( - <Trans>Feed by {sanitizeHandle(feed.creatorHandle, '@')}</Trans> - ) : ( - <Trans>List by {sanitizeHandle(feed.creatorHandle, '@')}</Trans> - )} - </Text> - </View> - - {showSaveBtn && feed.type === 'feed' && ( - <View style={[s.justifyCenter]}> - <Pressable - testID={`feed-${feed.displayName}-toggleSave`} - disabled={isSavePending || isPinPending || isRemovePending} - accessibilityRole="button" - accessibilityLabel={ - isSaved ? _(msg`Remove from my feeds`) : _(msg`Add to my feeds`) - } - accessibilityHint="" - onPress={onToggleSaved} - hitSlop={15} - style={styles.btn}> - {isSaved ? ( - <FontAwesomeIcon - icon={['far', 'trash-can']} - size={19} - color={pal.colors.icon} - /> + <> + <Pressable + testID={`feed-${feed.displayName}`} + accessibilityRole="button" + style={[styles.container, pal.border, style]} + onPress={() => { + if (feed.type === 'feed') { + navigation.push('ProfileFeed', { + name: feed.creatorDid, + rkey: new AtUri(feed.uri).rkey, + }) + } else if (feed.type === 'list') { + navigation.push('ProfileList', { + name: feed.creatorDid, + rkey: new AtUri(feed.uri).rkey, + }) + } + }} + key={feed.uri}> + <View style={[styles.headerContainer]}> + <View style={[s.mr10]}> + <UserAvatar type="algo" size={36} avatar={feed.avatar} /> + </View> + <View style={[styles.headerTextContainer]}> + <Text style={[pal.text, s.bold]} numberOfLines={3}> + {feed.displayName} + </Text> + <Text style={[pal.textLight]} numberOfLines={3}> + {feed.type === 'feed' ? ( + <Trans>Feed by {sanitizeHandle(feed.creatorHandle, '@')}</Trans> ) : ( - <FontAwesomeIcon - icon="plus" - size={18} - color={pal.colors.link} - /> + <Trans>List by {sanitizeHandle(feed.creatorHandle, '@')}</Trans> )} - </Pressable> + </Text> </View> - )} - </View> - {showDescription && feed.description ? ( - <RichText - style={[t.atoms.text_contrast_high, styles.description]} - value={feed.description} - numberOfLines={3} - /> - ) : null} + {showSaveBtn && feed.type === 'feed' && ( + <View style={[s.justifyCenter]}> + <Pressable + testID={`feed-${feed.displayName}-toggleSave`} + disabled={isSavePending || isPinPending || isRemovePending} + accessibilityRole="button" + accessibilityLabel={ + isSaved + ? _(msg`Remove from my feeds`) + : _(msg`Add to my feeds`) + } + accessibilityHint="" + onPress={onToggleSaved} + hitSlop={15} + style={styles.btn}> + {isSaved ? ( + <FontAwesomeIcon + icon={['far', 'trash-can']} + size={19} + color={pal.colors.icon} + /> + ) : ( + <FontAwesomeIcon + icon="plus" + size={18} + color={pal.colors.link} + /> + )} + </Pressable> + </View> + )} + </View> + + {showDescription && feed.description ? ( + <RichText + style={[t.atoms.text_contrast_high, styles.description]} + value={feed.description} + numberOfLines={3} + /> + ) : null} - {showLikes && feed.type === 'feed' ? ( - <Text type="sm-medium" style={[pal.text, pal.textLight]}> - <Trans> - Liked by {feed.likeCount || 0}{' '} - {pluralize(feed.likeCount || 0, 'user')} - </Trans> - </Text> - ) : null} - </Pressable> + {showLikes && feed.type === 'feed' ? ( + <Text type="sm-medium" style={[pal.text, pal.textLight]}> + <Trans> + Liked by {feed.likeCount || 0}{' '} + {pluralize(feed.likeCount || 0, 'user')} + </Trans> + </Text> + ) : null} + </Pressable> + + <Prompt.Basic + control={removePromptControl} + title={_(msg`Remove from my feeds?`)} + description={_( + msg`Are you sure you want to remove ${feed.displayName} from your feeds?`, + )} + onConfirm={onUnsave} + confirmButtonCta={_(msg`Remove`)} + confirmButtonColor="negative" + /> + </> ) } diff --git a/src/view/com/modals/Confirm.tsx b/src/view/com/modals/Confirm.tsx deleted file mode 100644 index 307897fb8..000000000 --- a/src/view/com/modals/Confirm.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import React, {useState} from 'react' -import { - ActivityIndicator, - StyleSheet, - TouchableOpacity, - View, -} from 'react-native' -import {Text} from '../util/text/Text' -import {s, colors} from 'lib/styles' -import {ErrorMessage} from '../util/error/ErrorMessage' -import {cleanError} from 'lib/strings/errors' -import {usePalette} from 'lib/hooks/usePalette' -import {isWeb} from 'platform/detection' -import {useLingui} from '@lingui/react' -import {Trans, msg} from '@lingui/macro' -import type {ConfirmModal} from '#/state/modals' -import {useModalControls} from '#/state/modals' - -export const snapPoints = ['50%'] - -export function Component({ - title, - message, - onPressConfirm, - onPressCancel, - confirmBtnText, - confirmBtnStyle, - cancelBtnText, -}: ConfirmModal) { - const pal = usePalette('default') - const {_} = useLingui() - const {closeModal} = useModalControls() - const [isProcessing, setIsProcessing] = useState<boolean>(false) - const [error, setError] = useState<string>('') - const onPress = async () => { - setError('') - setIsProcessing(true) - try { - await onPressConfirm() - closeModal() - return - } catch (e: any) { - setError(cleanError(e)) - setIsProcessing(false) - } - } - return ( - <View testID="confirmModal" style={[pal.view, styles.container]}> - <Text type="title-xl" style={[pal.text, styles.title]}> - {title} - </Text> - {typeof message === 'string' ? ( - <Text type="xl" style={[pal.textLight, styles.description]}> - {message} - </Text> - ) : ( - message() - )} - {error ? ( - <View style={s.mt10}> - <ErrorMessage message={error} /> - </View> - ) : undefined} - <View style={s.flex1} /> - {isProcessing ? ( - <View style={[styles.btn, s.mt10]}> - <ActivityIndicator /> - </View> - ) : ( - <TouchableOpacity - testID="confirmBtn" - onPress={onPress} - style={[styles.btn, confirmBtnStyle]} - accessibilityRole="button" - accessibilityLabel={_(msg({message: 'Confirm', context: 'action'}))} - accessibilityHint=""> - <Text style={[s.white, s.bold, s.f18]}> - {confirmBtnText ?? <Trans context="action">Confirm</Trans>} - </Text> - </TouchableOpacity> - )} - {onPressCancel === undefined ? null : ( - <TouchableOpacity - testID="cancelBtn" - onPress={onPressCancel} - style={[styles.btnCancel, s.mt10]} - accessibilityRole="button" - accessibilityLabel={_(msg({message: 'Cancel', context: 'action'}))} - accessibilityHint=""> - <Text type="button-lg" style={pal.textLight}> - {cancelBtnText ?? <Trans context="action">Cancel</Trans>} - </Text> - </TouchableOpacity> - )} - </View> - ) -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - padding: 10, - paddingBottom: isWeb ? 0 : 60, - }, - title: { - textAlign: 'center', - marginBottom: 12, - }, - description: { - textAlign: 'center', - paddingHorizontal: 22, - marginBottom: 10, - }, - btn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - borderRadius: 32, - padding: 14, - marginTop: 22, - marginHorizontal: 44, - backgroundColor: colors.blue3, - }, - btnCancel: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - borderRadius: 32, - padding: 14, - marginHorizontal: 20, - }, -}) diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index 100444ff5..e03879c1d 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -6,7 +6,6 @@ import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop' import {usePalette} from 'lib/hooks/usePalette' import {useModals, useModalControls} from '#/state/modals' -import * as ConfirmModal from './Confirm' import * as EditProfileModal from './EditProfile' import * as RepostModal from './Repost' import * as SelfLabelModal from './SelfLabel' @@ -66,10 +65,7 @@ export function ModalsContainer() { let snapPoints: (string | number)[] = DEFAULT_SNAPPOINTS let element - if (activeModal?.name === 'confirm') { - snapPoints = ConfirmModal.snapPoints - element = <ConfirmModal.Component {...activeModal} /> - } else if (activeModal?.name === 'edit-profile') { + if (activeModal?.name === 'edit-profile') { snapPoints = EditProfileModal.snapPoints element = <EditProfileModal.Component {...activeModal} /> } else if (activeModal?.name === 'report') { diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index 0ced894a1..d72b7e485 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -7,7 +7,6 @@ import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock' import {useModals, useModalControls} from '#/state/modals' import type {Modal as ModalIface} from '#/state/modals' -import * as ConfirmModal from './Confirm' import * as EditProfileModal from './EditProfile' import * as ReportModal from './report/Modal' import * as AppealLabelModal from './AppealLabel' @@ -78,9 +77,7 @@ function Modal({modal}: {modal: ModalIface}) { } let element - if (modal.name === 'confirm') { - element = <ConfirmModal.Component {...modal} /> - } else if (modal.name === 'edit-profile') { + if (modal.name === 'edit-profile') { element = <EditProfileModal.Component {...modal} /> } else if (modal.name === 'report') { element = <ReportModal.Component {...modal} /> diff --git a/src/view/com/posts/FeedErrorMessage.tsx b/src/view/com/posts/FeedErrorMessage.tsx index 6d99c32f1..c52090f97 100644 --- a/src/view/com/posts/FeedErrorMessage.tsx +++ b/src/view/com/posts/FeedErrorMessage.tsx @@ -9,13 +9,13 @@ import {usePalette} from 'lib/hooks/usePalette' import {useNavigation} from '@react-navigation/native' import {NavigationProp} from 'lib/routes/types' import {logger} from '#/logger' -import {useModalControls} from '#/state/modals' import {msg as msgLingui, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {FeedDescriptor} from '#/state/queries/post-feed' import {EmptyState} from '../util/EmptyState' import {cleanError} from '#/lib/strings/errors' import {useRemoveFeedMutation} from '#/state/queries/preferences' +import * as Prompt from '#/components/Prompt' export enum KnownError { Block = 'Block', @@ -118,35 +118,29 @@ function FeedgenErrorMessage({ ) const [_, uri] = feedDesc.split('|') const [ownerDid] = safeParseFeedgenUri(uri) - const {openModal, closeModal} = useModalControls() + const removePromptControl = Prompt.usePromptControl() const {mutateAsync: removeFeed} = useRemoveFeedMutation() const onViewProfile = React.useCallback(() => { navigation.navigate('Profile', {name: ownerDid}) }, [navigation, ownerDid]) + const onPressRemoveFeed = React.useCallback(() => { + removePromptControl.open() + }, [removePromptControl]) + const onRemoveFeed = React.useCallback(async () => { - openModal({ - name: 'confirm', - title: _l(msgLingui`Remove feed`), - message: _l(msgLingui`Remove this feed from your saved feeds?`), - async onPressConfirm() { - try { - await removeFeed({uri}) - } catch (err) { - Toast.show( - _l( - msgLingui`There was an an issue removing this feed. Please check your internet connection and try again.`, - ), - ) - logger.error('Failed to remove feed', {message: err}) - } - }, - onPressCancel() { - closeModal() - }, - }) - }, [openModal, closeModal, uri, removeFeed, _l]) + try { + await removeFeed({uri}) + } catch (err) { + Toast.show( + _l( + msgLingui`There was an an issue removing this feed. Please check your internet connection and try again.`, + ), + ) + logger.error('Failed to remove feed', {message: err}) + } + }, [uri, removeFeed, _l]) const cta = React.useMemo(() => { switch (knownError) { @@ -179,27 +173,38 @@ function FeedgenErrorMessage({ }, [knownError, onViewProfile, onRemoveFeed, _l]) return ( - <View - style={[ - pal.border, - pal.viewLight, - { - borderTopWidth: 1, - paddingHorizontal: 20, - paddingVertical: 18, - gap: 12, - }, - ]}> - <Text style={pal.text}>{msg}</Text> + <> + <View + style={[ + pal.border, + pal.viewLight, + { + borderTopWidth: 1, + paddingHorizontal: 20, + paddingVertical: 18, + gap: 12, + }, + ]}> + <Text style={pal.text}>{msg}</Text> - {rawError?.message && ( - <Text style={pal.textLight}> - <Trans>Message from server: {rawError.message}</Trans> - </Text> - )} + {rawError?.message && ( + <Text style={pal.textLight}> + <Trans>Message from server: {rawError.message}</Trans> + </Text> + )} - {cta} - </View> + {cta} + </View> + + <Prompt.Basic + control={removePromptControl} + title={_l(msgLingui`Remove feed?`)} + description={_l(msgLingui`Remove this feed from your saved feeds`)} + onConfirm={onPressRemoveFeed} + confirmButtonCta={_l(msgLingui`Remove`)} + confirmButtonColor="negative" + /> + </> ) } diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx index 3e479d7b5..75e06eb9b 100644 --- a/src/view/com/profile/ProfileHeader.tsx +++ b/src/view/com/profile/ProfileHeader.tsx @@ -7,7 +7,6 @@ import { } from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {useNavigation} from '@react-navigation/native' -import {useQueryClient} from '@tanstack/react-query' import { AppBskyActorDefs, ModerationOpts, @@ -17,7 +16,7 @@ import { import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {NavigationProp} from 'lib/routes/types' -import {isNative, isWeb} from 'platform/detection' +import {isNative} from 'platform/detection' import {BlurView} from '../util/BlurView' import * as Toast from '../util/Toast' import {LoadingPlaceholder} from '../util/LoadingPlaceholder' @@ -28,14 +27,11 @@ import {UserAvatar} from '../util/UserAvatar' import {UserBanner} from '../util/UserBanner' import {ProfileHeaderAlerts} from '../util/moderation/ProfileHeaderAlerts' import {formatCount} from '../util/numeric/format' -import {NativeDropdown, DropdownItem} from '../util/forms/NativeDropdown' import {Link} from '../util/Link' import {ProfileHeaderSuggestedFollows} from './ProfileHeaderSuggestedFollows' import {useModalControls} from '#/state/modals' import {useLightboxControls, ProfileImageLightbox} from '#/state/lightbox' import { - RQKEY as profileQueryKey, - useProfileMuteMutationQueue, useProfileBlockMutationQueue, useProfileFollowMutationQueue, } from '#/state/queries/profile' @@ -46,9 +42,7 @@ import {BACK_HITSLOP} from 'lib/constants' import {isInvalidHandle, sanitizeHandle} from 'lib/strings/handles' import {makeProfileLink} from 'lib/routes/links' import {pluralize} from 'lib/strings/helpers' -import {toShareUrl} from 'lib/strings/url-helpers' import {sanitizeDisplayName} from 'lib/strings/display-names' -import {shareUrl} from 'lib/sharing' import {s, colors} from 'lib/styles' import {logger} from '#/logger' import {useSession} from '#/state/session' @@ -57,6 +51,8 @@ import {useRequireAuth} from '#/state/session' import {LabelInfo} from '../util/moderation/LabelInfo' import {useProfileShadow} from 'state/cache/profile-shadow' import {atoms as a} from '#/alf' +import {ProfileMenu} from 'view/com/profile/ProfileMenu' +import * as Prompt from '#/components/Prompt' let ProfileHeaderLoading = (_props: {}): React.ReactNode => { const pal = usePalette('default') @@ -108,20 +104,13 @@ let ProfileHeader = ({ const {isDesktop} = useWebMediaQueries() const [showSuggestedFollows, setShowSuggestedFollows] = React.useState(false) const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile) - const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile) - const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) - const queryClient = useQueryClient() + const [__, queueUnblock] = useProfileBlockMutationQueue(profile) + const unblockPromptControl = Prompt.usePromptControl() const moderation = useMemo( () => moderateProfile(profile, moderationOpts), [profile, moderationOpts], ) - const invalidateProfileQuery = React.useCallback(() => { - queryClient.invalidateQueries({ - queryKey: profileQueryKey(profile.did), - }) - }, [queryClient, profile.did]) - const onPressBack = React.useCallback(() => { if (navigation.canGoBack()) { navigation.goBack() @@ -189,204 +178,23 @@ let ProfileHeader = ({ }) }, [track, openModal, profile]) - const onPressShare = React.useCallback(() => { - track('ProfileHeader:ShareButtonClicked') - shareUrl(toShareUrl(makeProfileLink(profile))) - }, [track, profile]) - - const onPressAddRemoveLists = React.useCallback(() => { - track('ProfileHeader:AddToListsButtonClicked') - openModal({ - name: 'user-add-remove-lists', - subject: profile.did, - handle: profile.handle, - displayName: profile.displayName || profile.handle, - onAdd: invalidateProfileQuery, - onRemove: invalidateProfileQuery, - }) - }, [track, profile, openModal, invalidateProfileQuery]) - - const onPressMuteAccount = React.useCallback(async () => { - track('ProfileHeader:MuteAccountButtonClicked') - try { - await queueMute() - Toast.show(_(msg`Account muted`)) - } catch (e: any) { - if (e?.name !== 'AbortError') { - logger.error('Failed to mute account', {message: e}) - Toast.show(_(msg`There was an issue! ${e.toString()}`)) - } - } - }, [track, queueMute, _]) - - const onPressUnmuteAccount = React.useCallback(async () => { - track('ProfileHeader:UnmuteAccountButtonClicked') + const unblockAccount = React.useCallback(async () => { + track('ProfileHeader:UnblockAccountButtonClicked') try { - await queueUnmute() - Toast.show(_(msg`Account unmuted`)) + await queueUnblock() + Toast.show(_(msg`Account unblocked`)) } catch (e: any) { if (e?.name !== 'AbortError') { - logger.error('Failed to unmute account', {message: e}) + logger.error('Failed to unblock account', {message: e}) Toast.show(_(msg`There was an issue! ${e.toString()}`)) } } - }, [track, queueUnmute, _]) - - const onPressBlockAccount = React.useCallback(async () => { - track('ProfileHeader:BlockAccountButtonClicked') - openModal({ - name: 'confirm', - title: _(msg`Block Account`), - message: _( - msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`, - ), - onPressConfirm: async () => { - try { - await queueBlock() - Toast.show(_(msg`Account blocked`)) - } catch (e: any) { - if (e?.name !== 'AbortError') { - logger.error('Failed to block account', {message: e}) - Toast.show(_(msg`There was an issue! ${e.toString()}`)) - } - } - }, - }) - }, [track, queueBlock, openModal, _]) - - const onPressUnblockAccount = React.useCallback(async () => { - track('ProfileHeader:UnblockAccountButtonClicked') - openModal({ - name: 'confirm', - title: _(msg`Unblock Account`), - message: _( - msg`The account will be able to interact with you after unblocking.`, - ), - onPressConfirm: async () => { - try { - await queueUnblock() - Toast.show(_(msg`Account unblocked`)) - } catch (e: any) { - if (e?.name !== 'AbortError') { - logger.error('Failed to unblock account', {message: e}) - Toast.show(_(msg`There was an issue! ${e.toString()}`)) - } - } - }, - }) - }, [track, queueUnblock, openModal, _]) - - const onPressReportAccount = React.useCallback(() => { - track('ProfileHeader:ReportAccountButtonClicked') - openModal({ - name: 'report', - did: profile.did, - }) - }, [track, openModal, profile]) + }, [_, queueUnblock, track]) const isMe = React.useMemo( () => currentAccount?.did === profile.did, [currentAccount, profile], ) - const dropdownItems: DropdownItem[] = React.useMemo(() => { - let items: DropdownItem[] = [ - { - testID: 'profileHeaderDropdownShareBtn', - label: isWeb ? _(msg`Copy link to profile`) : _(msg`Share`), - onPress: onPressShare, - icon: { - ios: { - name: 'square.and.arrow.up', - }, - android: 'ic_menu_share', - web: 'share', - }, - }, - ] - if (hasSession) { - items.push({label: 'separator'}) - items.push({ - testID: 'profileHeaderDropdownListAddRemoveBtn', - label: _(msg`Add to Lists`), - onPress: onPressAddRemoveLists, - icon: { - ios: { - name: 'list.bullet', - }, - android: 'ic_menu_add', - web: 'list', - }, - }) - if (!isMe) { - if (!profile.viewer?.blocking) { - if (!profile.viewer?.mutedByList) { - items.push({ - testID: 'profileHeaderDropdownMuteBtn', - label: profile.viewer?.muted - ? _(msg`Unmute Account`) - : _(msg`Mute Account`), - onPress: profile.viewer?.muted - ? onPressUnmuteAccount - : onPressMuteAccount, - icon: { - ios: { - name: 'speaker.slash', - }, - android: 'ic_lock_silent_mode', - web: 'comment-slash', - }, - }) - } - } - if (!profile.viewer?.blockingByList) { - items.push({ - testID: 'profileHeaderDropdownBlockBtn', - label: profile.viewer?.blocking - ? _(msg`Unblock Account`) - : _(msg`Block Account`), - onPress: profile.viewer?.blocking - ? onPressUnblockAccount - : onPressBlockAccount, - icon: { - ios: { - name: 'person.fill.xmark', - }, - android: 'ic_menu_close_clear_cancel', - web: 'user-slash', - }, - }) - } - items.push({ - testID: 'profileHeaderDropdownReportBtn', - label: _(msg`Report Account`), - onPress: onPressReportAccount, - icon: { - ios: { - name: 'exclamationmark.triangle', - }, - android: 'ic_menu_report_image', - web: 'circle-exclamation', - }, - }) - } - } - return items - }, [ - isMe, - hasSession, - profile.viewer?.muted, - profile.viewer?.mutedByList, - profile.viewer?.blocking, - profile.viewer?.blockingByList, - onPressShare, - onPressUnmuteAccount, - onPressMuteAccount, - onPressUnblockAccount, - onPressBlockAccount, - onPressReportAccount, - onPressAddRemoveLists, - _, - ]) const blockHide = !isMe && (profile.viewer?.blocking || profile.viewer?.blockedBy) @@ -427,7 +235,7 @@ let ProfileHeader = ({ profile.viewer?.blockingByList ? null : ( <TouchableOpacity testID="unblockBtn" - onPress={onPressUnblockAccount} + onPress={() => unblockPromptControl.open()} style={[styles.btn, styles.mainBtn, pal.btn]} accessibilityRole="button" accessibilityLabel={_(msg`Unblock`)} @@ -516,17 +324,7 @@ let ProfileHeader = ({ )} </> ) : null} - {dropdownItems?.length ? ( - <NativeDropdown - testID="profileHeaderDropdownBtn" - items={dropdownItems} - accessibilityLabel={_(msg`More options`)} - accessibilityHint=""> - <View style={[styles.btn, styles.secondaryBtn, pal.btn]}> - <FontAwesomeIcon icon="ellipsis" size={20} style={[pal.text]} /> - </View> - </NativeDropdown> - ) : undefined} + <ProfileMenu profile={profile} /> </View> <View pointerEvents="none"> <Text @@ -670,6 +468,18 @@ let ProfileHeader = ({ /> </View> </TouchableWithoutFeedback> + <Prompt.Basic + control={unblockPromptControl} + title={_(msg`Unblock Account?`)} + description={_( + msg`The account will be able to interact with you after unblocking.`, + )} + onConfirm={unblockAccount} + confirmButtonCta={ + profile.viewer?.blocking ? _(msg`Unblock`) : _(msg`Block`) + } + confirmButtonColor="negative" + /> </View> ) } diff --git a/src/view/com/profile/ProfileMenu.tsx b/src/view/com/profile/ProfileMenu.tsx new file mode 100644 index 000000000..4153b819e --- /dev/null +++ b/src/view/com/profile/ProfileMenu.tsx @@ -0,0 +1,315 @@ +import React, {memo} from 'react' +import {TouchableOpacity} from 'react-native' +import {AppBskyActorDefs} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {useQueryClient} from '@tanstack/react-query' +import * as Toast from 'view/com/util/Toast' +import {EventStopper} from 'view/com/util/EventStopper' +import {useSession} from 'state/session' +import * as Menu from '#/components/Menu' +import {useTheme} from '#/alf' +import {usePalette} from 'lib/hooks/usePalette' +import {HITSLOP_10} from 'lib/constants' +import {shareUrl} from 'lib/sharing' +import {toShareUrl} from 'lib/strings/url-helpers' +import {makeProfileLink} from 'lib/routes/links' +import {useAnalytics} from 'lib/analytics/analytics' +import {useModalControls} from 'state/modals' +import { + RQKEY as profileQueryKey, + useProfileBlockMutationQueue, + useProfileFollowMutationQueue, + useProfileMuteMutationQueue, +} from 'state/queries/profile' +import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox' +import {ListSparkle_Stroke2_Corner0_Rounded as List} from '#/components/icons/ListSparkle' +import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' +import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker' +import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag' +import {PersonCheck_Stroke2_Corner0_Rounded as PersonCheck} from '#/components/icons/PersonCheck' +import {PersonX_Stroke2_Corner0_Rounded as PersonX} from '#/components/icons/PersonX' +import {PeopleRemove2_Stroke2_Corner0_Rounded as UserMinus} from '#/components/icons/PeopleRemove2' +import {logger} from '#/logger' +import {Shadow} from 'state/cache/types' +import * as Prompt from '#/components/Prompt' + +let ProfileMenu = ({ + profile, +}: { + profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> +}): React.ReactNode => { + const {_} = useLingui() + const {currentAccount, hasSession} = useSession() + const t = useTheme() + // TODO ALF this + const pal = usePalette('default') + const {track} = useAnalytics() + const {openModal} = useModalControls() + const queryClient = useQueryClient() + const isSelf = currentAccount?.did === profile.did + + const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile) + const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) + const [, queueUnfollow] = useProfileFollowMutationQueue(profile) + + const blockPromptControl = Prompt.usePromptControl() + + const invalidateProfileQuery = React.useCallback(() => { + queryClient.invalidateQueries({ + queryKey: profileQueryKey(profile.did), + }) + }, [queryClient, profile.did]) + + const onPressShare = React.useCallback(() => { + track('ProfileHeader:ShareButtonClicked') + shareUrl(toShareUrl(makeProfileLink(profile))) + }, [track, profile]) + + const onPressAddRemoveLists = React.useCallback(() => { + track('ProfileHeader:AddToListsButtonClicked') + openModal({ + name: 'user-add-remove-lists', + subject: profile.did, + handle: profile.handle, + displayName: profile.displayName || profile.handle, + onAdd: invalidateProfileQuery, + onRemove: invalidateProfileQuery, + }) + }, [track, profile, openModal, invalidateProfileQuery]) + + const onPressMuteAccount = React.useCallback(async () => { + if (profile.viewer?.muted) { + track('ProfileHeader:UnmuteAccountButtonClicked') + try { + await queueUnmute() + Toast.show(_(msg`Account unmuted`)) + } catch (e: any) { + if (e?.name !== 'AbortError') { + logger.error('Failed to unmute account', {message: e}) + Toast.show(_(msg`There was an issue! ${e.toString()}`)) + } + } + } else { + track('ProfileHeader:MuteAccountButtonClicked') + try { + await queueMute() + Toast.show(_(msg`Account muted`)) + } catch (e: any) { + if (e?.name !== 'AbortError') { + logger.error('Failed to mute account', {message: e}) + Toast.show(_(msg`There was an issue! ${e.toString()}`)) + } + } + } + }, [profile.viewer?.muted, track, queueUnmute, _, queueMute]) + + const blockAccount = React.useCallback(async () => { + if (profile.viewer?.blocking) { + track('ProfileHeader:UnblockAccountButtonClicked') + try { + await queueUnblock() + Toast.show(_(msg`Account unblocked`)) + } catch (e: any) { + if (e?.name !== 'AbortError') { + logger.error('Failed to unblock account', {message: e}) + Toast.show(_(msg`There was an issue! ${e.toString()}`)) + } + } + } else { + track('ProfileHeader:BlockAccountButtonClicked') + try { + await queueBlock() + Toast.show(_(msg`Account blocked`)) + } catch (e: any) { + if (e?.name !== 'AbortError') { + logger.error('Failed to block account', {message: e}) + Toast.show(_(msg`There was an issue! ${e.toString()}`)) + } + } + } + }, [profile.viewer?.blocking, track, _, queueUnblock, queueBlock]) + + const onPressUnfollowAccount = React.useCallback(async () => { + track('ProfileHeader:UnfollowButtonClicked') + try { + await queueUnfollow() + Toast.show(_(msg`Account unfollowed`)) + } catch (e: any) { + if (e?.name !== 'AbortError') { + logger.error('Failed to unfollow account', {message: e}) + Toast.show(_(msg`There was an issue! ${e.toString()}`)) + } + } + }, [_, queueUnfollow, track]) + + const onPressReportAccount = React.useCallback(() => { + track('ProfileHeader:ReportAccountButtonClicked') + openModal({ + name: 'report', + did: profile.did, + }) + }, [track, openModal, profile]) + + return ( + <EventStopper onKeyDown={false}> + <Menu.Root> + <Menu.Trigger label={_(`More options`)}> + {({props}) => { + return ( + <TouchableOpacity + {...props} + hitSlop={HITSLOP_10} + testID="profileHeaderDropdownBtn" + style={[ + { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 7, + borderRadius: 50, + marginLeft: 6, + paddingHorizontal: 14, + }, + pal.btn, + ]}> + <FontAwesomeIcon + icon="ellipsis" + size={20} + style={t.atoms.text} + /> + </TouchableOpacity> + ) + }} + </Menu.Trigger> + + <Menu.Outer style={{minWidth: 170}}> + <Menu.Group> + <Menu.Item + testID="profileHeaderDropdownShareBtn" + label={_(msg`Share`)} + onPress={onPressShare}> + <Menu.ItemText> + <Trans>Share</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={Share} /> + </Menu.Item> + </Menu.Group> + {hasSession && ( + <> + <Menu.Divider /> + <Menu.Group> + <Menu.Item + testID="profileHeaderDropdownListAddRemoveBtn" + label={_(msg`Add to Lists`)} + onPress={onPressAddRemoveLists}> + <Menu.ItemText> + <Trans>Add to Lists</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={List} /> + </Menu.Item> + {!isSelf && ( + <> + {profile.viewer?.following && + (profile.viewer.blocking || profile.viewer.blockedBy) && ( + <Menu.Item + testID="profileHeaderDropdownUnfollowBtn" + label={_(msg`Unfollow Account`)} + onPress={onPressUnfollowAccount}> + <Menu.ItemText> + <Trans>Unfollow Account</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={UserMinus} /> + </Menu.Item> + )} + {!profile.viewer?.blocking && + !profile.viewer?.mutedByList && ( + <Menu.Item + testID="profileHeaderDropdownMuteBtn" + label={ + profile.viewer?.muted + ? _(msg`Unmute Account`) + : _(msg`Mute Account`) + } + onPress={onPressMuteAccount}> + <Menu.ItemText> + {profile.viewer?.muted ? ( + <Trans>Unmute Account</Trans> + ) : ( + <Trans>Mute Account</Trans> + )} + </Menu.ItemText> + <Menu.ItemIcon + icon={profile.viewer?.muted ? Unmute : Mute} + /> + </Menu.Item> + )} + {!profile.viewer?.blockingByList && ( + <Menu.Item + testID="profileHeaderDropdownBlockBtn" + label={ + profile.viewer + ? _(msg`Unblock Account`) + : _(msg`Block Account`) + } + onPress={() => blockPromptControl.open()}> + <Menu.ItemText> + {profile.viewer?.blocking ? ( + <Trans>Unblock Account</Trans> + ) : ( + <Trans>Block Account</Trans> + )} + </Menu.ItemText> + <Menu.ItemIcon + icon={ + profile.viewer?.blocking ? PersonCheck : PersonX + } + /> + </Menu.Item> + )} + <Menu.Item + testID="profileHeaderDropdownReportBtn" + label={_(msg`Report Account`)} + onPress={onPressReportAccount}> + <Menu.ItemText> + <Trans>Report Account</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={Flag} /> + </Menu.Item> + </> + )} + </Menu.Group> + </> + )} + </Menu.Outer> + </Menu.Root> + + <Prompt.Basic + control={blockPromptControl} + title={ + profile.viewer?.blocking + ? _(msg`Unblock Account?`) + : _(msg`Block Account?`) + } + description={ + profile.viewer?.blocking + ? _( + msg`The account will be able to interact with you after unblocking.`, + ) + : _( + msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`, + ) + } + onConfirm={blockAccount} + confirmButtonCta={ + profile.viewer?.blocking ? _(msg`Unblock`) : _(msg`Block`) + } + confirmButtonColor={profile.viewer?.blocking ? undefined : 'negative'} + /> + </EventStopper> + ) +} + +ProfileMenu = memo(ProfileMenu) +export {ProfileMenu} diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx index e50fb7f09..f45622488 100644 --- a/src/view/com/util/Link.tsx +++ b/src/view/com/util/Link.tsx @@ -11,14 +11,9 @@ import { TouchableWithoutFeedback, TouchableOpacity, } from 'react-native' -import { - useLinkProps, - useNavigation, - StackActions, -} from '@react-navigation/native' +import {useLinkProps, StackActions} from '@react-navigation/native' import {Text} from './text/Text' import {TypographyVariant} from 'lib/ThemeContext' -import {NavigationProp} from 'lib/routes/types' import {router} from '../../../routes' import { convertBskyAppUrlIfNeeded, @@ -32,6 +27,10 @@ import FixedTouchableHighlight from '../pager/FixedTouchableHighlight' import {useModalControls} from '#/state/modals' import {useOpenLink} from '#/state/preferences/in-app-browser' import {WebAuxClickWrapper} from 'view/com/util/WebAuxClickWrapper' +import { + DebouncedNavigationProp, + useNavigationDeduped, +} from 'lib/hooks/useNavigationDeduped' type Event = | React.MouseEvent<HTMLAnchorElement, MouseEvent> @@ -65,7 +64,7 @@ export const Link = memo(function Link({ ...props }: Props) { const {closeModal} = useModalControls() - const navigation = useNavigation<NavigationProp>() + const navigation = useNavigationDeduped() const anchorHref = asAnchor ? sanitizeUrl(href) : undefined const openLink = useOpenLink() @@ -176,7 +175,7 @@ export const TextLink = memo(function TextLink({ navigationAction?: 'push' | 'replace' | 'navigate' } & TextProps) { const {...props} = useLinkProps({to: sanitizeUrl(href)}) - const navigation = useNavigation<NavigationProp>() + const navigation = useNavigationDeduped() const {openModal, closeModal} = useModalControls() const openLink = useOpenLink() @@ -335,7 +334,7 @@ const EXEMPT_PATHS = ['/robots.txt', '/security.txt', '/.well-known/'] // -prf function onPressInner( closeModal = () => {}, - navigation: NavigationProp, + navigation: DebouncedNavigationProp, href: string, navigationAction: 'push' | 'replace' | 'navigate' = 'push', openLink: (href: string) => void, diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index f673db1ee..413237397 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -1,9 +1,13 @@ import React, {memo, useMemo} from 'react' -import {Image, StyleSheet, View} from 'react-native' +import {Image, StyleSheet, TouchableOpacity, View} from 'react-native' import Svg, {Circle, Rect, Path} from 'react-native-svg' +import {Image as RNImage} from 'react-native-image-crop-picker' +import {useLingui} from '@lingui/react' +import {msg, Trans} from '@lingui/macro' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {HighPriorityImage} from 'view/com/util/images/Image' import {ModerationUI} from '@atproto/api' + +import {HighPriorityImage} from 'view/com/util/images/Image' import {openCamera, openCropper, openPicker} from '../../../lib/media/picker' import { usePhotoLibraryPermission, @@ -11,12 +15,16 @@ import { } from 'lib/hooks/usePermissions' import {colors} from 'lib/styles' import {usePalette} from 'lib/hooks/usePalette' -import {isWeb, isAndroid} from 'platform/detection' -import {Image as RNImage} from 'react-native-image-crop-picker' +import {isWeb, isAndroid, isNative} from 'platform/detection' import {UserPreviewLink} from './UserPreviewLink' -import {DropdownItem, NativeDropdown} from './forms/NativeDropdown' -import {useLingui} from '@lingui/react' -import {msg} from '@lingui/macro' +import * as Menu from '#/components/Menu' +import { + Camera_Stroke2_Corner0_Rounded as Camera, + Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled, +} from '#/components/icons/Camera' +import {StreamingLive_Stroke2_Corner0_Rounded as Library} from '#/components/icons/StreamingLive' +import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' +import {useTheme} from '#/alf' export type UserAvatarType = 'user' | 'algo' | 'list' @@ -196,6 +204,7 @@ let EditableUserAvatar = ({ avatar, onSelectNewAvatar, }: EditableUserAvatarProps): React.ReactNode => { + const t = useTheme() const pal = usePalette('default') const {_} = useLingui() const {requestCameraAccessIfNeeded} = useCameraPermission() @@ -216,118 +225,115 @@ let EditableUserAvatar = ({ } }, [type, size]) - const dropdownItems = useMemo( - () => - [ - !isWeb && { - testID: 'changeAvatarCameraBtn', - label: _(msg`Camera`), - icon: { - ios: { - name: 'camera', - }, - android: 'ic_menu_camera', - web: 'camera', - }, - onPress: async () => { - if (!(await requestCameraAccessIfNeeded())) { - return - } + const onOpenCamera = React.useCallback(async () => { + if (!(await requestCameraAccessIfNeeded())) { + return + } - onSelectNewAvatar( - await openCamera({ - width: 1000, - height: 1000, - cropperCircleOverlay: true, - }), - ) - }, - }, - { - testID: 'changeAvatarLibraryBtn', - label: _(msg`Library`), - icon: { - ios: { - name: 'photo.on.rectangle.angled', - }, - android: 'ic_menu_gallery', - web: 'gallery', - }, - onPress: async () => { - if (!(await requestPhotoAccessIfNeeded())) { - return - } + onSelectNewAvatar( + await openCamera({ + width: 1000, + height: 1000, + cropperCircleOverlay: true, + }), + ) + }, [onSelectNewAvatar, requestCameraAccessIfNeeded]) - const items = await openPicker({ - aspect: [1, 1], - }) - const item = items[0] - if (!item) { - return - } + const onOpenLibrary = React.useCallback(async () => { + if (!(await requestPhotoAccessIfNeeded())) { + return + } - const croppedImage = await openCropper({ - mediaType: 'photo', - cropperCircleOverlay: true, - height: item.height, - width: item.width, - path: item.path, - }) + const items = await openPicker({ + aspect: [1, 1], + }) + const item = items[0] + if (!item) { + return + } - onSelectNewAvatar(croppedImage) - }, - }, - !!avatar && { - label: 'separator', - }, - !!avatar && { - testID: 'changeAvatarRemoveBtn', - label: _(msg`Remove`), - icon: { - ios: { - name: 'trash', - }, - android: 'ic_delete', - web: ['far', 'trash-can'], - }, - onPress: async () => { - onSelectNewAvatar(null) - }, - }, - ].filter(Boolean) as DropdownItem[], - [ - avatar, - onSelectNewAvatar, - requestCameraAccessIfNeeded, - requestPhotoAccessIfNeeded, - _, - ], - ) + const croppedImage = await openCropper({ + mediaType: 'photo', + cropperCircleOverlay: true, + height: item.height, + width: item.width, + path: item.path, + }) + + onSelectNewAvatar(croppedImage) + }, [onSelectNewAvatar, requestPhotoAccessIfNeeded]) + + const onRemoveAvatar = React.useCallback(() => { + onSelectNewAvatar(null) + }, [onSelectNewAvatar]) return ( - <NativeDropdown - testID="changeAvatarBtn" - items={dropdownItems} - accessibilityLabel={_(msg`Image options`)} - accessibilityHint=""> - {avatar ? ( - <HighPriorityImage - testID="userAvatarImage" - style={aviStyle} - source={{uri: avatar}} - accessibilityRole="image" - /> - ) : ( - <DefaultAvatar type={type} size={size} /> - )} - <View style={[styles.editButtonContainer, pal.btn]}> - <FontAwesomeIcon - icon="camera" - size={12} - color={pal.text.color as string} - /> - </View> - </NativeDropdown> + <Menu.Root> + <Menu.Trigger label={_(msg`Edit avatar`)}> + {({props}) => ( + <TouchableOpacity {...props} activeOpacity={0.8}> + {avatar ? ( + <HighPriorityImage + testID="userAvatarImage" + style={aviStyle} + source={{uri: avatar}} + accessibilityRole="image" + /> + ) : ( + <DefaultAvatar type={type} size={size} /> + )} + <View style={[styles.editButtonContainer, pal.btn]}> + <CameraFilled height={14} width={14} style={t.atoms.text} /> + </View> + </TouchableOpacity> + )} + </Menu.Trigger> + <Menu.Outer showCancel> + <Menu.Group> + {isNative && ( + <Menu.Item + testID="changeAvatarCameraBtn" + label={_(msg`Upload from Camera`)} + onPress={onOpenCamera}> + <Menu.ItemText> + <Trans>Upload from Camera</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={Camera} /> + </Menu.Item> + )} + + <Menu.Item + testID="changeAvatarLibraryBtn" + label={_(msg`Upload from Library`)} + onPress={onOpenLibrary}> + <Menu.ItemText> + {isNative ? ( + <Trans>Upload from Library</Trans> + ) : ( + <Trans>Upload from Files</Trans> + )} + </Menu.ItemText> + <Menu.ItemIcon icon={Library} /> + </Menu.Item> + </Menu.Group> + {!!avatar && ( + <> + <Menu.Divider /> + <Menu.Group> + <Menu.Item + testID="changeAvatarRemoveBtn" + label={_(`Remove Avatar`)} + onPress={onRemoveAvatar}> + <Menu.ItemText> + <Trans>Remove Avatar</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={Trash} /> + </Menu.Item> + </Menu.Group> + </> + )} + </Menu.Outer> + </Menu.Root> ) } EditableUserAvatar = memo(EditableUserAvatar) diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx index cb47b6659..a5ddfee8a 100644 --- a/src/view/com/util/UserBanner.tsx +++ b/src/view/com/util/UserBanner.tsx @@ -1,21 +1,29 @@ -import React, {useMemo} from 'react' -import {StyleSheet, View} from 'react-native' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import React from 'react' +import {StyleSheet, TouchableOpacity, View} from 'react-native' import {ModerationUI} from '@atproto/api' import {Image} from 'expo-image' import {useLingui} from '@lingui/react' -import {msg} from '@lingui/macro' +import {msg, Trans} from '@lingui/macro' + import {colors} from 'lib/styles' import {useTheme} from 'lib/ThemeContext' +import {useTheme as useAlfTheme} from '#/alf' import {openCamera, openCropper, openPicker} from '../../../lib/media/picker' import { usePhotoLibraryPermission, useCameraPermission, } from 'lib/hooks/usePermissions' import {usePalette} from 'lib/hooks/usePalette' -import {isWeb, isAndroid} from 'platform/detection' +import {isAndroid, isNative} from 'platform/detection' import {Image as RNImage} from 'react-native-image-crop-picker' -import {NativeDropdown, DropdownItem} from './forms/NativeDropdown' +import {EventStopper} from 'view/com/util/EventStopper' +import * as Menu from '#/components/Menu' +import { + Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled, + Camera_Stroke2_Corner0_Rounded as Camera, +} from '#/components/icons/Camera' +import {StreamingLive_Stroke2_Corner0_Rounded as Library} from '#/components/icons/StreamingLive' +import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' export function UserBanner({ banner, @@ -28,118 +36,120 @@ export function UserBanner({ }) { const pal = usePalette('default') const theme = useTheme() + const t = useAlfTheme() const {_} = useLingui() const {requestCameraAccessIfNeeded} = useCameraPermission() const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() - const dropdownItems: DropdownItem[] = useMemo( - () => - [ - !isWeb && { - testID: 'changeBannerCameraBtn', - label: _(msg`Camera`), - icon: { - ios: { - name: 'camera', - }, - android: 'ic_menu_camera', - web: 'camera', - }, - onPress: async () => { - if (!(await requestCameraAccessIfNeeded())) { - return - } - onSelectNewBanner?.( - await openCamera({ - width: 3000, - height: 1000, - }), - ) - }, - }, - { - testID: 'changeBannerLibraryBtn', - label: _(msg`Library`), - icon: { - ios: { - name: 'photo.on.rectangle.angled', - }, - android: 'ic_menu_gallery', - web: 'gallery', - }, - onPress: async () => { - if (!(await requestPhotoAccessIfNeeded())) { - return - } - const items = await openPicker() - if (!items[0]) { - return - } + const onOpenCamera = React.useCallback(async () => { + if (!(await requestCameraAccessIfNeeded())) { + return + } + onSelectNewBanner?.( + await openCamera({ + width: 3000, + height: 1000, + }), + ) + }, [onSelectNewBanner, requestCameraAccessIfNeeded]) - onSelectNewBanner?.( - await openCropper({ - mediaType: 'photo', - path: items[0].path, - width: 3000, - height: 1000, - }), - ) - }, - }, - !!banner && { - testID: 'changeBannerRemoveBtn', - label: _(msg`Remove`), - icon: { - ios: { - name: 'trash', - }, - android: 'ic_delete', - web: ['far', 'trash-can'], - }, - onPress: () => { - onSelectNewBanner?.(null) - }, - }, - ].filter(Boolean) as DropdownItem[], - [ - banner, - onSelectNewBanner, - requestCameraAccessIfNeeded, - requestPhotoAccessIfNeeded, - _, - ], - ) + const onOpenLibrary = React.useCallback(async () => { + if (!(await requestPhotoAccessIfNeeded())) { + return + } + const items = await openPicker() + if (!items[0]) { + return + } + + onSelectNewBanner?.( + await openCropper({ + mediaType: 'photo', + path: items[0].path, + width: 3000, + height: 1000, + }), + ) + }, [onSelectNewBanner, requestPhotoAccessIfNeeded]) + + const onRemoveBanner = React.useCallback(() => { + onSelectNewBanner?.(null) + }, [onSelectNewBanner]) // setUserBanner is only passed as prop on the EditProfile component return onSelectNewBanner ? ( - <NativeDropdown - testID="changeBannerBtn" - items={dropdownItems} - accessibilityLabel={_(msg`Image options`)} - accessibilityHint=""> - {banner ? ( - <Image - testID="userBannerImage" - style={styles.bannerImage} - source={{uri: banner}} - accessible={true} - accessibilityIgnoresInvertColors - /> - ) : ( - <View - testID="userBannerFallback" - style={[styles.bannerImage, styles.defaultBanner]} - /> - )} - <View style={[styles.editButtonContainer, pal.btn]}> - <FontAwesomeIcon - icon="camera" - size={12} - style={{color: colors.white}} - color={pal.text.color as string} - /> - </View> - </NativeDropdown> + <EventStopper onKeyDown={false}> + <Menu.Root> + <Menu.Trigger label={_(msg`Edit avatar`)}> + {({props}) => ( + <TouchableOpacity {...props} activeOpacity={0.8}> + {banner ? ( + <Image + testID="userBannerImage" + style={styles.bannerImage} + source={{uri: banner}} + accessible={true} + accessibilityIgnoresInvertColors + /> + ) : ( + <View + testID="userBannerFallback" + style={[styles.bannerImage, styles.defaultBanner]} + /> + )} + <View style={[styles.editButtonContainer, pal.btn]}> + <CameraFilled height={14} width={14} style={t.atoms.text} /> + </View> + </TouchableOpacity> + )} + </Menu.Trigger> + <Menu.Outer showCancel> + <Menu.Group> + {isNative && ( + <Menu.Item + testID="changeBannerCameraBtn" + label={_(msg`Upload from Camera`)} + onPress={onOpenCamera}> + <Menu.ItemText> + <Trans>Upload from Camera</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={Camera} /> + </Menu.Item> + )} + + <Menu.Item + testID="changeBannerLibraryBtn" + label={_(msg`Upload from Library`)} + onPress={onOpenLibrary}> + <Menu.ItemText> + {isNative ? ( + <Trans>Upload from Library</Trans> + ) : ( + <Trans>Upload from Files</Trans> + )} + </Menu.ItemText> + <Menu.ItemIcon icon={Library} /> + </Menu.Item> + </Menu.Group> + {!!banner && ( + <> + <Menu.Divider /> + <Menu.Group> + <Menu.Item + testID="changeBannerRemoveBtn" + label={_(`Remove Banner`)} + onPress={onRemoveBanner}> + <Menu.ItemText> + <Trans>Remove Banner</Trans> + </Menu.ItemText> + <Menu.ItemIcon icon={Trash} /> + </Menu.Item> + </Menu.Group> + </> + )} + </Menu.Outer> + </Menu.Root> + </EventStopper> ) : banner && !((moderation?.blur && isAndroid) /* android crashes with blur */) ? ( <Image diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx index 6f2ae55b2..84a047c40 100644 --- a/src/view/com/util/forms/PostDropdownBtn.tsx +++ b/src/view/com/util/forms/PostDropdownBtn.tsx @@ -1,11 +1,5 @@ import React, {memo} from 'react' -import { - StyleProp, - ViewStyle, - Pressable, - View, - PressableProps, -} from 'react-native' +import {StyleProp, ViewStyle, Pressable, PressableProps} from 'react-native' import Clipboard from '@react-native-clipboard/clipboard' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {useNavigation} from '@react-navigation/native' @@ -20,6 +14,8 @@ import {useTheme} from 'lib/ThemeContext' import {shareUrl} from 'lib/sharing' import * as Toast from '../Toast' import {EventStopper} from '../EventStopper' +import {useDialogControl} from '#/components/Dialog' +import * as Prompt from '#/components/Prompt' import {useModalControls} from '#/state/modals' import {makeProfileLink} from '#/lib/routes/links' import {CommonNavigatorParams} from '#/lib/routes/types' @@ -38,7 +34,7 @@ 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 {atoms as a, useTheme as useAlf} 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' @@ -87,6 +83,8 @@ let PostDropdownBtn = ({ const openLink = useOpenLink() const navigation = useNavigation() const {mutedWordsDialogControl} = useGlobalDialogsControlContext() + const deletePromptControl = useDialogControl() + const hidePromptControl = useDialogControl() const rootUri = record.reply?.root?.uri || postUri const isThreadMuted = mutedThreads.includes(rootUri) @@ -174,29 +172,18 @@ let PostDropdownBtn = ({ <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> - ) : ( + return ( <Pressable {...props} hitSlop={hitSlop} testID={testID} - style={styles}> + style={[ + style, + a.rounded_full, + (state.hovered || state.pressed) && [ + alf.atoms.bg_contrast_50, + ], + ]}> <FontAwesomeIcon icon="ellipsis" size={20} @@ -274,16 +261,7 @@ let PostDropdownBtn = ({ <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, - }) - }}> + onPress={hidePromptControl.open}> <Menu.ItemText>{_(msg`Hide post`)}</Menu.ItemText> <Menu.ItemIcon icon={EyeSlash} position="right" /> </Menu.Item> @@ -315,14 +293,7 @@ let PostDropdownBtn = ({ <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, - }) - }}> + onPress={deletePromptControl.open}> <Menu.ItemText>{_(msg`Delete post`)}</Menu.ItemText> <Menu.ItemIcon icon={Trash} position="right" /> </Menu.Item> @@ -352,6 +323,25 @@ let PostDropdownBtn = ({ </Menu.Group> </Menu.Outer> </Menu.Root> + + <Prompt.Basic + control={deletePromptControl} + title={_(msg`Delete this post?`)} + description={_( + msg`If you remove this post, you won't be able to recover it.`, + )} + onConfirm={onDeletePost} + confirmButtonCta={_(msg`Delete`)} + confirmButtonColor="negative" + /> + + <Prompt.Basic + control={hidePromptControl} + title={_(msg`Hide this post?`)} + description={_(msg`This post will be hidden from feeds.`)} + onConfirm={onHidePost} + confirmButtonCta={_(msg`Hide`)} + /> </EventStopper> ) } diff --git a/src/view/com/util/load-latest/LoadLatestBtn.tsx b/src/view/com/util/load-latest/LoadLatestBtn.tsx index 5fad11760..f02e4a2bd 100644 --- a/src/view/com/util/load-latest/LoadLatestBtn.tsx +++ b/src/view/com/util/load-latest/LoadLatestBtn.tsx @@ -1,12 +1,13 @@ import React from 'react' import {StyleSheet, TouchableOpacity, View} from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import Animated from 'react-native-reanimated' +import {useMediaQuery} from 'react-responsive' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {colors} from 'lib/styles' import {HITSLOP_20} from 'lib/constants' import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' -import Animated from 'react-native-reanimated' const AnimatedTouchableOpacity = Animated.createAnimatedComponent(TouchableOpacity) import {isWeb} from 'platform/detection' @@ -26,6 +27,9 @@ export function LoadLatestBtn({ const {isDesktop, isTablet, isMobile, isTabletOrMobile} = useWebMediaQueries() const {fabMinimalShellTransform} = useMinimalShellMode() + // move button inline if it starts overlapping the left nav + const isTallViewport = useMediaQuery({minHeight: 700}) + // Adjust height of the fab if we have a session only on mobile web. If we don't have a session, we want to adjust // it on both tablet and mobile since we are showing the bottom bar (see createNativeStackNavigatorWithAuth) const showBottomBar = hasSession ? isMobile : isTabletOrMobile @@ -34,8 +38,11 @@ export function LoadLatestBtn({ <AnimatedTouchableOpacity style={[ styles.loadLatest, - isDesktop && styles.loadLatestDesktop, - isTablet && styles.loadLatestTablet, + isDesktop && + (isTallViewport + ? styles.loadLatestOutOfLine + : styles.loadLatestInline), + isTablet && styles.loadLatestInline, pal.borderDark, pal.view, showBottomBar && fabMinimalShellTransform, @@ -65,11 +72,11 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', }, - loadLatestTablet: { + loadLatestInline: { // @ts-ignore web only left: 'calc(50vw - 282px)', }, - loadLatestDesktop: { + loadLatestOutOfLine: { // @ts-ignore web only left: 'calc(50vw - 382px)', }, diff --git a/src/view/screens/AppPasswords.tsx b/src/view/screens/AppPasswords.tsx index dc439c367..800216169 100644 --- a/src/view/screens/AppPasswords.tsx +++ b/src/view/screens/AppPasswords.tsx @@ -29,6 +29,8 @@ import { } from '#/state/queries/app-passwords' import {ErrorScreen} from '../com/util/error/ErrorScreen' import {cleanError} from '#/lib/strings/errors' +import * as Prompt from '#/components/Prompt' +import {useDialogControl} from '#/components/Dialog' type Props = NativeStackScreenProps<CommonNavigatorParams, 'AppPasswords'> export function AppPasswords({}: Props) { @@ -212,23 +214,18 @@ function AppPassword({ }) { const pal = usePalette('default') const {_} = useLingui() - const {openModal} = useModalControls() + const control = useDialogControl() const {contentLanguages} = useLanguagePrefs() const deleteMutation = useAppPasswordDeleteMutation() const onDelete = React.useCallback(async () => { - openModal({ - name: 'confirm', - title: _(msg`Delete app password`), - message: _( - msg`Are you sure you want to delete the app password "${name}"?`, - ), - async onPressConfirm() { - await deleteMutation.mutateAsync({name}) - Toast.show(_(msg`App password deleted`)) - }, - }) - }, [deleteMutation, openModal, name, _]) + await deleteMutation.mutateAsync({name}) + Toast.show(_(msg`App password deleted`)) + }, [deleteMutation, name, _]) + + const onPress = React.useCallback(() => { + control.open() + }, [control]) const primaryLocale = contentLanguages.length > 0 ? contentLanguages[0] : 'en-US' @@ -237,7 +234,7 @@ function AppPassword({ <TouchableOpacity testID={testID} style={[styles.item, pal.border]} - onPress={onDelete} + onPress={onPress} accessibilityRole="button" accessibilityLabel={_(msg`Delete app password`)} accessibilityHint=""> @@ -260,6 +257,17 @@ function AppPassword({ </Text> </View> <FontAwesomeIcon icon={['far', 'trash-can']} style={styles.trashIcon} /> + + <Prompt.Basic + control={control} + title={_(msg`Delete app password?`)} + description={_( + msg`Are you sure you want to delete the app password "${name}"?`, + )} + onConfirm={onDelete} + confirmButtonCta={_(msg`Delete`)} + confirmButtonColor="negative" + /> </TouchableOpacity> ) } diff --git a/src/view/screens/ProfileFeed.tsx b/src/view/screens/ProfileFeed.tsx index d92c1cd83..579e77f57 100644 --- a/src/view/screens/ProfileFeed.tsx +++ b/src/view/screens/ProfileFeed.tsx @@ -1,11 +1,9 @@ import React, {useMemo, useCallback} from 'react' -import {Dimensions, StyleSheet, View} from 'react-native' +import {StyleSheet, View, Pressable} from 'react-native' import {NativeStackScreenProps} from '@react-navigation/native-stack' import {useIsFocused, useNavigation} from '@react-navigation/native' import {useQueryClient} from '@tanstack/react-query' import {usePalette} from 'lib/hooks/usePalette' -import {HeartIcon, HeartIconSolid} from 'lib/icons' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {CommonNavigatorParams} from 'lib/routes/types' import {makeRecordUri} from 'lib/strings/url-helpers' import {s} from 'lib/styles' @@ -13,7 +11,7 @@ import {FeedDescriptor} from '#/state/queries/post-feed' import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' import {ProfileSubpageHeader} from 'view/com/profile/ProfileSubpageHeader' import {Feed} from 'view/com/posts/Feed' -import {TextLink} from 'view/com/util/Link' +import {InlineLink} from '#/components/Link' import {ListRef} from 'view/com/util/List' import {Button} from 'view/com/util/forms/Button' import {Text} from 'view/com/util/text/Text' @@ -29,15 +27,10 @@ import {shareUrl} from 'lib/sharing' import {toShareUrl} from 'lib/strings/url-helpers' import {Haptics} from 'lib/haptics' import {useAnalytics} from 'lib/analytics/analytics' -import {NativeDropdown, DropdownItem} from 'view/com/util/forms/NativeDropdown' -import {useScrollHandlers} from '#/lib/ScrollContext' -import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' import {makeCustomFeedLink} from 'lib/routes/links' import {pluralize} from 'lib/strings/helpers' -import {CenteredView, ScrollView} from 'view/com/util/Views' +import {CenteredView} from 'view/com/util/Views' import {NavigationProp} from 'lib/routes/types' -import {sanitizeHandle} from 'lib/strings/handles' -import {makeProfileLink} from 'lib/routes/links' import {ComposeIcon2} from 'lib/icons' import {logger} from '#/logger' import {Trans, msg} from '@lingui/macro' @@ -59,9 +52,21 @@ import {useComposerControls} from '#/state/shell/composer' import {truncateAndInvalidate} from '#/state/queries/util' import {isNative} from '#/platform/detection' import {listenSoftReset} from '#/state/events' -import {atoms as a} from '#/alf' +import {atoms as a, useTheme} from '#/alf' +import * as Menu from '#/components/Menu' +import {HITSLOP_20} from '#/lib/constants' +import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' +import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' +import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' +import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' +import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox' +import { + Heart2_Stroke2_Corner0_Rounded as HeartOutline, + Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled, +} from '#/components/icons/Heart2' +import {Button as NewButton, ButtonText} from '#/components/Button' -const SECTION_TITLES = ['Posts', 'About'] +const SECTION_TITLES = ['Posts'] interface SectionRef { scrollToTop: () => void @@ -148,7 +153,7 @@ export function ProfileFeedScreenInner({ feedInfo: FeedSourceFeedInfo }) { const {_} = useLingui() - const pal = usePalette('default') + const t = useTheme() const {hasSession, currentAccount} = useSession() const {openModal} = useModalControls() const {openComposer} = useComposerControls() @@ -200,9 +205,11 @@ export function ProfileFeedScreenInner({ if (isSaved) { await removeFeed({uri: feedInfo.uri}) resetRemoveFeed() + Toast.show(_(msg`Removed from your feeds`)) } else { await saveFeed({uri: feedInfo.uri}) resetSaveFeed() + Toast.show(_(msg`Saved to your feeds`)) } } catch (err) { Toast.show( @@ -263,130 +270,132 @@ export function ProfileFeedScreenInner({ [feedSectionRef], ) - // render - // = - - const dropdownItems: DropdownItem[] = React.useMemo(() => { - return [ - hasSession && { - testID: 'feedHeaderDropdownToggleSavedBtn', - label: isSaved ? _(msg`Remove from my feeds`) : _(msg`Add to my feeds`), - onPress: isSavePending || isRemovePending ? undefined : onToggleSaved, - icon: isSaved - ? { - ios: { - name: 'trash', - }, - android: 'ic_delete', - web: ['far', 'trash-can'], - } - : { - ios: { - name: 'plus', - }, - android: '', - web: 'plus', - }, - }, - hasSession && { - testID: 'feedHeaderDropdownReportBtn', - label: _(msg`Report feed`), - onPress: onPressReport, - icon: { - ios: { - name: 'exclamationmark.triangle', - }, - android: 'ic_menu_report_image', - web: 'circle-exclamation', - }, - }, - { - testID: 'feedHeaderDropdownShareBtn', - label: _(msg`Share feed`), - onPress: onPressShare, - icon: { - ios: { - name: 'square.and.arrow.up', - }, - android: 'ic_menu_share', - web: 'share', - }, - }, - ].filter(Boolean) as DropdownItem[] - }, [ - hasSession, - onToggleSaved, - onPressReport, - onPressShare, - isSaved, - isSavePending, - isRemovePending, - _, - ]) - const renderHeader = useCallback(() => { return ( - <ProfileSubpageHeader - isLoading={false} - href={feedInfo.route.href} - title={feedInfo?.displayName} - avatar={feedInfo?.avatar} - isOwner={feedInfo.creatorDid === currentAccount?.did} - creator={ - feedInfo - ? {did: feedInfo.creatorDid, handle: feedInfo.creatorHandle} - : undefined - } - avatarType="algo"> - {feedInfo && hasSession && ( - <> - <Button - disabled={isSavePending || isRemovePending} - type="default" - label={isSaved ? _(msg`Unsave`) : _(msg`Save`)} - onPress={onToggleSaved} - style={styles.btn} - /> - <Button - testID={isPinned ? 'unpinBtn' : 'pinBtn'} - disabled={isPinPending || isUnpinPending} - type={isPinned ? 'default' : 'inverted'} - label={isPinned ? _(msg`Unpin`) : _(msg`Pin to home`)} - onPress={onTogglePinned} - style={styles.btn} - /> - </> - )} - <NativeDropdown - testID="headerDropdownBtn" - items={dropdownItems} - accessibilityLabel={_(msg`More options`)} - accessibilityHint=""> - <View style={[pal.viewLight, styles.btn]}> - <FontAwesomeIcon - icon="ellipsis" - size={20} - color={pal.colors.text} - /> + <> + <ProfileSubpageHeader + isLoading={false} + href={feedInfo.route.href} + title={feedInfo?.displayName} + avatar={feedInfo?.avatar} + isOwner={feedInfo.creatorDid === currentAccount?.did} + creator={ + feedInfo + ? {did: feedInfo.creatorDid, handle: feedInfo.creatorHandle} + : undefined + } + avatarType="algo"> + <View style={[a.flex_row, a.align_center, a.gap_sm]}> + {feedInfo && hasSession && ( + <NewButton + testID={isPinned ? 'unpinBtn' : 'pinBtn'} + disabled={isPinPending || isUnpinPending} + size="small" + variant="solid" + color={isPinned ? 'secondary' : 'primary'} + label={isPinned ? _(msg`Unpin from home`) : _(msg`Pin to home`)} + onPress={onTogglePinned}> + <ButtonText> + {isPinned ? _(msg`Unpin`) : _(msg`Pin to Home`)} + </ButtonText> + </NewButton> + )} + <Menu.Root> + <Menu.Trigger label={_(msg`Open feed options menu`)}> + {({props, state}) => { + return ( + <Pressable + {...props} + hitSlop={HITSLOP_20} + style={[ + a.justify_center, + a.align_center, + a.rounded_full, + {height: 36, width: 36}, + t.atoms.bg_contrast_50, + (state.hovered || state.pressed) && [ + t.atoms.bg_contrast_100, + ], + ]} + testID="headerDropdownBtn"> + <Ellipsis + size="lg" + fill={t.atoms.text_contrast_medium.color} + /> + </Pressable> + ) + }} + </Menu.Trigger> + + <Menu.Outer> + <Menu.Group> + {hasSession && ( + <> + <Menu.Item + disabled={isSavePending || isRemovePending} + testID="feedHeaderDropdownToggleSavedBtn" + label={ + isSaved + ? _(msg`Remove from my feeds`) + : _(msg`Save to my feeds`) + } + onPress={onToggleSaved}> + <Menu.ItemText> + {isSaved + ? _(msg`Remove from my feeds`) + : _(msg`Save to my feeds`)} + </Menu.ItemText> + <Menu.ItemIcon + icon={isSaved ? Trash : Plus} + position="right" + /> + </Menu.Item> + + <Menu.Item + testID="feedHeaderDropdownReportBtn" + label={_(msg`Report feed`)} + onPress={onPressReport}> + <Menu.ItemText>{_(msg`Report feed`)}</Menu.ItemText> + <Menu.ItemIcon icon={CircleInfo} position="right" /> + </Menu.Item> + </> + )} + + <Menu.Item + testID="feedHeaderDropdownShareBtn" + label={_(msg`Share feed`)} + onPress={onPressShare}> + <Menu.ItemText>{_(msg`Share feed`)}</Menu.ItemText> + <Menu.ItemIcon icon={Share} position="right" /> + </Menu.Item> + </Menu.Group> + </Menu.Outer> + </Menu.Root> </View> - </NativeDropdown> - </ProfileSubpageHeader> + </ProfileSubpageHeader> + <AboutSection + feedOwnerDid={feedInfo.creatorDid} + feedRkey={feedInfo.route.params.rkey} + feedInfo={feedInfo} + /> + </> ) }, [ _, hasSession, - pal, feedInfo, isPinned, onTogglePinned, onToggleSaved, - dropdownItems, currentAccount?.did, isPinPending, isRemovePending, isSavePending, isSaved, isUnpinPending, + onPressReport, + onPressShare, + t, ]) return ( @@ -405,18 +414,6 @@ export function ProfileFeedScreenInner({ isFocused={isScreenFocused && isFocused} /> )} - {({headerHeight, scrollElRef}) => ( - <AboutSection - feedOwnerDid={feedInfo.creatorDid} - feedRkey={feedInfo.route.params.rkey} - feedInfo={feedInfo} - headerHeight={headerHeight} - scrollElRef={ - scrollElRef as React.MutableRefObject<ScrollView | null> - } - isOwner={feedInfo.creatorDid === currentAccount?.did} - /> - )} </PagerWithHeader> {hasSession && ( <FAB @@ -505,21 +502,14 @@ function AboutSection({ feedOwnerDid, feedRkey, feedInfo, - headerHeight, - scrollElRef, - isOwner, }: { feedOwnerDid: string feedRkey: string feedInfo: FeedSourceFeedInfo - headerHeight: number - scrollElRef: React.MutableRefObject<ScrollView | null> - isOwner: boolean }) { + const t = useTheme() const pal = usePalette('default') const {_} = useLingui() - const scrollHandlers = useScrollHandlers() - const onScroll = useAnimatedScrollHandler(scrollHandlers) const [likeUri, setLikeUri] = React.useState(feedInfo.likeUri) const {hasSession} = useSession() const {track} = useAnalytics() @@ -555,24 +545,8 @@ function AboutSection({ }, [likeUri, isLiked, feedInfo, likeFeed, unlikeFeed, track, _]) return ( - <ScrollView - ref={scrollElRef} - onScroll={onScroll} - scrollEventThrottle={1} - contentContainerStyle={{ - paddingTop: headerHeight, - minHeight: Dimensions.get('window').height * 1.5, - }}> - <View - style={[ - { - borderTopWidth: 1, - paddingVertical: 20, - paddingHorizontal: 20, - gap: 12, - }, - pal.border, - ]}> + <View style={[styles.aboutSectionContainer]}> + <View style={[a.pt_sm]}> {feedInfo.description ? ( <RichText testID="listDescription" @@ -584,50 +558,34 @@ function AboutSection({ <Trans>No description</Trans> </Text> )} - <View style={{flexDirection: 'row', alignItems: 'center', gap: 10}}> - <Button - type="default" - testID="toggleLikeBtn" - accessibilityLabel={_(msg`Like this feed`)} - accessibilityHint="" - disabled={!hasSession || isLikePending || isUnlikePending} - onPress={onToggleLiked} - style={{paddingHorizontal: 10}}> - {isLiked ? ( - <HeartIconSolid size={19} style={s.likeColor} /> - ) : ( - <HeartIcon strokeWidth={3} size={19} style={pal.textLight} /> - )} - </Button> - {typeof likeCount === 'number' && ( - <TextLink - href={makeCustomFeedLink(feedOwnerDid, feedRkey, 'liked-by')} - text={_( - msg`Liked by ${likeCount} ${pluralize(likeCount, 'user')}`, - )} - style={[pal.textLight, s.semiBold]} - /> - )} - </View> - <Text type="md" style={[pal.textLight]} numberOfLines={1}> - {isOwner ? ( - <Trans>Created by you</Trans> + </View> + + <View style={[a.flex_row, a.gap_sm, a.align_center, a.pb_sm]}> + <NewButton + size="small" + variant="solid" + color="secondary" + shape="round" + label={isLiked ? _(msg`Unlike this feed`) : _(msg`Like this feed`)} + testID="toggleLikeBtn" + disabled={!hasSession || isLikePending || isUnlikePending} + onPress={onToggleLiked}> + {isLiked ? ( + <HeartFilled size="md" fill={s.likeColor.color} /> ) : ( - <Trans> - Created by{' '} - <TextLink - text={sanitizeHandle(feedInfo.creatorHandle, '@')} - href={makeProfileLink({ - did: feedInfo.creatorDid, - handle: feedInfo.creatorHandle, - })} - style={pal.textLight} - /> - </Trans> + <HeartOutline size="md" fill={t.atoms.text_contrast_medium.color} /> )} - </Text> + </NewButton> + {typeof likeCount === 'number' && ( + <InlineLink + label={_(msg`View users who like this feed`)} + to={makeCustomFeedLink(feedOwnerDid, feedRkey, 'liked-by')} + style={[t.atoms.text_contrast_medium, a.font_bold]}> + {_(msg`Liked by ${likeCount} ${pluralize(likeCount, 'user')}`)} + </InlineLink> + )} </View> - </ScrollView> + </View> ) } @@ -647,4 +605,9 @@ const styles = StyleSheet.create({ paddingVertical: 14, borderRadius: 6, }, + aboutSectionContainer: { + paddingVertical: 4, + paddingHorizontal: 16, + gap: 12, + }, }) diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx index 3c675ee0a..798611157 100644 --- a/src/view/screens/ProfileList.tsx +++ b/src/view/screens/ProfileList.tsx @@ -61,6 +61,8 @@ import {logger} from '#/logger' import {useAnalytics} from '#/lib/analytics/analytics' import {listenSoftReset} from '#/state/events' import {atoms as a, useTheme} from '#/alf' +import * as Prompt from '#/components/Prompt' +import {useDialogControl} from '#/components/Dialog' const SECTION_TITLES_CURATE = ['Posts', 'About'] const SECTION_TITLES_MOD = ['About'] @@ -234,7 +236,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { const {_} = useLingui() const navigation = useNavigation<NavigationProp>() const {currentAccount} = useSession() - const {openModal, closeModal} = useModalControls() + const {openModal} = useModalControls() const listMuteMutation = useListMuteMutation() const listBlockMutation = useListBlockMutation() const listDeleteMutation = useListDeleteMutation() @@ -251,6 +253,10 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { const {mutate: setSavedFeeds} = useSetSaveFeedsMutation() const {track} = useAnalytics() + const deleteListPromptControl = useDialogControl() + const subscribeMutePromptControl = useDialogControl() + const subscribeBlockPromptControl = useDialogControl() + const isPinned = preferences?.feeds?.pinned?.includes(list.uri) const isSaved = preferences?.feeds?.saved?.includes(list.uri) @@ -269,32 +275,19 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { } }, [list.uri, isPinned, pinFeed, unpinFeed, _]) - const onSubscribeMute = useCallback(() => { - openModal({ - name: 'confirm', - title: _(msg`Mute these accounts?`), - message: _( - msg`Muting is private. Muted accounts can interact with you, but you will not see their posts or receive notifications from them.`, - ), - confirmBtnText: _(msg`Mute this List`), - async onPressConfirm() { - try { - await listMuteMutation.mutateAsync({uri: list.uri, mute: true}) - Toast.show(_(msg`List muted`)) - track('Lists:Mute') - } catch { - Toast.show( - _( - msg`There was an issue. Please check your internet connection and try again.`, - ), - ) - } - }, - onPressCancel() { - closeModal() - }, - }) - }, [openModal, closeModal, list, listMuteMutation, track, _]) + const onSubscribeMute = useCallback(async () => { + try { + await listMuteMutation.mutateAsync({uri: list.uri, mute: true}) + Toast.show(_(msg`List muted`)) + track('Lists:Mute') + } catch { + Toast.show( + _( + msg`There was an issue. Please check your internet connection and try again.`, + ), + ) + } + }, [list, listMuteMutation, track, _]) const onUnsubscribeMute = useCallback(async () => { try { @@ -310,32 +303,19 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { } }, [list, listMuteMutation, track, _]) - const onSubscribeBlock = useCallback(() => { - openModal({ - name: 'confirm', - title: _(msg`Block these accounts?`), - message: _( - msg`Blocking is public. Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`, - ), - confirmBtnText: _(msg`Block this List`), - async onPressConfirm() { - try { - await listBlockMutation.mutateAsync({uri: list.uri, block: true}) - Toast.show(_(msg`List blocked`)) - track('Lists:Block') - } catch { - Toast.show( - _( - msg`There was an issue. Please check your internet connection and try again.`, - ), - ) - } - }, - onPressCancel() { - closeModal() - }, - }) - }, [openModal, closeModal, list, listBlockMutation, track, _]) + const onSubscribeBlock = useCallback(async () => { + try { + await listBlockMutation.mutateAsync({uri: list.uri, block: true}) + Toast.show(_(msg`List blocked`)) + track('Lists:Block') + } catch { + Toast.show( + _( + msg`There was an issue. Please check your internet connection and try again.`, + ), + ) + } + }, [list, listBlockMutation, track, _]) const onUnsubscribeBlock = useCallback(async () => { try { @@ -358,34 +338,26 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { }) }, [openModal, list]) - const onPressDelete = useCallback(() => { - openModal({ - name: 'confirm', - title: _(msg`Delete List`), - message: _(msg`Are you sure?`), - async onPressConfirm() { - await listDeleteMutation.mutateAsync({uri: list.uri}) - - if (isSaved || isPinned) { - const {saved, pinned} = preferences!.feeds - - setSavedFeeds({ - saved: isSaved ? saved.filter(uri => uri !== list.uri) : saved, - pinned: isPinned ? pinned.filter(uri => uri !== list.uri) : pinned, - }) - } + const onPressDelete = useCallback(async () => { + await listDeleteMutation.mutateAsync({uri: list.uri}) - Toast.show(_(msg`List deleted`)) - track('Lists:Delete') - if (navigation.canGoBack()) { - navigation.goBack() - } else { - navigation.navigate('Home') - } - }, - }) + if (isSaved || isPinned) { + const {saved, pinned} = preferences!.feeds + + setSavedFeeds({ + saved: isSaved ? saved.filter(uri => uri !== list.uri) : saved, + pinned: isPinned ? pinned.filter(uri => uri !== list.uri) : pinned, + }) + } + + Toast.show(_(msg`List deleted`)) + track('Lists:Delete') + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') + } }, [ - openModal, list, listDeleteMutation, navigation, @@ -443,7 +415,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { items.push({ testID: 'listHeaderDropdownDeleteBtn', label: _(msg`Delete List`), - onPress: onPressDelete, + onPress: deleteListPromptControl.open, icon: { ios: { name: 'trash', @@ -489,7 +461,9 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { items.push({ testID: 'listHeaderDropdownMuteBtn', label: isMuting ? _(msg`Un-mute list`) : _(msg`Mute list`), - onPress: isMuting ? onUnsubscribeMute : onSubscribeMute, + onPress: isMuting + ? onUnsubscribeMute + : subscribeMutePromptControl.open, icon: { ios: { name: isMuting ? 'eye' : 'eye.slash', @@ -504,7 +478,9 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { items.push({ testID: 'listHeaderDropdownBlockBtn', label: isBlocking ? _(msg`Un-block list`) : _(msg`Block list`), - onPress: isBlocking ? onUnsubscribeBlock : onSubscribeBlock, + onPress: isBlocking + ? onUnsubscribeBlock + : subscribeBlockPromptControl.open, icon: { ios: { name: 'person.fill.xmark', @@ -517,24 +493,24 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { } return items }, [ - isOwner, - onPressShare, - onPressEdit, - onPressDelete, - onPressReport, _, + onPressShare, + isOwner, isModList, isPinned, - unpinFeed, + isCurateList, + onPressEdit, + deleteListPromptControl.open, + onPressReport, isPending, + unpinFeed, list.uri, - isCurateList, - isMuting, isBlocking, + isMuting, onUnsubscribeMute, - onSubscribeMute, + subscribeMutePromptControl.open, onUnsubscribeBlock, - onSubscribeBlock, + subscribeBlockPromptControl.open, ]) const subscribeDropdownItems: DropdownItem[] = useMemo(() => { @@ -542,7 +518,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { { testID: 'subscribeDropdownMuteBtn', label: _(msg`Mute accounts`), - onPress: onSubscribeMute, + onPress: subscribeMutePromptControl.open, icon: { ios: { name: 'speaker.slash', @@ -554,7 +530,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { { testID: 'subscribeDropdownBlockBtn', label: _(msg`Block accounts`), - onPress: onSubscribeBlock, + onPress: subscribeBlockPromptControl.open, icon: { ios: { name: 'person.fill.xmark', @@ -564,7 +540,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { }, }, ] - }, [onSubscribeMute, onSubscribeBlock, _]) + }, [_, subscribeMutePromptControl.open, subscribeBlockPromptControl.open]) return ( <ProfileSubpageHeader @@ -620,6 +596,38 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) { <FontAwesomeIcon icon="ellipsis" size={20} color={pal.colors.text} /> </View> </NativeDropdown> + + <Prompt.Basic + control={deleteListPromptControl} + title={_(msg`Delete this list?`)} + description={_( + msg`If you delete this list, you won't be able to recover it.`, + )} + onConfirm={onPressDelete} + confirmButtonCta={_(msg`Delete`)} + confirmButtonColor="negative" + /> + + <Prompt.Basic + control={subscribeMutePromptControl} + title={_(msg`Mute these accounts?`)} + description={_( + msg`Muting is private. Muted accounts can interact with you, but you will not see their posts or receive notifications from them.`, + )} + onConfirm={onSubscribeMute} + confirmButtonCta={_(msg`Mute list`)} + /> + + <Prompt.Basic + control={subscribeBlockPromptControl} + title={_(msg`Block these accounts?`)} + description={_( + msg`Blocking is public. Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`, + )} + onConfirm={onSubscribeBlock} + confirmButtonCta={_(msg`Block list`)} + confirmButtonColor="negative" + /> </ProfileSubpageHeader> ) } diff --git a/src/view/screens/Storybook/Dialogs.tsx b/src/view/screens/Storybook/Dialogs.tsx index 09be124db..c2eaf19ac 100644 --- a/src/view/screens/Storybook/Dialogs.tsx +++ b/src/view/screens/Storybook/Dialogs.tsx @@ -68,7 +68,7 @@ export function Dialogs() { </Prompt.Description> <Prompt.Actions> <Prompt.Cancel>Cancel</Prompt.Cancel> - <Prompt.Action>Confirm</Prompt.Action> + <Prompt.Action onPress={() => {}}>Confirm</Prompt.Action> </Prompt.Actions> </Prompt.Outer> diff --git a/src/view/screens/Storybook/Menus.tsx b/src/view/screens/Storybook/Menus.tsx index 082fb2b6e..2f2b14721 100644 --- a/src/view/screens/Storybook/Menus.tsx +++ b/src/view/screens/Storybook/Menus.tsx @@ -16,7 +16,7 @@ export function Menus() { <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]}> + <Menu.Trigger label="Open basic menu"> {({state, props}) => { return ( <Text diff --git a/src/view/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx index 1ab3334fa..115faa296 100644 --- a/src/view/shell/bottom-bar/BottomBar.tsx +++ b/src/view/shell/bottom-bar/BottomBar.tsx @@ -36,6 +36,7 @@ import {Button} from '#/view/com/util/forms/Button' import {s} from 'lib/styles' import {Logo} from '#/view/icons/Logo' import {Logotype} from '#/view/icons/Logotype' +import {useDedupe} from 'lib/hooks/useDedupe' type TabOptions = 'Home' | 'Search' | 'Notifications' | 'MyProfile' | 'Feeds' @@ -54,6 +55,7 @@ export function BottomBar({navigation}: BottomTabBarProps) { const {data: profile} = useProfileQuery({did: currentAccount?.did}) const {requestSwitchToAccount} = useLoggedOutViewControls() const closeAllActiveElements = useCloseAllActiveElements() + const dedupe = useDedupe() const showSignIn = React.useCallback(() => { closeAllActiveElements() @@ -74,12 +76,12 @@ export function BottomBar({navigation}: BottomTabBarProps) { if (tabState === TabState.InsideAtRoot) { emitSoftReset() } else if (tabState === TabState.Inside) { - navigation.dispatch(StackActions.popToTop()) + dedupe(() => navigation.dispatch(StackActions.popToTop())) } else { - navigation.navigate(`${tab}Tab`) + dedupe(() => navigation.navigate(`${tab}Tab`)) } }, - [track, navigation], + [track, navigation, dedupe], ) const onPressHome = React.useCallback(() => onPressTab('Home'), [onPressTab]) const onPressSearch = React.useCallback( diff --git a/yarn.lock b/yarn.lock index 386bc1a3b..28d821028 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9475,9 +9475,9 @@ caniuse-api@^3.0.0: lodash.uniq "^4.5.0" caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001517, caniuse-lite@^1.0.30001520: - version "1.0.30001522" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001522.tgz#44b87a406c901269adcdb834713e23582dd71856" - integrity sha512-TKiyTVZxJGhsTszLuzb+6vUZSjVOAhClszBr2Ta2k9IwtNBT/4dzmL6aywt0HCgEZlmwJzXJd8yNiob6HgwTRg== + version "1.0.30001596" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001596.tgz" + integrity sha512-zpkZ+kEr6We7w63ORkoJ2pOfBwBkY/bJrG/UZ90qNb45Isblu8wzDgevEOrRL1r9dWayHjYiiyCMEXPn4DweGQ== case-anything@^2.1.13: version "2.1.13" @@ -15059,11 +15059,6 @@ js-sha256@^0.10.1: resolved "https://registry.yarnpkg.com/js-sha256/-/js-sha256-0.10.1.tgz#b40104ba1368e823fdd5f41b66b104b15a0da60d" integrity sha512-5obBtsz9301ULlsgggLg542s/jqtddfOpV5KJc4hajc9JV8GeY2gZHSVpYBn4nWqAUTJ9v+xwtbJ1mIBgIH5Vw== -js-sha256@^0.11.0: - version "0.11.0" - resolved "https://registry.yarnpkg.com/js-sha256/-/js-sha256-0.11.0.tgz#256a921d9292f7fe98905face82e367abaca9576" - integrity sha512-6xNlKayMZvds9h1Y1VWc0fQHQ82BxTXizWPEtEeGvmOUYpBRy4gbWroHLpzowe6xiQhHpelCQiE7HEdznyBL9Q== - js-sha256@^0.9.0: version "0.9.0" resolved "https://registry.yarnpkg.com/js-sha256/-/js-sha256-0.9.0.tgz#0b89ac166583e91ef9123644bd3c5334ce9d0966" @@ -20082,14 +20077,6 @@ statsig-js@4.45.1: js-sha256 "^0.10.1" uuid "^8.3.2" -statsig-js@4.49.0: - version "4.49.0" - resolved "https://registry.yarnpkg.com/statsig-js/-/statsig-js-4.49.0.tgz#8470a9ac218a93d36f4b7b306ff9377e48064740" - integrity sha512-N4drx6fzI168Q4NndFY3IJbSDqpWSBWvS290H/RnT/g3Et58SvtXzG5qqgzmqy4CwcmwH+IL8K15pL7hPnfvUQ== - dependencies: - js-sha256 "^0.11.0" - uuid "^8.3.2" - statsig-react-native-expo@^4.6.1: version "4.6.1" resolved "https://registry.yarnpkg.com/statsig-react-native-expo/-/statsig-react-native-expo-4.6.1.tgz#0bdf49fee7112f7f28bff2405f4ba0c1727bb3d6" @@ -20110,13 +20097,6 @@ statsig-react@^1.21.1: dependencies: statsig-js "4.45.1" -statsig-react@^1.36.0: - version "1.36.0" - resolved "https://registry.yarnpkg.com/statsig-react/-/statsig-react-1.36.0.tgz#c2171268a6c76eee534849ec9556b836baba04b6" - integrity sha512-QcTHla3ypfn2RvrnHGNlqWbiC2W/ZjcMM5LT6ExNV4skH7Xhspto3dMS3JVzBhOb74OEDZK4DbxQj9Wdz6XW0w== - dependencies: - statsig-js "4.49.0" - statuses@2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" |