From f3619cf9f9f74fb744bfdf074bbc470d8118dc4a Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Tue, 12 Mar 2024 00:21:48 +0000 Subject: [PATCH 01/15] move button inline if viewport is small (vertically) --- src/view/com/util/load-latest/LoadLatestBtn.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/view/com/util/load-latest/LoadLatestBtn.tsx b/src/view/com/util/load-latest/LoadLatestBtn.tsx index 5fad1176..41a1f106 100644 --- a/src/view/com/util/load-latest/LoadLatestBtn.tsx +++ b/src/view/com/util/load-latest/LoadLatestBtn.tsx @@ -11,6 +11,7 @@ const AnimatedTouchableOpacity = Animated.createAnimatedComponent(TouchableOpacity) import {isWeb} from 'platform/detection' import {useSession} from 'state/session' +import {useMediaQuery} from 'react-responsive' export function LoadLatestBtn({ onPress, @@ -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({ Date: Tue, 12 Mar 2024 00:30:02 +0000 Subject: [PATCH 02/15] move imports to top --- src/view/com/util/load-latest/LoadLatestBtn.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/view/com/util/load-latest/LoadLatestBtn.tsx b/src/view/com/util/load-latest/LoadLatestBtn.tsx index 41a1f106..f02e4a2b 100644 --- a/src/view/com/util/load-latest/LoadLatestBtn.tsx +++ b/src/view/com/util/load-latest/LoadLatestBtn.tsx @@ -1,17 +1,17 @@ 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' import {useSession} from 'state/session' -import {useMediaQuery} from 'react-responsive' export function LoadLatestBtn({ onPress, From b8afb935f4eafbe64e83512ec5a97bb7b38a6ecb Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 12 Mar 2024 11:23:01 -0500 Subject: [PATCH 03/15] Unwrap Menu.Trigger on web (#3182) --- src/components/Menu/index.web.tsx | 65 +++++++++++++-------- src/components/Menu/types.ts | 28 ++++++++- src/view/com/util/forms/PostDropdownBtn.tsx | 37 ++++-------- src/view/screens/Storybook/Menus.tsx | 2 +- 4 files changed, 78 insertions(+), 54 deletions(-) diff --git a/src/components/Menu/index.web.tsx b/src/components/Menu/index.web.tsx index 054e51b0..f23c39ce 100644 --- a/src/components/Menu/index.web.tsx +++ b/src/components/Menu/index.web.tsx @@ -1,3 +1,5 @@ +/* eslint-disable react/prop-types */ + import React from 'react' import {View, Pressable} from 'react-native' import * as DropdownMenu from '@radix-ui/react-dropdown-menu' @@ -14,6 +16,7 @@ import { GroupProps, ItemTextProps, ItemIconProps, + RadixPassThroughTriggerProps, } from '#/components/Menu/types' import {Context} from '#/components/Menu/context' @@ -76,7 +79,24 @@ export function Root({ ) } -export function Trigger({children, label, style}: TriggerProps) { +const RadixTriggerPassThrough = React.forwardRef( + ( + props: { + children: ( + props: RadixPassThroughTriggerProps & { + ref: React.Ref + }, + ) => 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,28 +107,27 @@ export function Trigger({children, label, style}: TriggerProps) { return ( - control.open()} - {...web({ - onMouseEnter, - onMouseLeave, - })}> - {children({ - isNative: false, - control, - state: { - hovered, - focused, - pressed: false, - }, - props: {}, - })} - + + {props => + children({ + isNative: false, + control, + state: { + hovered, + focused, + pressed: false, + }, + props: { + ...props, + onFocus: onFocus, + onBlur: onBlur, + onMouseEnter, + onMouseLeave, + accessibilityLabel: label, + }, + }) + } + ) } diff --git a/src/components/Menu/types.ts b/src/components/Menu/types.ts index 2f52e639..7d04a334 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,19 @@ 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 + onPointerDown: PressableProps['onPointerDown'] +} +export type TriggerProps = { children(props: TriggerChildProps): React.ReactNode label: string } @@ -52,7 +68,13 @@ export type TriggerChildProps = */ pressed: false } - props: {} + props: RadixPassThroughTriggerProps & { + onFocus: () => void + onBlur: () => void + onMouseEnter: () => void + onMouseLeave: () => void + accessibilityLabel: string + } } export type ItemProps = React.PropsWithChildren< diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx index 6f2ae55b..3c1a736f 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' @@ -38,7 +32,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' @@ -174,29 +168,18 @@ let PostDropdownBtn = ({ {({props, state}) => { - const styles = [ - style, - a.rounded_full, - (state.hovered || state.focused || state.pressed) && [ - web({outline: 0}), - alf.atoms.bg_contrast_25, - ], - ] - return isWeb ? ( - - - - ) : ( + return ( + style={[ + style, + a.rounded_full, + (state.hovered || state.pressed) && [ + alf.atoms.bg_contrast_50, + ], + ]}> - + {({state, props}) => { return ( Date: Tue, 12 Mar 2024 09:46:25 -0700 Subject: [PATCH 04/15] Dedupe navigation events (push, navigate, pop, etc) (#3179) --- src/components/Link.tsx | 11 ++-- src/components/Lists.tsx | 5 +- src/lib/hooks/useDedupe.ts | 17 ++++++ src/lib/hooks/useNavigationDeduped.ts | 80 +++++++++++++++++++++++++ src/view/com/feeds/FeedSourceCard.tsx | 5 +- src/view/com/util/Link.tsx | 17 +++--- src/view/shell/bottom-bar/BottomBar.tsx | 8 ++- 7 files changed, 118 insertions(+), 25 deletions(-) create mode 100644 src/lib/hooks/useDedupe.ts create mode 100644 src/lib/hooks/useNavigationDeduped.ts diff --git a/src/components/Link.tsx b/src/components/Link.tsx index 8c963909..ff72a08c 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() + const navigation = useNavigationDeduped() const {href} = useLinkProps({ to: typeof to === 'string' ? convertBskyAppUrlIfNeeded(sanitizeUrl(to)) : to, diff --git a/src/components/Lists.tsx b/src/components/Lists.tsx index 58aa74b3..bb0d2479 100644 --- a/src/components/Lists.tsx +++ b/src/components/Lists.tsx @@ -8,9 +8,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, @@ -142,7 +141,7 @@ export function ListMaybePlaceholder({ notFoundType?: 'page' | 'results' onRetry?: () => Promise }) { - const navigation = useNavigation() + const navigation = useNavigationDeduped() const t = useTheme() const {gtMobile, gtTablet} = useBreakpoints() diff --git a/src/lib/hooks/useDedupe.ts b/src/lib/hooks/useDedupe.ts new file mode 100644 index 00000000..d9432cb2 --- /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 00000000..d913f7f3 --- /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() + const dedupe = useDedupe() + + return React.useMemo( + (): DebouncedNavigationProp => ({ + // Types from @react-navigation/routers/lib/typescript/src/StackRouter.ts + push: ( + ...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: ( + ...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: ( + ...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/view/com/feeds/FeedSourceCard.tsx b/src/view/com/feeds/FeedSourceCard.tsx index 9bd7238d..4af62f6f 100644 --- a/src/view/com/feeds/FeedSourceCard.tsx +++ b/src/view/com/feeds/FeedSourceCard.tsx @@ -6,8 +6,6 @@ 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' @@ -26,6 +24,7 @@ import { import {useFeedSourceInfoQuery, FeedSourceInfo} from '#/state/queries/feed' import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' import {useTheme} from '#/alf' +import {useNavigationDeduped} from 'lib/hooks/useNavigationDeduped' export function FeedSourceCard({ feedUri, @@ -86,7 +85,7 @@ export function FeedSourceCardLoaded({ const t = useTheme() const pal = usePalette('default') const {_} = useLingui() - const navigation = useNavigation() + const navigation = useNavigationDeduped() const {openModal} = useModalControls() const {isPending: isSavePending, mutateAsync: saveFeed} = diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx index e50fb7f0..f4562248 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 @@ -65,7 +64,7 @@ export const Link = memo(function Link({ ...props }: Props) { const {closeModal} = useModalControls() - const navigation = useNavigation() + 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() + 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/shell/bottom-bar/BottomBar.tsx b/src/view/shell/bottom-bar/BottomBar.tsx index 1ab3334f..115faa29 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( From 80cc1f18a215182cae60c902744319ef4e842d9c Mon Sep 17 00:00:00 2001 From: Hailey Date: Tue, 12 Mar 2024 09:46:43 -0700 Subject: [PATCH 05/15] Save image to user media library when taken from camera during composing (#3180) * save images to media library when taken from camera * ensure we have access to media library * `canAskAgain` * just use MediaLibrary directly to avoid duplication --- .../com/composer/photos/OpenCameraBtn.tsx | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/view/com/composer/photos/OpenCameraBtn.tsx b/src/view/com/composer/photos/OpenCameraBtn.tsx index a288e731..4353704d 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) { From 812329919266924c1ea488669dc38aa106b79d71 Mon Sep 17 00:00:00 2001 From: Hailey Date: Tue, 12 Mar 2024 10:17:27 -0700 Subject: [PATCH 06/15] Convert profile edit avatar/banner dropdown menus to new menu (#3177) * convert profile edit dropdown menu to new menu fix banner text add `showCancel` prop to menu outer banner dropdown to menu add Cancel button to menu replace user avatar dropdown with menu add StreamingLive icon add camera icon * remove export * use new camera icon * adjust icon color --- .../camera_filled_stroke2_corner0_rounded.svg | 1 + .../icons/camera_stroke2_corner0_rounded.svg | 1 + .../streamingLive_stroke2_corner0_rounded.svg | 1 + src/components/Menu/index.tsx | 30 ++- src/components/icons/Camera.tsx | 9 + src/components/icons/StreamingLive.tsx | 5 + src/view/com/util/UserAvatar.tsx | 232 +++++++++--------- src/view/com/util/UserBanner.tsx | 230 ++++++++--------- 8 files changed, 284 insertions(+), 225 deletions(-) create mode 100644 assets/icons/camera_filled_stroke2_corner0_rounded.svg create mode 100644 assets/icons/camera_stroke2_corner0_rounded.svg create mode 100644 assets/icons/streamingLive_stroke2_corner0_rounded.svg create mode 100644 src/components/icons/Camera.tsx create mode 100644 src/components/icons/StreamingLive.tsx 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 00000000..fa0101cf --- /dev/null +++ b/assets/icons/camera_filled_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + \ 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 00000000..ce0c29ae --- /dev/null +++ b/assets/icons/camera_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + \ 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 00000000..b6cdd34d --- /dev/null +++ b/assets/icons/streamingLive_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/src/components/Menu/index.tsx b/src/components/Menu/index.tsx index ee96a566..f9b697ea 100644 --- a/src/components/Menu/index.tsx +++ b/src/components/Menu/index.tsx @@ -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,10 @@ export function Trigger({children, label}: TriggerProps) { }) } -export function Outer({children}: React.PropsWithChildren<{}>) { +export function Outer({ + children, + showCancel, +}: React.PropsWithChildren<{showCancel?: boolean}>) { const context = React.useContext(Context) return ( @@ -78,7 +85,10 @@ export function Outer({children}: React.PropsWithChildren<{}>) { {/* Re-wrap with context since Dialogs are portal-ed to root */} - {children} + + {children} + {isNative && showCancel && } + @@ -185,6 +195,22 @@ export function Group({children, style}: GroupProps) { ) } +function Cancel() { + const {_} = useLingui() + const {control} = React.useContext(Context) + + return ( + + ) +} + export function Divider() { return null } diff --git a/src/components/icons/Camera.tsx b/src/components/icons/Camera.tsx new file mode 100644 index 00000000..ced8e744 --- /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/StreamingLive.tsx b/src/components/icons/StreamingLive.tsx new file mode 100644 index 00000000..8ab5099d --- /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/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index f673db1e..41323739 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 ( - - {avatar ? ( - - ) : ( - - )} - - - - + + + {({props}) => ( + + {avatar ? ( + + ) : ( + + )} + + + + + )} + + + + {isNative && ( + + + Upload from Camera + + + + )} + + + + {isNative ? ( + Upload from Library + ) : ( + Upload from Files + )} + + + + + {!!avatar && ( + <> + + + + + Remove Avatar + + + + + + )} + + ) } EditableUserAvatar = memo(EditableUserAvatar) diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx index cb47b665..a5ddfee8 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 ? ( - - {banner ? ( - - ) : ( - - )} - - - - + + + + {({props}) => ( + + {banner ? ( + + ) : ( + + )} + + + + + )} + + + + {isNative && ( + + + Upload from Camera + + + + )} + + + + {isNative ? ( + Upload from Library + ) : ( + Upload from Files + )} + + + + + {!!banner && ( + <> + + + + + Remove Banner + + + + + + )} + + + ) : banner && !((moderation?.blur && isAndroid) /* android crashes with blur */) ? ( Date: Tue, 12 Mar 2024 13:50:53 -0500 Subject: [PATCH 07/15] Combine actions, convert to new menu (#3174) * Combine actions, convert to new menu * remove about tab and move content to header * Tweak alignment * fix missing rkey * hog the like button * Add a little more whitespace * Improve a11y * Yeah toast * Update usage * Pin to Home --------- Co-authored-by: Samuel Newman --- ...d1x3Horizontal_stroke2_corner2_rounded.svg | 1 + src/components/Link.tsx | 4 +- src/components/icons/DotGrid.tsx | 5 + src/components/icons/Heart2.tsx | 9 + src/view/screens/ProfileFeed.tsx | 365 ++++++++---------- 5 files changed, 182 insertions(+), 202 deletions(-) create mode 100644 assets/icons/dotGrid1x3Horizontal_stroke2_corner2_rounded.svg create mode 100644 src/components/icons/DotGrid.tsx create mode 100644 src/components/icons/Heart2.tsx diff --git a/assets/icons/dotGrid1x3Horizontal_stroke2_corner2_rounded.svg b/assets/icons/dotGrid1x3Horizontal_stroke2_corner2_rounded.svg new file mode 100644 index 00000000..c3b456b1 --- /dev/null +++ b/assets/icons/dotGrid1x3Horizontal_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/src/components/Link.tsx b/src/components/Link.tsx index ff72a08c..00e6a56f 100644 --- a/src/components/Link.tsx +++ b/src/components/Link.tsx @@ -228,6 +228,7 @@ export function InlineLink({ onPress: outerOnPress, download, selectable, + label, ...rest }: InlineLinkProps) { const t = useTheme() @@ -255,7 +256,8 @@ export function InlineLink({ return ( 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 ( - - {feedInfo && hasSession && ( - <> - - {typeof likeCount === 'number' && ( - - )} - - - {isOwner ? ( - Created by you - ) : ( - - Created by{' '} - - - )} - - + + + + {isLiked ? ( + + ) : ( + + )} + + {typeof likeCount === 'number' && ( + + {_(msg`Liked by ${likeCount} ${pluralize(likeCount, 'user')}`)} + + )} + + ) } @@ -647,4 +605,9 @@ const styles = StyleSheet.create({ paddingVertical: 14, borderRadius: 6, }, + aboutSectionContainer: { + paddingVertical: 4, + paddingHorizontal: 16, + gap: 12, + }, }) From eeba0387b75d3583bfded8e5ed4366609375c767 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 12 Mar 2024 14:33:02 -0500 Subject: [PATCH 08/15] Small fixes (#3184) * Fix alignment of MutedWords close button * Add cancel button to dropdowns * Revert "Add cancel button to dropdowns" This reverts commit b8f5ddce924311e439aeaa844a80d38f6e7da051. --- src/components/dialogs/MutedWords.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/dialogs/MutedWords.tsx b/src/components/dialogs/MutedWords.tsx index 658ba2aa..5f23a788 100644 --- a/src/components/dialogs/MutedWords.tsx +++ b/src/components/dialogs/MutedWords.tsx @@ -254,9 +254,9 @@ function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) { {isNative && } - - + + ) } From 70ad820d64d1377ee190e9b4f7ba7477b401e2dd Mon Sep 17 00:00:00 2001 From: Alice <81575558+aliceisjustplaying@users.noreply.github.com> Date: Tue, 12 Mar 2024 20:39:24 +0000 Subject: [PATCH 09/15] `npx update-browserslist-db@latest` (#3166) Should silence the annoying warning for a bit. --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 386bc1a3..d0ea946b 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" From 090b35e52e3c42214bcf044a70d3658d1cf8b2de Mon Sep 17 00:00:00 2001 From: Hailey Date: Tue, 12 Mar 2024 14:06:12 -0700 Subject: [PATCH 10/15] Use new menu for Profile (#3168) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * use new menu on profile * organize imports * fix testID * add person icons * use `style` prop for minWidth * use new icons * rm circleban * Add unfollow option if account is blocked/blocking * use `StyleProp` 🤯 * ts after merge --------- Co-authored-by: Samuel Newman --- assets/icons/flag_stroke2_corner0_rounded.svg | 1 + .../peopleRemove2_stroke2_corner0_rounded.svg | 1 + .../personCheck_stroke2_corner0_rounded.svg | 1 + .../icons/personX_stroke2_corner0_rounded.svg | 1 + src/components/Menu/index.tsx | 7 +- src/components/Menu/index.web.tsx | 11 +- src/components/icons/Flag.tsx | 5 + src/components/icons/PeopleRemove2.tsx | 5 + src/components/icons/PersonCheck.tsx | 5 + src/components/icons/PersonX.tsx | 5 + src/view/com/profile/ProfileHeader.tsx | 207 +----------- src/view/com/profile/ProfileMenu.tsx | 307 ++++++++++++++++++ 12 files changed, 351 insertions(+), 205 deletions(-) create mode 100644 assets/icons/flag_stroke2_corner0_rounded.svg create mode 100644 assets/icons/peopleRemove2_stroke2_corner0_rounded.svg create mode 100644 assets/icons/personCheck_stroke2_corner0_rounded.svg create mode 100644 assets/icons/personX_stroke2_corner0_rounded.svg create mode 100644 src/components/icons/Flag.tsx create mode 100644 src/components/icons/PeopleRemove2.tsx create mode 100644 src/components/icons/PersonCheck.tsx create mode 100644 src/components/icons/PersonX.tsx create mode 100644 src/view/com/profile/ProfileMenu.tsx diff --git a/assets/icons/flag_stroke2_corner0_rounded.svg b/assets/icons/flag_stroke2_corner0_rounded.svg new file mode 100644 index 00000000..9f9cc5cd --- /dev/null +++ b/assets/icons/flag_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + \ 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 00000000..2e798cbe --- /dev/null +++ b/assets/icons/peopleRemove2_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + \ 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 00000000..b3231c27 --- /dev/null +++ b/assets/icons/personCheck_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + \ 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 00000000..073015bc --- /dev/null +++ b/assets/icons/personX_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/Menu/index.tsx b/src/components/Menu/index.tsx index f9b697ea..9be9dd86 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' @@ -75,7 +75,10 @@ export function Trigger({children, label}: TriggerProps) { export function Outer({ children, showCancel, -}: React.PropsWithChildren<{showCancel?: boolean}>) { +}: React.PropsWithChildren<{ + showCancel?: boolean + style?: StyleProp +}>) { const context = React.useContext(Context) return ( diff --git a/src/components/Menu/index.web.tsx b/src/components/Menu/index.web.tsx index f23c39ce..2004ee7c 100644 --- a/src/components/Menu/index.web.tsx +++ b/src/components/Menu/index.web.tsx @@ -1,7 +1,7 @@ /* 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 * as Dialog from '#/components/Dialog' @@ -132,7 +132,13 @@ export function Trigger({children, label}: TriggerProps) { ) } -export function Outer({children}: React.PropsWithChildren<{}>) { +export function Outer({ + children, + style, +}: React.PropsWithChildren<{ + showCancel?: boolean + style?: StyleProp +}>) { const t = useTheme() return ( @@ -144,6 +150,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} diff --git a/src/components/icons/Flag.tsx b/src/components/icons/Flag.tsx new file mode 100644 index 00000000..d986db75 --- /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/PeopleRemove2.tsx b/src/components/icons/PeopleRemove2.tsx new file mode 100644 index 00000000..3d16ed96 --- /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 00000000..097271d8 --- /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 00000000..a015e137 --- /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/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx index 3e479d7b..a11fe837 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,7 @@ 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' let ProfileHeaderLoading = (_props: {}): React.ReactNode => { const pal = usePalette('default') @@ -108,20 +103,12 @@ 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 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,72 +176,7 @@ 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') - 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()}`)) - } - } - }, [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 () => { + const onPressUnblockAccount = React.useCallback(() => { track('ProfileHeader:UnblockAccountButtonClicked') openModal({ name: 'confirm', @@ -274,119 +196,12 @@ let ProfileHeader = ({ } }, }) - }, [track, queueUnblock, openModal, _]) - - const onPressReportAccount = React.useCallback(() => { - track('ProfileHeader:ReportAccountButtonClicked') - openModal({ - name: 'report', - did: profile.did, - }) - }, [track, openModal, profile]) + }, [_, openModal, 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) @@ -516,17 +331,7 @@ let ProfileHeader = ({ )} ) : null} - {dropdownItems?.length ? ( - - - - - - ) : undefined} + +}): 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 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 onPressBlockAccount = React.useCallback(async () => { + if (profile.viewer?.blocking) { + 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()}`)) + } + } + }, + }) + } else { + 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()}`)) + } + } + }, + }) + } + }, [profile.viewer?.blocking, track, openModal, _, 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 ( + + + + {({props}) => { + return ( + + + + ) + }} + + + + + + + Share + + + + + {hasSession && ( + <> + + + + + Add to Lists + + + + {!isSelf && ( + <> + {profile.viewer?.following && + (profile.viewer.blocking || profile.viewer.blockedBy) && ( + + + Unfollow Account + + + + )} + {!profile.viewer?.blocking && + !profile.viewer?.mutedByList && ( + + + {profile.viewer?.muted ? ( + Unmute Account + ) : ( + Mute Account + )} + + + + )} + {!profile.viewer?.blockingByList && ( + + + {profile.viewer?.blocking ? ( + Unblock Account + ) : ( + Block Account + )} + + + + )} + + + Report Account + + + + + )} + + + )} + + + + ) +} + +ProfileMenu = memo(ProfileMenu) +export {ProfileMenu} From 9f2f7f221c10e89916e6e8761623fbf1281eec77 Mon Sep 17 00:00:00 2001 From: Hailey Date: Tue, 12 Mar 2024 16:56:14 -0700 Subject: [PATCH 11/15] ALF confirmation dialogs (Dialogs Pt. 3) (#3143) * Improve a11y on ios * Format * Remove android * Fix android * ALF confirmation dialog * Use ALF for Delete Post confirmation organize diff fix text minimize change copy alternative confirm prompt revert type changes add ButtonColor param * small adjustment to buttons in prompt * full width below gtmobile * update hide post dialog * space out dialogs * update dialogs for lists * add example * add to app passwords * Revert some changes * use sharedvalue for `importantForAccessibility` * add back `isOpen` * fix some more types * small adjustment to buttons in prompt * full width below gtmobile * update the rest of the prompts rm old confirm modal rm update prompt feed error prompt feed source card and profile block/unblock composer discard * Update src/view/screens/AppPasswords.tsx Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * lint * How about a default * Reverse reverse * Port over confirm dialogs * Add some comments * Remove unused file * complete merge * add testID where needed --------- Co-authored-by: Eric Bailey Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> --- src/alf/atoms.ts | 6 + src/components/Dialog/index.tsx | 4 +- src/components/Dialog/types.ts | 1 + src/components/Prompt.tsx | 91 +++++-- src/components/dialogs/MutedWords.tsx | 33 +-- src/lib/hooks/useOTAUpdate.ts | 52 ++-- src/state/modals/index.tsx | 13 - src/view/com/composer/Composer.tsx | 43 ++-- src/view/com/feeds/FeedSourceCard.tsx | 260 ++++++++++---------- src/view/com/modals/Confirm.tsx | 132 ---------- src/view/com/modals/Modal.tsx | 6 +- src/view/com/modals/Modal.web.tsx | 5 +- src/view/com/posts/FeedErrorMessage.tsx | 89 +++---- src/view/com/profile/ProfileHeader.tsx | 47 ++-- src/view/com/profile/ProfileMenu.tsx | 86 ++++--- src/view/com/util/forms/PostDropdownBtn.tsx | 43 ++-- src/view/screens/AppPasswords.tsx | 36 +-- src/view/screens/ProfileList.tsx | 196 ++++++++------- src/view/screens/Storybook/Dialogs.tsx | 2 +- 19 files changed, 540 insertions(+), 605 deletions(-) delete mode 100644 src/view/com/modals/Confirm.tsx diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts index fff3a4d8..6d73cd30 100644 --- a/src/alf/atoms.ts +++ b/src/alf/atoms.ts @@ -111,6 +111,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 f0e7b7e8..5f6edeac 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) { const t = useTheme() const sheet = React.useRef(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}> } webOptions?: {} + testID?: string } type DialogInnerPropsBase = React.PropsWithChildren & T diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx index 3b245c44..9ffa9e7d 100644 --- a/src/components/Prompt.tsx +++ b/src/components/Prompt.tsx @@ -1,11 +1,11 @@ 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 {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 +22,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 +37,7 @@ export function Outer({ ) return ( - + @@ -80,7 +82,9 @@ 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, a.pt_md, a.pb_4xl], ]}> {children} @@ -89,18 +93,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 ( ) } @@ -108,22 +123,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 ( ) } + +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 ( + + {title} + {description} + + + + + + ) +} diff --git a/src/components/dialogs/MutedWords.tsx b/src/components/dialogs/MutedWords.tsx index 5f23a788..dcdaccea 100644 --- a/src/components/dialogs/MutedWords.tsx +++ b/src/components/dialogs/MutedWords.tsx @@ -277,29 +277,16 @@ function MutedWordRow({ return ( <> - - - Are you sure? - - - - This will delete {word.value} from your muted words. You can always - add it back later. - - - - - - Nevermind - - - - - Remove - - - - + { - 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/state/modals/index.tsx b/src/state/modals/index.tsx index 691add00..a18f6c87 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 - onPressCancel?: () => void | Promise - confirmBtnText?: string - confirmBtnStyle?: StyleProp - 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 2855d423..ef965b27 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(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({ + + ) }) diff --git a/src/view/com/feeds/FeedSourceCard.tsx b/src/view/com/feeds/FeedSourceCard.tsx index 4af62f6f..9300b415 100644 --- a/src/view/com/feeds/FeedSourceCard.tsx +++ b/src/view/com/feeds/FeedSourceCard.tsx @@ -11,7 +11,6 @@ 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 { @@ -24,6 +23,7 @@ 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({ @@ -85,8 +85,8 @@ export function FeedSourceCardLoaded({ const t = useTheme() const pal = usePalette('default') const {_} = useLingui() + const removePromptControl = Prompt.usePromptControl() const navigation = useNavigationDeduped() - const {openModal} = useModalControls() const {isPending: isSavePending, mutateAsync: saveFeed} = useSaveFeedMutation() @@ -96,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 @@ -167,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}> { - 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}> - - - - - - - {feed.displayName} - - - {feed.type === 'feed' ? ( - Feed by {sanitizeHandle(feed.creatorHandle, '@')} - ) : ( - List by {sanitizeHandle(feed.creatorHandle, '@')} - )} - - - - {showSaveBtn && feed.type === 'feed' && ( - - - {isSaved ? ( - - ) : ( - - )} - + <> + { + 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}> + + + + + + {feed.displayName} + + + {feed.type === 'feed' ? ( + Feed by {sanitizeHandle(feed.creatorHandle, '@')} + ) : ( + List by {sanitizeHandle(feed.creatorHandle, '@')} + )} + + + + {showSaveBtn && feed.type === 'feed' && ( + + + {isSaved ? ( + + ) : ( + + )} + + + )} + + + {showDescription && feed.description ? ( + + ) : null} + + {showLikes && feed.type === 'feed' ? ( + + + Liked by {feed.likeCount || 0}{' '} + {pluralize(feed.likeCount || 0, 'user')} + + + ) : null} + + + - - {showDescription && feed.description ? ( - - ) : null} - - {showLikes && feed.type === 'feed' ? ( - - - Liked by {feed.likeCount || 0}{' '} - {pluralize(feed.likeCount || 0, 'user')} - - - ) : null} - + 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 307897fb..00000000 --- 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(false) - const [error, setError] = useState('') - const onPress = async () => { - setError('') - setIsProcessing(true) - try { - await onPressConfirm() - closeModal() - return - } catch (e: any) { - setError(cleanError(e)) - setIsProcessing(false) - } - } - return ( - - - {title} - - {typeof message === 'string' ? ( - - {message} - - ) : ( - message() - )} - {error ? ( - - - - ) : undefined} - - {isProcessing ? ( - - - - ) : ( - - - {confirmBtnText ?? Confirm} - - - )} - {onPressCancel === undefined ? null : ( - - - {cancelBtnText ?? Cancel} - - - )} - - ) -} - -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 100444ff..e03879c1 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 = - } else if (activeModal?.name === 'edit-profile') { + if (activeModal?.name === 'edit-profile') { snapPoints = EditProfileModal.snapPoints element = } else if (activeModal?.name === 'report') { diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index 0ced894a..d72b7e48 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 = - } else if (modal.name === 'edit-profile') { + if (modal.name === 'edit-profile') { element = } else if (modal.name === 'report') { element = diff --git a/src/view/com/posts/FeedErrorMessage.tsx b/src/view/com/posts/FeedErrorMessage.tsx index 6d99c32f..c52090f9 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 ( - - {msg} + <> + + {msg} - {rawError?.message && ( - - Message from server: {rawError.message} - - )} + {rawError?.message && ( + + Message from server: {rawError.message} + + )} - {cta} - + {cta} + + + + ) } diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx index a11fe837..75e06eb9 100644 --- a/src/view/com/profile/ProfileHeader.tsx +++ b/src/view/com/profile/ProfileHeader.tsx @@ -52,6 +52,7 @@ 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') @@ -104,6 +105,7 @@ let ProfileHeader = ({ const [showSuggestedFollows, setShowSuggestedFollows] = React.useState(false) const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile) const [__, queueUnblock] = useProfileBlockMutationQueue(profile) + const unblockPromptControl = Prompt.usePromptControl() const moderation = useMemo( () => moderateProfile(profile, moderationOpts), [profile, moderationOpts], @@ -176,27 +178,18 @@ let ProfileHeader = ({ }) }, [track, openModal, profile]) - const onPressUnblockAccount = React.useCallback(() => { + const unblockAccount = 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()}`)) - } - } - }, - }) - }, [_, openModal, queueUnblock, track]) + 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()}`)) + } + } + }, [_, queueUnblock, track]) const isMe = React.useMemo( () => currentAccount?.did === profile.did, @@ -242,7 +235,7 @@ let ProfileHeader = ({ profile.viewer?.blockingByList ? null : ( unblockPromptControl.open()} style={[styles.btn, styles.mainBtn, pal.btn]} accessibilityRole="button" accessibilityLabel={_(msg`Unblock`)} @@ -475,6 +468,18 @@ let ProfileHeader = ({ /> + ) } diff --git a/src/view/com/profile/ProfileMenu.tsx b/src/view/com/profile/ProfileMenu.tsx index d79e1891..ef102daf 100644 --- a/src/view/com/profile/ProfileMenu.tsx +++ b/src/view/com/profile/ProfileMenu.tsx @@ -33,6 +33,7 @@ import {PersonX_Stroke2_Corner0_Rounded as PersonX} from '#/components/icons/Per 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, @@ -53,6 +54,8 @@ let ProfileMenu = ({ const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) const [, queueUnfollow] = useProfileFollowMutationQueue(profile) + const blockPromptControl = Prompt.usePromptControl() + const invalidateProfileQuery = React.useCallback(() => { queryClient.invalidateQueries({ queryKey: profileQueryKey(profile.did), @@ -102,49 +105,31 @@ let ProfileMenu = ({ } }, [profile.viewer?.muted, track, queueUnmute, _, queueMute]) - const onPressBlockAccount = React.useCallback(async () => { + const blockAccount = React.useCallback(async () => { if (profile.viewer?.blocking) { 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()}`)) - } - } - }, - }) + 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') - 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()}`)) - } - } - }, - }) + 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, openModal, _, queueUnblock, queueBlock]) + }, [profile.viewer?.blocking, track, _, queueUnblock, queueBlock]) const onPressUnfollowAccount = React.useCallback(async () => { track('ProfileHeader:UnfollowButtonClicked') @@ -268,7 +253,7 @@ let ProfileMenu = ({ ? _(msg`Unblock Account`) : _(msg`Block Account`) } - onPress={onPressBlockAccount}> + onPress={() => blockPromptControl.open()}> {profile.viewer?.blocking ? ( Unblock Account @@ -299,6 +284,29 @@ let ProfileMenu = ({ )} + + ) } diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx index 3c1a736f..84a047c4 100644 --- a/src/view/com/util/forms/PostDropdownBtn.tsx +++ b/src/view/com/util/forms/PostDropdownBtn.tsx @@ -14,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' @@ -81,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) @@ -257,16 +261,7 @@ let PostDropdownBtn = ({ { - openModal({ - name: 'confirm', - title: _(msg`Hide this post?`), - message: _( - msg`This will hide this post from your feeds.`, - ), - onPressConfirm: onHidePost, - }) - }}> + onPress={hidePromptControl.open}> {_(msg`Hide post`)} @@ -298,14 +293,7 @@ let PostDropdownBtn = ({ { - openModal({ - name: 'confirm', - title: _(msg`Delete this post?`), - message: _(msg`Are you sure? This cannot be undone.`), - onPressConfirm: onDeletePost, - }) - }}> + onPress={deletePromptControl.open}> {_(msg`Delete post`)} @@ -335,6 +323,25 @@ let PostDropdownBtn = ({ + + + +