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 0000000000..fa0101cf0d
--- /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 0000000000..ce0c29ae50
--- /dev/null
+++ b/assets/icons/camera_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/assets/icons/dotGrid1x3Horizontal_stroke2_corner2_rounded.svg b/assets/icons/dotGrid1x3Horizontal_stroke2_corner2_rounded.svg
new file mode 100644
index 0000000000..c3b456b100
--- /dev/null
+++ b/assets/icons/dotGrid1x3Horizontal_stroke2_corner2_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/flag_stroke2_corner0_rounded.svg b/assets/icons/flag_stroke2_corner0_rounded.svg
new file mode 100644
index 0000000000..9f9cc5cdd1
--- /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 0000000000..2e798cbe29
--- /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 0000000000..b3231c2780
--- /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 0000000000..073015bc54
--- /dev/null
+++ b/assets/icons/personX_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 0000000000..b6cdd34d77
--- /dev/null
+++ b/assets/icons/streamingLive_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/package.json b/package.json
index 2571fe772d..6e17fcb02a 100644
--- a/package.json
+++ b/package.json
@@ -179,7 +179,6 @@
"react-responsive": "^9.0.2",
"rn-fetch-blob": "^0.12.0",
"sentry-expo": "~7.0.1",
- "statsig-react": "^1.36.0",
"statsig-react-native-expo": "^4.6.1",
"tippy.js": "^6.3.7",
"tlds": "^1.234.0",
diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts
index fff3a4d8bd..5088e3aaca 100644
--- a/src/alf/atoms.ts
+++ b/src/alf/atoms.ts
@@ -1,3 +1,4 @@
+import {Platform} from 'react-native'
import {web, native} from '#/alf/util/platform'
import * as tokens from '#/alf/tokens'
@@ -6,7 +7,7 @@ export const atoms = {
* Positioning
*/
fixed: {
- position: 'fixed',
+ position: Platform.select({web: 'fixed', native: 'absolute'}) as 'absolute',
},
absolute: {
position: 'absolute',
@@ -111,6 +112,12 @@ export const atoms = {
flex_row: {
flexDirection: 'row',
},
+ flex_col_reverse: {
+ flexDirection: 'column-reverse',
+ },
+ flex_row_reverse: {
+ flexDirection: 'row-reverse',
+ },
flex_wrap: {
flexWrap: 'wrap',
},
diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx
index f0e7b7e82b..5f6edeac78 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/Link.tsx b/src/components/Link.tsx
index 8c963909be..00e6a56f42 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,
@@ -231,6 +228,7 @@ export function InlineLink({
onPress: outerOnPress,
download,
selectable,
+ label,
...rest
}: InlineLinkProps) {
const t = useTheme()
@@ -258,7 +256,8 @@ export function InlineLink({
return (
Promise
}) {
- const navigation = useNavigation()
+ const navigation = useNavigationDeduped()
const t = useTheme()
const {gtMobile, gtTablet} = useBreakpoints()
const {_} = useLingui()
diff --git a/src/components/Menu/index.tsx b/src/components/Menu/index.tsx
index ee96a5667e..9be9dd86bf 100644
--- a/src/components/Menu/index.tsx
+++ b/src/components/Menu/index.tsx
@@ -1,5 +1,5 @@
import React from 'react'
-import {View, Pressable} from 'react-native'
+import {View, Pressable, ViewStyle, StyleProp} from 'react-native'
import flattenReactChildren from 'react-keyed-flatten-children'
import {atoms as a, useTheme} from '#/alf'
@@ -16,6 +16,10 @@ import {
ItemTextProps,
ItemIconProps,
} from '#/components/Menu/types'
+import {Button, ButtonText} from '#/components/Button'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {isNative} from 'platform/detection'
export {useDialogControl as useMenuControl} from '#/components/Dialog'
@@ -68,7 +72,13 @@ export function Trigger({children, label}: TriggerProps) {
})
}
-export function Outer({children}: React.PropsWithChildren<{}>) {
+export function Outer({
+ children,
+ showCancel,
+}: React.PropsWithChildren<{
+ showCancel?: boolean
+ style?: StyleProp
+}>) {
const context = React.useContext(Context)
return (
@@ -78,7 +88,10 @@ export function Outer({children}: React.PropsWithChildren<{}>) {
{/* Re-wrap with context since Dialogs are portal-ed to root */}
- {children}
+
+ {children}
+ {isNative && showCancel && }
+
@@ -185,6 +198,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/Menu/index.web.tsx b/src/components/Menu/index.web.tsx
index 054e51b01e..f4b03f680e 100644
--- a/src/components/Menu/index.web.tsx
+++ b/src/components/Menu/index.web.tsx
@@ -1,6 +1,10 @@
+/* eslint-disable react/prop-types */
+
import React from 'react'
-import {View, Pressable} from 'react-native'
+import {View, Pressable, ViewStyle, StyleProp} from 'react-native'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
import * as Dialog from '#/components/Dialog'
import {useInteractionState} from '#/components/hooks/useInteractionState'
@@ -14,8 +18,10 @@ import {
GroupProps,
ItemTextProps,
ItemIconProps,
+ RadixPassThroughTriggerProps,
} from '#/components/Menu/types'
import {Context} from '#/components/Menu/context'
+import {Portal} from '#/components/Portal'
export function useMenuControl(): Dialog.DialogControlProps {
const id = React.useId()
@@ -47,6 +53,7 @@ export function Root({
}: React.PropsWithChildren<{
control?: Dialog.DialogOuterProps['control']
}>) {
+ const {_} = useLingui()
const defaultControl = useMenuControl()
const context = React.useMemo(
() => ({
@@ -67,6 +74,18 @@ export function Root({
return (
+ {context.control.isOpen && (
+
+ context.control.close()}
+ accessibilityHint=""
+ accessibilityLabel={_(
+ msg`Context menu backdrop, click to close the menu.`,
+ )}
+ />
+
+ )}
@@ -76,7 +95,24 @@ export function Root({
)
}
-export function Trigger({children, label, style}: TriggerProps) {
+const RadixTriggerPassThrough = React.forwardRef(
+ (
+ props: {
+ children: (
+ props: RadixPassThroughTriggerProps & {
+ ref: React.Ref
+ },
+ ) => React.ReactNode
+ },
+ ref,
+ ) => {
+ // @ts-expect-error Radix provides no types of this stuff
+ return props.children({...props, ref})
+ },
+)
+RadixTriggerPassThrough.displayName = 'RadixTriggerPassThrough'
+
+export function Trigger({children, label}: TriggerProps) {
const {control} = React.useContext(Context)
const {
state: hovered,
@@ -87,33 +123,42 @@ export function Trigger({children, label, style}: TriggerProps) {
return (
- 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,
+ // disable on web, use `onPress`
+ onPointerDown: () => false,
+ onPress: () =>
+ control.isOpen ? control.close() : control.open(),
+ onFocus: onFocus,
+ onBlur: onBlur,
+ onMouseEnter,
+ onMouseLeave,
+ accessibilityLabel: label,
+ },
+ })
+ }
+
)
}
-export function Outer({children}: React.PropsWithChildren<{}>) {
+export function Outer({
+ children,
+ style,
+}: React.PropsWithChildren<{
+ showCancel?: boolean
+ style?: StyleProp
+}>) {
const t = useTheme()
return (
@@ -125,6 +170,7 @@ export function Outer({children}: React.PropsWithChildren<{}>) {
a.p_xs,
t.name === 'light' ? t.atoms.bg : t.atoms.bg_contrast_25,
t.atoms.shadow_md,
+ style,
]}>
{children}
diff --git a/src/components/Menu/types.ts b/src/components/Menu/types.ts
index 2f52e63906..e710971ee9 100644
--- a/src/components/Menu/types.ts
+++ b/src/components/Menu/types.ts
@@ -1,5 +1,9 @@
import React from 'react'
-import {GestureResponderEvent, PressableProps} from 'react-native'
+import {
+ GestureResponderEvent,
+ PressableProps,
+ AccessibilityProps,
+} from 'react-native'
import {Props as SVGIconProps} from '#/components/icons/common'
import * as Dialog from '#/components/Dialog'
@@ -9,7 +13,23 @@ export type ContextType = {
control: Dialog.DialogOuterProps['control']
}
-export type TriggerProps = ViewStyleProp & {
+export type RadixPassThroughTriggerProps = {
+ id: string
+ type: 'button'
+ disabled: boolean
+ ['data-disabled']: boolean
+ ['data-state']: string
+ ['aria-controls']?: string
+ ['aria-haspopup']?: boolean
+ ['aria-expanded']?: AccessibilityProps['aria-expanded']
+ onKeyDown: (e: React.KeyboardEvent) => void
+ /**
+ * Radix provides this, but we override on web to use `onPress` instead,
+ * which is less sensitive while scrolling.
+ */
+ onPointerDown: PressableProps['onPointerDown']
+}
+export type TriggerProps = {
children(props: TriggerChildProps): React.ReactNode
label: string
}
@@ -52,7 +72,14 @@ export type TriggerChildProps =
*/
pressed: false
}
- props: {}
+ props: RadixPassThroughTriggerProps & {
+ onPress: () => void
+ onFocus: () => void
+ onBlur: () => void
+ onMouseEnter: () => void
+ onMouseLeave: () => void
+ accessibilityLabel: string
+ }
}
export type ItemProps = React.PropsWithChildren<
diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx
index 3b245c440f..1b9348d9c6 100644
--- a/src/components/Prompt.tsx
+++ b/src/components/Prompt.tsx
@@ -1,11 +1,12 @@
import React from 'react'
-import {View, PressableProps} from 'react-native'
+import {View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
+import {isNative} from '#/platform/detection'
import {useTheme, atoms as a, useBreakpoints} from '#/alf'
import {Text} from '#/components/Typography'
-import {Button} from '#/components/Button'
+import {Button, ButtonColor, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
@@ -22,8 +23,10 @@ const Context = React.createContext<{
export function Outer({
children,
control,
+ testID,
}: React.PropsWithChildren<{
control: Dialog.DialogOuterProps['control']
+ testID?: string
}>) {
const {gtMobile} = useBreakpoints()
const titleId = React.useId()
@@ -35,7 +38,7 @@ export function Outer({
)
return (
-
+
@@ -80,7 +83,10 @@ export function Actions({children}: React.PropsWithChildren<{}>) {
a.w_full,
a.gap_sm,
a.justify_end,
- gtMobile ? [a.flex_row] : [a.flex_col, a.pt_md, a.pb_4xl],
+ gtMobile
+ ? [a.flex_row, a.flex_row_reverse, a.justify_start]
+ : [a.flex_col],
+ isNative && [a.pb_4xl],
]}>
{children}
@@ -89,18 +95,29 @@ export function Actions({children}: React.PropsWithChildren<{}>) {
export function Cancel({
children,
-}: React.PropsWithChildren<{onPress?: PressableProps['onPress']}>) {
+ cta,
+}: React.PropsWithChildren<{
+ /**
+ * Optional i18n string, used in lieu of `children` for simple buttons. If
+ * undefined (and `children` is undefined), it will default to "Cancel".
+ */
+ cta?: string
+}>) {
const {_} = useLingui()
const {gtMobile} = useBreakpoints()
const {close} = Dialog.useDialogContext()
+ const onPress = React.useCallback(() => {
+ close()
+ }, [close])
+
return (
)
}
@@ -108,22 +125,70 @@ export function Cancel({
export function Action({
children,
onPress,
-}: React.PropsWithChildren<{onPress?: () => void}>) {
+ color = 'primary',
+ cta,
+ testID,
+}: React.PropsWithChildren<{
+ onPress: () => void
+ color?: ButtonColor
+ /**
+ * Optional i18n string, used in lieu of `children` for simple buttons. If
+ * undefined (and `children` is undefined), it will default to "Confirm".
+ */
+ cta?: string
+ testID?: string
+}>) {
const {_} = useLingui()
const {gtMobile} = useBreakpoints()
const {close} = Dialog.useDialogContext()
const handleOnPress = React.useCallback(() => {
close()
- onPress?.()
+ onPress()
}, [close, onPress])
+
return (
)
}
+
+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 658ba2aae0..dcdaccea3e 100644
--- a/src/components/dialogs/MutedWords.tsx
+++ b/src/components/dialogs/MutedWords.tsx
@@ -254,9 +254,9 @@ function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) {
{isNative && }
-
-
+
+
)
}
@@ -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
-
-
-
-
+
{
+ 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 0000000000..d913f7f3d0
--- /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/lib/hooks/useOTAUpdate.ts b/src/lib/hooks/useOTAUpdate.ts
index 53eab300e7..d35179256d 100644
--- a/src/lib/hooks/useOTAUpdate.ts
+++ b/src/lib/hooks/useOTAUpdate.ts
@@ -2,25 +2,9 @@ import * as Updates from 'expo-updates'
import {useCallback, useEffect} from 'react'
import {AppState} from 'react-native'
import {logger} from '#/logger'
-import {useModalControls} from '#/state/modals'
-import {t} from '@lingui/macro'
export function useOTAUpdate() {
- const {openModal} = useModalControls()
-
// HELPER FUNCTIONS
- const showUpdatePopup = useCallback(() => {
- openModal({
- name: 'confirm',
- title: t`Update Available`,
- message: t`A new version of the app is available. Please update to continue using the app.`,
- onPressConfirm: async () => {
- Updates.reloadAsync().catch(err => {
- throw err
- })
- },
- })
- }, [openModal])
const checkForUpdate = useCallback(async () => {
logger.debug('useOTAUpdate: Checking for update...')
try {
@@ -32,32 +16,26 @@ export function useOTAUpdate() {
}
// Otherwise fetch the update in the background, so even if the user rejects switching to latest version it will be done automatically on next relaunch.
await Updates.fetchUpdateAsync()
- // show a popup modal
- showUpdatePopup()
} catch (e) {
logger.error('useOTAUpdate: Error while checking for update', {
message: e,
})
}
- }, [showUpdatePopup])
- const updateEventListener = useCallback(
- (event: Updates.UpdateEvent) => {
- logger.debug('useOTAUpdate: Listening for update...')
- if (event.type === Updates.UpdateEventType.ERROR) {
- logger.error('useOTAUpdate: Error while listening for update', {
- message: event.message,
- })
- } else if (event.type === Updates.UpdateEventType.NO_UPDATE_AVAILABLE) {
- // Handle no update available
- // do nothing
- } else if (event.type === Updates.UpdateEventType.UPDATE_AVAILABLE) {
- // Handle update available
- // open modal, ask for user confirmation, and reload the app
- showUpdatePopup()
- }
- },
- [showUpdatePopup],
- )
+ }, [])
+ const updateEventListener = useCallback((event: Updates.UpdateEvent) => {
+ logger.debug('useOTAUpdate: Listening for update...')
+ if (event.type === Updates.UpdateEventType.ERROR) {
+ logger.error('useOTAUpdate: Error while listening for update', {
+ message: event.message,
+ })
+ } else if (event.type === Updates.UpdateEventType.NO_UPDATE_AVAILABLE) {
+ // Handle no update available
+ // do nothing
+ } else if (event.type === Updates.UpdateEventType.UPDATE_AVAILABLE) {
+ // Handle update available
+ // open modal, ask for user confirmation, and reload the app
+ }
+ }, [])
useEffect(() => {
// ADD EVENT LISTENERS
diff --git a/src/lib/statsig/statsig.web.tsx b/src/lib/statsig/statsig.web.tsx
deleted file mode 100644
index d1c9120194..0000000000
--- a/src/lib/statsig/statsig.web.tsx
+++ /dev/null
@@ -1,75 +0,0 @@
-import React from 'react'
-import {
- Statsig,
- StatsigProvider,
- useGate as useStatsigGate,
-} from 'statsig-react'
-import {useSession} from '../../state/session'
-import {sha256} from 'js-sha256'
-
-const statsigOptions = {
- environment: {
- tier: process.env.NODE_ENV === 'development' ? 'development' : 'production',
- },
- // Don't block on waiting for network. The fetched config will kick in on next load.
- // This ensures the UI is always consistent and doesn't update mid-session.
- // Note this makes cold load (no local storage) and private mode return `false` for all gates.
- initTimeoutMs: 1,
-}
-
-export function logEvent(
- eventName: string,
- value?: string | number | null,
- metadata?: Record | null,
-) {
- Statsig.logEvent(eventName, value, metadata)
-}
-
-export function useGate(gateName: string) {
- const {isLoading, value} = useStatsigGate(gateName)
- if (isLoading) {
- // This should not happen because of waitForInitialization={true}.
- console.error('Did not expected isLoading to ever be true.')
- }
- return value
-}
-
-function toStatsigUser(did: string | undefined) {
- let userID: string | undefined
- if (did) {
- userID = sha256(did)
- }
- return {userID}
-}
-
-export function Provider({children}: {children: React.ReactNode}) {
- const {currentAccount} = useSession()
- const currentStatsigUser = React.useMemo(
- () => toStatsigUser(currentAccount?.did),
- [currentAccount?.did],
- )
-
- React.useEffect(() => {
- function refresh() {
- // Intentionally refetching the config using the JS SDK rather than React SDK
- // so that the new config is stored in cache but isn't used during this session.
- // It will kick in for the next reload.
- Statsig.updateUser(currentStatsigUser)
- }
- const id = setInterval(refresh, 3 * 60e3 /* 3 min */)
- return () => clearInterval(id)
- }, [currentStatsigUser])
-
- return (
-
- {children}
-
- )
-}
diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx
index 691add0050..a18f6c87c0 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 2855d4232c..ef965b271d 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/composer/photos/OpenCameraBtn.tsx b/src/view/com/composer/photos/OpenCameraBtn.tsx
index a288e73109..4353704d57 100644
--- a/src/view/com/composer/photos/OpenCameraBtn.tsx
+++ b/src/view/com/composer/photos/OpenCameraBtn.tsx
@@ -1,5 +1,6 @@
import React, {useCallback} from 'react'
import {TouchableOpacity, StyleSheet} from 'react-native'
+import * as MediaLibrary from 'expo-media-library'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
@@ -24,6 +25,8 @@ export function OpenCameraBtn({gallery}: Props) {
const {track} = useAnalytics()
const {_} = useLingui()
const {requestCameraAccessIfNeeded} = useCameraPermission()
+ const [mediaPermissionRes, requestMediaPermission] =
+ MediaLibrary.usePermissions()
const onPressTakePicture = useCallback(async () => {
track('Composer:CameraOpened')
@@ -31,6 +34,9 @@ export function OpenCameraBtn({gallery}: Props) {
if (!(await requestCameraAccessIfNeeded())) {
return
}
+ if (!mediaPermissionRes?.granted && mediaPermissionRes?.canAskAgain) {
+ await requestMediaPermission()
+ }
const img = await openCamera({
width: POST_IMG_MAX.width,
@@ -38,12 +44,23 @@ export function OpenCameraBtn({gallery}: Props) {
freeStyleCropEnabled: true,
})
+ // If we don't have permissions it's fine, we just wont save it. The post itself will still have access to
+ // the image even without these permissions
+ if (mediaPermissionRes) {
+ await MediaLibrary.createAssetAsync(img.path)
+ }
gallery.add(img)
} catch (err: any) {
// ignore
logger.warn('Error using camera', {error: err})
}
- }, [gallery, track, requestCameraAccessIfNeeded])
+ }, [
+ gallery,
+ track,
+ requestCameraAccessIfNeeded,
+ mediaPermissionRes,
+ requestMediaPermission,
+ ])
const shouldShowCameraButton = isNative || isMobileWeb
if (!shouldShowCameraButton) {
diff --git a/src/view/com/feeds/FeedSourceCard.tsx b/src/view/com/feeds/FeedSourceCard.tsx
index 9bd7238df2..9300b41596 100644
--- a/src/view/com/feeds/FeedSourceCard.tsx
+++ b/src/view/com/feeds/FeedSourceCard.tsx
@@ -6,14 +6,11 @@ import {RichText} from '#/components/RichText'
import {usePalette} from 'lib/hooks/usePalette'
import {s} from 'lib/styles'
import {UserAvatar} from '../util/UserAvatar'
-import {useNavigation} from '@react-navigation/native'
-import {NavigationProp} from 'lib/routes/types'
import {pluralize} from 'lib/strings/helpers'
import {AtUri} from '@atproto/api'
import * as Toast from 'view/com/util/Toast'
import {sanitizeHandle} from 'lib/strings/handles'
import {logger} from '#/logger'
-import {useModalControls} from '#/state/modals'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {
@@ -26,6 +23,8 @@ import {
import {useFeedSourceInfoQuery, FeedSourceInfo} from '#/state/queries/feed'
import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {useTheme} from '#/alf'
+import * as Prompt from '#/components/Prompt'
+import {useNavigationDeduped} from 'lib/hooks/useNavigationDeduped'
export function FeedSourceCard({
feedUri,
@@ -86,8 +85,8 @@ export function FeedSourceCardLoaded({
const t = useTheme()
const pal = usePalette('default')
const {_} = useLingui()
- const navigation = useNavigation()
- const {openModal} = useModalControls()
+ const removePromptControl = Prompt.usePromptControl()
+ const navigation = useNavigationDeduped()
const {isPending: isSavePending, mutateAsync: saveFeed} =
useSaveFeedMutation()
@@ -97,40 +96,45 @@ export function FeedSourceCardLoaded({
const isSaved = Boolean(preferences?.feeds?.saved?.includes(feed?.uri || ''))
+ const onSave = React.useCallback(async () => {
+ if (!feed) return
+
+ try {
+ if (pinOnSave) {
+ await pinFeed({uri: feed.uri})
+ } else {
+ await saveFeed({uri: feed.uri})
+ }
+ Toast.show(_(msg`Added to my feeds`))
+ } catch (e) {
+ Toast.show(_(msg`There was an issue contacting your server`))
+ logger.error('Failed to save feed', {message: e})
+ }
+ }, [_, feed, pinFeed, pinOnSave, saveFeed])
+
+ const onUnsave = React.useCallback(async () => {
+ if (!feed) return
+
+ try {
+ await removeFeed({uri: feed.uri})
+ // await item.unsave()
+ Toast.show(_(msg`Removed from my feeds`))
+ } catch (e) {
+ Toast.show(_(msg`There was an issue contacting your server`))
+ logger.error('Failed to unsave feed', {message: e})
+ }
+ }, [_, feed, removeFeed])
+
const onToggleSaved = React.useCallback(async () => {
// Only feeds can be un/saved, lists are handled elsewhere
if (feed?.type !== 'feed') return
if (isSaved) {
- openModal({
- name: 'confirm',
- title: _(msg`Remove from my feeds`),
- message: _(msg`Remove ${feed?.displayName} from my feeds?`),
- onPressConfirm: async () => {
- try {
- await removeFeed({uri: feed.uri})
- // await item.unsave()
- Toast.show(_(msg`Removed from my feeds`))
- } catch (e) {
- Toast.show(_(msg`There was an issue contacting your server`))
- logger.error('Failed to unsave feed', {message: e})
- }
- },
- })
+ removePromptControl.open()
} else {
- try {
- if (pinOnSave) {
- await pinFeed({uri: feed.uri})
- } else {
- await saveFeed({uri: feed.uri})
- }
- Toast.show(_(msg`Added to my feeds`))
- } catch (e) {
- Toast.show(_(msg`There was an issue contacting your server`))
- logger.error('Failed to save feed', {message: e})
- }
+ await onSave()
}
- }, [isSaved, openModal, feed, removeFeed, saveFeed, _, pinOnSave, pinFeed])
+ }, [feed?.type, isSaved, removePromptControl, onSave])
/*
* LOAD STATE
@@ -168,25 +172,7 @@ export function FeedSourceCardLoaded({
accessibilityRole="button"
accessibilityLabel={_(msg`Remove from my feeds`)}
accessibilityHint=""
- onPress={() => {
- openModal({
- name: 'confirm',
- title: _(msg`Remove from my feeds`),
- message: _(msg`Remove this feed from my feeds?`),
- onPressConfirm: async () => {
- try {
- await removeFeed({uri: feedUri})
- // await item.unsave()
- Toast.show(_(msg`Removed from my feeds`))
- } catch (e) {
- Toast.show(
- _(msg`There was an issue contacting your server`),
- )
- logger.error('Failed to unsave feed', {message: e})
- }
- },
- })
- }}
+ onPress={onToggleSaved}
hitSlop={15}
style={styles.btn}>
{
- 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 307897fb8a..0000000000
--- 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 100444ff58..e03879c1de 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 0ced894a17..d72b7e4855 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 6d99c32f18..c52090f975 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 3e479d7b53..75e06eb9b7 100644
--- a/src/view/com/profile/ProfileHeader.tsx
+++ b/src/view/com/profile/ProfileHeader.tsx
@@ -7,7 +7,6 @@ import {
} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {useNavigation} from '@react-navigation/native'
-import {useQueryClient} from '@tanstack/react-query'
import {
AppBskyActorDefs,
ModerationOpts,
@@ -17,7 +16,7 @@ import {
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {NavigationProp} from 'lib/routes/types'
-import {isNative, isWeb} from 'platform/detection'
+import {isNative} from 'platform/detection'
import {BlurView} from '../util/BlurView'
import * as Toast from '../util/Toast'
import {LoadingPlaceholder} from '../util/LoadingPlaceholder'
@@ -28,14 +27,11 @@ import {UserAvatar} from '../util/UserAvatar'
import {UserBanner} from '../util/UserBanner'
import {ProfileHeaderAlerts} from '../util/moderation/ProfileHeaderAlerts'
import {formatCount} from '../util/numeric/format'
-import {NativeDropdown, DropdownItem} from '../util/forms/NativeDropdown'
import {Link} from '../util/Link'
import {ProfileHeaderSuggestedFollows} from './ProfileHeaderSuggestedFollows'
import {useModalControls} from '#/state/modals'
import {useLightboxControls, ProfileImageLightbox} from '#/state/lightbox'
import {
- RQKEY as profileQueryKey,
- useProfileMuteMutationQueue,
useProfileBlockMutationQueue,
useProfileFollowMutationQueue,
} from '#/state/queries/profile'
@@ -46,9 +42,7 @@ import {BACK_HITSLOP} from 'lib/constants'
import {isInvalidHandle, sanitizeHandle} from 'lib/strings/handles'
import {makeProfileLink} from 'lib/routes/links'
import {pluralize} from 'lib/strings/helpers'
-import {toShareUrl} from 'lib/strings/url-helpers'
import {sanitizeDisplayName} from 'lib/strings/display-names'
-import {shareUrl} from 'lib/sharing'
import {s, colors} from 'lib/styles'
import {logger} from '#/logger'
import {useSession} from '#/state/session'
@@ -57,6 +51,8 @@ import {useRequireAuth} from '#/state/session'
import {LabelInfo} from '../util/moderation/LabelInfo'
import {useProfileShadow} from 'state/cache/profile-shadow'
import {atoms as a} from '#/alf'
+import {ProfileMenu} from 'view/com/profile/ProfileMenu'
+import * as Prompt from '#/components/Prompt'
let ProfileHeaderLoading = (_props: {}): React.ReactNode => {
const pal = usePalette('default')
@@ -108,20 +104,13 @@ let ProfileHeader = ({
const {isDesktop} = useWebMediaQueries()
const [showSuggestedFollows, setShowSuggestedFollows] = React.useState(false)
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile)
- const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile)
- const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
- const queryClient = useQueryClient()
+ const [__, queueUnblock] = useProfileBlockMutationQueue(profile)
+ const unblockPromptControl = Prompt.usePromptControl()
const moderation = useMemo(
() => moderateProfile(profile, moderationOpts),
[profile, moderationOpts],
)
- const invalidateProfileQuery = React.useCallback(() => {
- queryClient.invalidateQueries({
- queryKey: profileQueryKey(profile.did),
- })
- }, [queryClient, profile.did])
-
const onPressBack = React.useCallback(() => {
if (navigation.canGoBack()) {
navigation.goBack()
@@ -189,204 +178,23 @@ let ProfileHeader = ({
})
}, [track, openModal, profile])
- const onPressShare = React.useCallback(() => {
- track('ProfileHeader:ShareButtonClicked')
- shareUrl(toShareUrl(makeProfileLink(profile)))
- }, [track, profile])
-
- const onPressAddRemoveLists = React.useCallback(() => {
- track('ProfileHeader:AddToListsButtonClicked')
- openModal({
- name: 'user-add-remove-lists',
- subject: profile.did,
- handle: profile.handle,
- displayName: profile.displayName || profile.handle,
- onAdd: invalidateProfileQuery,
- onRemove: invalidateProfileQuery,
- })
- }, [track, profile, openModal, invalidateProfileQuery])
-
- const onPressMuteAccount = React.useCallback(async () => {
- track('ProfileHeader:MuteAccountButtonClicked')
- try {
- await queueMute()
- Toast.show(_(msg`Account muted`))
- } catch (e: any) {
- if (e?.name !== 'AbortError') {
- logger.error('Failed to mute account', {message: e})
- Toast.show(_(msg`There was an issue! ${e.toString()}`))
- }
- }
- }, [track, queueMute, _])
-
- const onPressUnmuteAccount = React.useCallback(async () => {
- track('ProfileHeader:UnmuteAccountButtonClicked')
- 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 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()}`))
- }
- }
- },
- })
- }, [track, queueUnblock, openModal, _])
-
- const onPressReportAccount = React.useCallback(() => {
- track('ProfileHeader:ReportAccountButtonClicked')
- openModal({
- name: 'report',
- did: profile.did,
- })
- }, [track, openModal, profile])
+ 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,
[currentAccount, profile],
)
- const dropdownItems: DropdownItem[] = React.useMemo(() => {
- let items: DropdownItem[] = [
- {
- testID: 'profileHeaderDropdownShareBtn',
- label: isWeb ? _(msg`Copy link to profile`) : _(msg`Share`),
- onPress: onPressShare,
- icon: {
- ios: {
- name: 'square.and.arrow.up',
- },
- android: 'ic_menu_share',
- web: 'share',
- },
- },
- ]
- if (hasSession) {
- items.push({label: 'separator'})
- items.push({
- testID: 'profileHeaderDropdownListAddRemoveBtn',
- label: _(msg`Add to Lists`),
- onPress: onPressAddRemoveLists,
- icon: {
- ios: {
- name: 'list.bullet',
- },
- android: 'ic_menu_add',
- web: 'list',
- },
- })
- if (!isMe) {
- if (!profile.viewer?.blocking) {
- if (!profile.viewer?.mutedByList) {
- items.push({
- testID: 'profileHeaderDropdownMuteBtn',
- label: profile.viewer?.muted
- ? _(msg`Unmute Account`)
- : _(msg`Mute Account`),
- onPress: profile.viewer?.muted
- ? onPressUnmuteAccount
- : onPressMuteAccount,
- icon: {
- ios: {
- name: 'speaker.slash',
- },
- android: 'ic_lock_silent_mode',
- web: 'comment-slash',
- },
- })
- }
- }
- if (!profile.viewer?.blockingByList) {
- items.push({
- testID: 'profileHeaderDropdownBlockBtn',
- label: profile.viewer?.blocking
- ? _(msg`Unblock Account`)
- : _(msg`Block Account`),
- onPress: profile.viewer?.blocking
- ? onPressUnblockAccount
- : onPressBlockAccount,
- icon: {
- ios: {
- name: 'person.fill.xmark',
- },
- android: 'ic_menu_close_clear_cancel',
- web: 'user-slash',
- },
- })
- }
- items.push({
- testID: 'profileHeaderDropdownReportBtn',
- label: _(msg`Report Account`),
- onPress: onPressReportAccount,
- icon: {
- ios: {
- name: 'exclamationmark.triangle',
- },
- android: 'ic_menu_report_image',
- web: 'circle-exclamation',
- },
- })
- }
- }
- return items
- }, [
- isMe,
- hasSession,
- profile.viewer?.muted,
- profile.viewer?.mutedByList,
- profile.viewer?.blocking,
- profile.viewer?.blockingByList,
- onPressShare,
- onPressUnmuteAccount,
- onPressMuteAccount,
- onPressUnblockAccount,
- onPressBlockAccount,
- onPressReportAccount,
- onPressAddRemoveLists,
- _,
- ])
const blockHide =
!isMe && (profile.viewer?.blocking || profile.viewer?.blockedBy)
@@ -427,7 +235,7 @@ let ProfileHeader = ({
profile.viewer?.blockingByList ? null : (
unblockPromptControl.open()}
style={[styles.btn, styles.mainBtn, pal.btn]}
accessibilityRole="button"
accessibilityLabel={_(msg`Unblock`)}
@@ -516,17 +324,7 @@ let ProfileHeader = ({
)}
>
) : null}
- {dropdownItems?.length ? (
-
-
-
-
-
- ) : undefined}
+
+
)
}
diff --git a/src/view/com/profile/ProfileMenu.tsx b/src/view/com/profile/ProfileMenu.tsx
new file mode 100644
index 0000000000..4153b819ea
--- /dev/null
+++ b/src/view/com/profile/ProfileMenu.tsx
@@ -0,0 +1,315 @@
+import React, {memo} from 'react'
+import {TouchableOpacity} from 'react-native'
+import {AppBskyActorDefs} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {useQueryClient} from '@tanstack/react-query'
+import * as Toast from 'view/com/util/Toast'
+import {EventStopper} from 'view/com/util/EventStopper'
+import {useSession} from 'state/session'
+import * as Menu from '#/components/Menu'
+import {useTheme} from '#/alf'
+import {usePalette} from 'lib/hooks/usePalette'
+import {HITSLOP_10} from 'lib/constants'
+import {shareUrl} from 'lib/sharing'
+import {toShareUrl} from 'lib/strings/url-helpers'
+import {makeProfileLink} from 'lib/routes/links'
+import {useAnalytics} from 'lib/analytics/analytics'
+import {useModalControls} from 'state/modals'
+import {
+ RQKEY as profileQueryKey,
+ useProfileBlockMutationQueue,
+ useProfileFollowMutationQueue,
+ useProfileMuteMutationQueue,
+} from 'state/queries/profile'
+import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox'
+import {ListSparkle_Stroke2_Corner0_Rounded as List} from '#/components/icons/ListSparkle'
+import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
+import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
+import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag'
+import {PersonCheck_Stroke2_Corner0_Rounded as PersonCheck} from '#/components/icons/PersonCheck'
+import {PersonX_Stroke2_Corner0_Rounded as PersonX} from '#/components/icons/PersonX'
+import {PeopleRemove2_Stroke2_Corner0_Rounded as UserMinus} from '#/components/icons/PeopleRemove2'
+import {logger} from '#/logger'
+import {Shadow} from 'state/cache/types'
+import * as Prompt from '#/components/Prompt'
+
+let ProfileMenu = ({
+ profile,
+}: {
+ profile: Shadow
+}): React.ReactNode => {
+ const {_} = useLingui()
+ const {currentAccount, hasSession} = useSession()
+ const t = useTheme()
+ // TODO ALF this
+ const pal = usePalette('default')
+ const {track} = useAnalytics()
+ const {openModal} = useModalControls()
+ const queryClient = useQueryClient()
+ const isSelf = currentAccount?.did === profile.did
+
+ const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile)
+ const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
+ const [, queueUnfollow] = useProfileFollowMutationQueue(profile)
+
+ const blockPromptControl = Prompt.usePromptControl()
+
+ const invalidateProfileQuery = React.useCallback(() => {
+ queryClient.invalidateQueries({
+ queryKey: profileQueryKey(profile.did),
+ })
+ }, [queryClient, profile.did])
+
+ const onPressShare = React.useCallback(() => {
+ track('ProfileHeader:ShareButtonClicked')
+ shareUrl(toShareUrl(makeProfileLink(profile)))
+ }, [track, profile])
+
+ const onPressAddRemoveLists = React.useCallback(() => {
+ track('ProfileHeader:AddToListsButtonClicked')
+ openModal({
+ name: 'user-add-remove-lists',
+ subject: profile.did,
+ handle: profile.handle,
+ displayName: profile.displayName || profile.handle,
+ onAdd: invalidateProfileQuery,
+ onRemove: invalidateProfileQuery,
+ })
+ }, [track, profile, openModal, invalidateProfileQuery])
+
+ const onPressMuteAccount = React.useCallback(async () => {
+ if (profile.viewer?.muted) {
+ track('ProfileHeader:UnmuteAccountButtonClicked')
+ try {
+ await queueUnmute()
+ Toast.show(_(msg`Account unmuted`))
+ } catch (e: any) {
+ if (e?.name !== 'AbortError') {
+ logger.error('Failed to unmute account', {message: e})
+ Toast.show(_(msg`There was an issue! ${e.toString()}`))
+ }
+ }
+ } else {
+ track('ProfileHeader:MuteAccountButtonClicked')
+ try {
+ await queueMute()
+ Toast.show(_(msg`Account muted`))
+ } catch (e: any) {
+ if (e?.name !== 'AbortError') {
+ logger.error('Failed to mute account', {message: e})
+ Toast.show(_(msg`There was an issue! ${e.toString()}`))
+ }
+ }
+ }
+ }, [profile.viewer?.muted, track, queueUnmute, _, queueMute])
+
+ const blockAccount = React.useCallback(async () => {
+ if (profile.viewer?.blocking) {
+ track('ProfileHeader:UnblockAccountButtonClicked')
+ try {
+ await queueUnblock()
+ Toast.show(_(msg`Account unblocked`))
+ } catch (e: any) {
+ if (e?.name !== 'AbortError') {
+ logger.error('Failed to unblock account', {message: e})
+ Toast.show(_(msg`There was an issue! ${e.toString()}`))
+ }
+ }
+ } else {
+ track('ProfileHeader:BlockAccountButtonClicked')
+ try {
+ await queueBlock()
+ Toast.show(_(msg`Account blocked`))
+ } catch (e: any) {
+ if (e?.name !== 'AbortError') {
+ logger.error('Failed to block account', {message: e})
+ Toast.show(_(msg`There was an issue! ${e.toString()}`))
+ }
+ }
+ }
+ }, [profile.viewer?.blocking, track, _, queueUnblock, queueBlock])
+
+ const onPressUnfollowAccount = React.useCallback(async () => {
+ track('ProfileHeader:UnfollowButtonClicked')
+ try {
+ await queueUnfollow()
+ Toast.show(_(msg`Account unfollowed`))
+ } catch (e: any) {
+ if (e?.name !== 'AbortError') {
+ logger.error('Failed to unfollow account', {message: e})
+ Toast.show(_(msg`There was an issue! ${e.toString()}`))
+ }
+ }
+ }, [_, queueUnfollow, track])
+
+ const onPressReportAccount = React.useCallback(() => {
+ track('ProfileHeader:ReportAccountButtonClicked')
+ openModal({
+ name: 'report',
+ did: profile.did,
+ })
+ }, [track, openModal, profile])
+
+ return (
+
+
+
+ {({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 && (
+ blockPromptControl.open()}>
+
+ {profile.viewer?.blocking ? (
+ Unblock Account
+ ) : (
+ Block Account
+ )}
+
+
+
+ )}
+
+
+ Report Account
+
+
+
+ >
+ )}
+
+ >
+ )}
+
+
+
+
+
+ )
+}
+
+ProfileMenu = memo(ProfileMenu)
+export {ProfileMenu}
diff --git a/src/view/com/util/Link.tsx b/src/view/com/util/Link.tsx
index e50fb7f091..f456224885 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/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx
index f673db1ee1..4132373974 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 cb47b66594..a5ddfee8ac 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 */) ? (
{({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,
+ ],
+ ]}>
{
- 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`)}
@@ -315,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`)}
@@ -352,6 +323,25 @@ let PostDropdownBtn = ({
+
+
+
+
)
}
diff --git a/src/view/com/util/load-latest/LoadLatestBtn.tsx b/src/view/com/util/load-latest/LoadLatestBtn.tsx
index 5fad117607..f02e4a2bd7 100644
--- a/src/view/com/util/load-latest/LoadLatestBtn.tsx
+++ b/src/view/com/util/load-latest/LoadLatestBtn.tsx
@@ -1,12 +1,13 @@
import React from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import Animated from 'react-native-reanimated'
+import {useMediaQuery} from 'react-responsive'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {colors} from 'lib/styles'
import {HITSLOP_20} from 'lib/constants'
import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
-import Animated from 'react-native-reanimated'
const AnimatedTouchableOpacity =
Animated.createAnimatedComponent(TouchableOpacity)
import {isWeb} from 'platform/detection'
@@ -26,6 +27,9 @@ export function LoadLatestBtn({
const {isDesktop, isTablet, isMobile, isTabletOrMobile} = useWebMediaQueries()
const {fabMinimalShellTransform} = useMinimalShellMode()
+ // move button inline if it starts overlapping the left nav
+ const isTallViewport = useMediaQuery({minHeight: 700})
+
// Adjust height of the fab if we have a session only on mobile web. If we don't have a session, we want to adjust
// it on both tablet and mobile since we are showing the bottom bar (see createNativeStackNavigatorWithAuth)
const showBottomBar = hasSession ? isMobile : isTabletOrMobile
@@ -34,8 +38,11 @@ export function LoadLatestBtn({
export function AppPasswords({}: Props) {
@@ -212,23 +214,18 @@ function AppPassword({
}) {
const pal = usePalette('default')
const {_} = useLingui()
- const {openModal} = useModalControls()
+ const control = useDialogControl()
const {contentLanguages} = useLanguagePrefs()
const deleteMutation = useAppPasswordDeleteMutation()
const onDelete = React.useCallback(async () => {
- openModal({
- name: 'confirm',
- title: _(msg`Delete app password`),
- message: _(
- msg`Are you sure you want to delete the app password "${name}"?`,
- ),
- async onPressConfirm() {
- await deleteMutation.mutateAsync({name})
- Toast.show(_(msg`App password deleted`))
- },
- })
- }, [deleteMutation, openModal, name, _])
+ await deleteMutation.mutateAsync({name})
+ Toast.show(_(msg`App password deleted`))
+ }, [deleteMutation, name, _])
+
+ const onPress = React.useCallback(() => {
+ control.open()
+ }, [control])
const primaryLocale =
contentLanguages.length > 0 ? contentLanguages[0] : 'en-US'
@@ -237,7 +234,7 @@ function AppPassword({
@@ -260,6 +257,17 @@ function AppPassword({
+
+
)
}
diff --git a/src/view/screens/ProfileFeed.tsx b/src/view/screens/ProfileFeed.tsx
index d92c1cd837..579e77f578 100644
--- a/src/view/screens/ProfileFeed.tsx
+++ b/src/view/screens/ProfileFeed.tsx
@@ -1,11 +1,9 @@
import React, {useMemo, useCallback} from 'react'
-import {Dimensions, StyleSheet, View} from 'react-native'
+import {StyleSheet, View, Pressable} from 'react-native'
import {NativeStackScreenProps} from '@react-navigation/native-stack'
import {useIsFocused, useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
import {usePalette} from 'lib/hooks/usePalette'
-import {HeartIcon, HeartIconSolid} from 'lib/icons'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {CommonNavigatorParams} from 'lib/routes/types'
import {makeRecordUri} from 'lib/strings/url-helpers'
import {s} from 'lib/styles'
@@ -13,7 +11,7 @@ import {FeedDescriptor} from '#/state/queries/post-feed'
import {PagerWithHeader} from 'view/com/pager/PagerWithHeader'
import {ProfileSubpageHeader} from 'view/com/profile/ProfileSubpageHeader'
import {Feed} from 'view/com/posts/Feed'
-import {TextLink} from 'view/com/util/Link'
+import {InlineLink} from '#/components/Link'
import {ListRef} from 'view/com/util/List'
import {Button} from 'view/com/util/forms/Button'
import {Text} from 'view/com/util/text/Text'
@@ -29,15 +27,10 @@ import {shareUrl} from 'lib/sharing'
import {toShareUrl} from 'lib/strings/url-helpers'
import {Haptics} from 'lib/haptics'
import {useAnalytics} from 'lib/analytics/analytics'
-import {NativeDropdown, DropdownItem} from 'view/com/util/forms/NativeDropdown'
-import {useScrollHandlers} from '#/lib/ScrollContext'
-import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
import {makeCustomFeedLink} from 'lib/routes/links'
import {pluralize} from 'lib/strings/helpers'
-import {CenteredView, ScrollView} from 'view/com/util/Views'
+import {CenteredView} from 'view/com/util/Views'
import {NavigationProp} from 'lib/routes/types'
-import {sanitizeHandle} from 'lib/strings/handles'
-import {makeProfileLink} from 'lib/routes/links'
import {ComposeIcon2} from 'lib/icons'
import {logger} from '#/logger'
import {Trans, msg} from '@lingui/macro'
@@ -59,9 +52,21 @@ import {useComposerControls} from '#/state/shell/composer'
import {truncateAndInvalidate} from '#/state/queries/util'
import {isNative} from '#/platform/detection'
import {listenSoftReset} from '#/state/events'
-import {atoms as a} from '#/alf'
+import {atoms as a, useTheme} from '#/alf'
+import * as Menu from '#/components/Menu'
+import {HITSLOP_20} from '#/lib/constants'
+import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid'
+import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
+import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
+import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
+import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox'
+import {
+ Heart2_Stroke2_Corner0_Rounded as HeartOutline,
+ Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled,
+} from '#/components/icons/Heart2'
+import {Button as NewButton, ButtonText} from '#/components/Button'
-const SECTION_TITLES = ['Posts', 'About']
+const SECTION_TITLES = ['Posts']
interface SectionRef {
scrollToTop: () => void
@@ -148,7 +153,7 @@ export function ProfileFeedScreenInner({
feedInfo: FeedSourceFeedInfo
}) {
const {_} = useLingui()
- const pal = usePalette('default')
+ const t = useTheme()
const {hasSession, currentAccount} = useSession()
const {openModal} = useModalControls()
const {openComposer} = useComposerControls()
@@ -200,9 +205,11 @@ export function ProfileFeedScreenInner({
if (isSaved) {
await removeFeed({uri: feedInfo.uri})
resetRemoveFeed()
+ Toast.show(_(msg`Removed from your feeds`))
} else {
await saveFeed({uri: feedInfo.uri})
resetSaveFeed()
+ Toast.show(_(msg`Saved to your feeds`))
}
} catch (err) {
Toast.show(
@@ -263,130 +270,132 @@ export function ProfileFeedScreenInner({
[feedSectionRef],
)
- // render
- // =
-
- const dropdownItems: DropdownItem[] = React.useMemo(() => {
- return [
- hasSession && {
- testID: 'feedHeaderDropdownToggleSavedBtn',
- label: isSaved ? _(msg`Remove from my feeds`) : _(msg`Add to my feeds`),
- onPress: isSavePending || isRemovePending ? undefined : onToggleSaved,
- icon: isSaved
- ? {
- ios: {
- name: 'trash',
- },
- android: 'ic_delete',
- web: ['far', 'trash-can'],
- }
- : {
- ios: {
- name: 'plus',
- },
- android: '',
- web: 'plus',
- },
- },
- hasSession && {
- testID: 'feedHeaderDropdownReportBtn',
- label: _(msg`Report feed`),
- onPress: onPressReport,
- icon: {
- ios: {
- name: 'exclamationmark.triangle',
- },
- android: 'ic_menu_report_image',
- web: 'circle-exclamation',
- },
- },
- {
- testID: 'feedHeaderDropdownShareBtn',
- label: _(msg`Share feed`),
- onPress: onPressShare,
- icon: {
- ios: {
- name: 'square.and.arrow.up',
- },
- android: 'ic_menu_share',
- web: 'share',
- },
- },
- ].filter(Boolean) as DropdownItem[]
- }, [
- hasSession,
- onToggleSaved,
- onPressReport,
- onPressShare,
- isSaved,
- isSavePending,
- isRemovePending,
- _,
- ])
-
const renderHeader = useCallback(() => {
return (
-
- {feedInfo && hasSession && (
- <>
-
-
- >
- )}
-
-
-
+ <>
+
+
+ {feedInfo && hasSession && (
+
+
+ {isPinned ? _(msg`Unpin`) : _(msg`Pin to Home`)}
+
+
+ )}
+
+
+ {({props, state}) => {
+ return (
+
+
+
+ )
+ }}
+
+
+
+
+ {hasSession && (
+ <>
+
+
+ {isSaved
+ ? _(msg`Remove from my feeds`)
+ : _(msg`Save to my feeds`)}
+
+
+
+
+
+ {_(msg`Report feed`)}
+
+
+ >
+ )}
+
+
+ {_(msg`Share feed`)}
+
+
+
+
+
-
-
+
+
+ >
)
}, [
_,
hasSession,
- pal,
feedInfo,
isPinned,
onTogglePinned,
onToggleSaved,
- dropdownItems,
currentAccount?.did,
isPinPending,
isRemovePending,
isSavePending,
isSaved,
isUnpinPending,
+ onPressReport,
+ onPressShare,
+ t,
])
return (
@@ -405,18 +414,6 @@ export function ProfileFeedScreenInner({
isFocused={isScreenFocused && isFocused}
/>
)}
- {({headerHeight, scrollElRef}) => (
-
- }
- isOwner={feedInfo.creatorDid === currentAccount?.did}
- />
- )}
{hasSession && (
- isOwner: boolean
}) {
+ const t = useTheme()
const pal = usePalette('default')
const {_} = useLingui()
- const scrollHandlers = useScrollHandlers()
- const onScroll = useAnimatedScrollHandler(scrollHandlers)
const [likeUri, setLikeUri] = React.useState(feedInfo.likeUri)
const {hasSession} = useSession()
const {track} = useAnalytics()
@@ -555,24 +545,8 @@ function AboutSection({
}, [likeUri, isLiked, feedInfo, likeFeed, unlikeFeed, track, _])
return (
-
-
+
+
{feedInfo.description ? (
No description
)}
-
-
- {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,
+ },
})
diff --git a/src/view/screens/ProfileList.tsx b/src/view/screens/ProfileList.tsx
index 3c675ee0af..7986111577 100644
--- a/src/view/screens/ProfileList.tsx
+++ b/src/view/screens/ProfileList.tsx
@@ -61,6 +61,8 @@ import {logger} from '#/logger'
import {useAnalytics} from '#/lib/analytics/analytics'
import {listenSoftReset} from '#/state/events'
import {atoms as a, useTheme} from '#/alf'
+import * as Prompt from '#/components/Prompt'
+import {useDialogControl} from '#/components/Dialog'
const SECTION_TITLES_CURATE = ['Posts', 'About']
const SECTION_TITLES_MOD = ['About']
@@ -234,7 +236,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
const {_} = useLingui()
const navigation = useNavigation()
const {currentAccount} = useSession()
- const {openModal, closeModal} = useModalControls()
+ const {openModal} = useModalControls()
const listMuteMutation = useListMuteMutation()
const listBlockMutation = useListBlockMutation()
const listDeleteMutation = useListDeleteMutation()
@@ -251,6 +253,10 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
const {mutate: setSavedFeeds} = useSetSaveFeedsMutation()
const {track} = useAnalytics()
+ const deleteListPromptControl = useDialogControl()
+ const subscribeMutePromptControl = useDialogControl()
+ const subscribeBlockPromptControl = useDialogControl()
+
const isPinned = preferences?.feeds?.pinned?.includes(list.uri)
const isSaved = preferences?.feeds?.saved?.includes(list.uri)
@@ -269,32 +275,19 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
}
}, [list.uri, isPinned, pinFeed, unpinFeed, _])
- const onSubscribeMute = useCallback(() => {
- openModal({
- name: 'confirm',
- title: _(msg`Mute these accounts?`),
- message: _(
- msg`Muting is private. Muted accounts can interact with you, but you will not see their posts or receive notifications from them.`,
- ),
- confirmBtnText: _(msg`Mute this List`),
- async onPressConfirm() {
- try {
- await listMuteMutation.mutateAsync({uri: list.uri, mute: true})
- Toast.show(_(msg`List muted`))
- track('Lists:Mute')
- } catch {
- Toast.show(
- _(
- msg`There was an issue. Please check your internet connection and try again.`,
- ),
- )
- }
- },
- onPressCancel() {
- closeModal()
- },
- })
- }, [openModal, closeModal, list, listMuteMutation, track, _])
+ const onSubscribeMute = useCallback(async () => {
+ try {
+ await listMuteMutation.mutateAsync({uri: list.uri, mute: true})
+ Toast.show(_(msg`List muted`))
+ track('Lists:Mute')
+ } catch {
+ Toast.show(
+ _(
+ msg`There was an issue. Please check your internet connection and try again.`,
+ ),
+ )
+ }
+ }, [list, listMuteMutation, track, _])
const onUnsubscribeMute = useCallback(async () => {
try {
@@ -310,32 +303,19 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
}
}, [list, listMuteMutation, track, _])
- const onSubscribeBlock = useCallback(() => {
- openModal({
- name: 'confirm',
- title: _(msg`Block these accounts?`),
- message: _(
- msg`Blocking is public. Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`,
- ),
- confirmBtnText: _(msg`Block this List`),
- async onPressConfirm() {
- try {
- await listBlockMutation.mutateAsync({uri: list.uri, block: true})
- Toast.show(_(msg`List blocked`))
- track('Lists:Block')
- } catch {
- Toast.show(
- _(
- msg`There was an issue. Please check your internet connection and try again.`,
- ),
- )
- }
- },
- onPressCancel() {
- closeModal()
- },
- })
- }, [openModal, closeModal, list, listBlockMutation, track, _])
+ const onSubscribeBlock = useCallback(async () => {
+ try {
+ await listBlockMutation.mutateAsync({uri: list.uri, block: true})
+ Toast.show(_(msg`List blocked`))
+ track('Lists:Block')
+ } catch {
+ Toast.show(
+ _(
+ msg`There was an issue. Please check your internet connection and try again.`,
+ ),
+ )
+ }
+ }, [list, listBlockMutation, track, _])
const onUnsubscribeBlock = useCallback(async () => {
try {
@@ -358,34 +338,26 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
})
}, [openModal, list])
- const onPressDelete = useCallback(() => {
- openModal({
- name: 'confirm',
- title: _(msg`Delete List`),
- message: _(msg`Are you sure?`),
- async onPressConfirm() {
- await listDeleteMutation.mutateAsync({uri: list.uri})
+ const onPressDelete = useCallback(async () => {
+ await listDeleteMutation.mutateAsync({uri: list.uri})
- if (isSaved || isPinned) {
- const {saved, pinned} = preferences!.feeds
+ if (isSaved || isPinned) {
+ const {saved, pinned} = preferences!.feeds
- setSavedFeeds({
- saved: isSaved ? saved.filter(uri => uri !== list.uri) : saved,
- pinned: isPinned ? pinned.filter(uri => uri !== list.uri) : pinned,
- })
- }
+ setSavedFeeds({
+ saved: isSaved ? saved.filter(uri => uri !== list.uri) : saved,
+ pinned: isPinned ? pinned.filter(uri => uri !== list.uri) : pinned,
+ })
+ }
- Toast.show(_(msg`List deleted`))
- track('Lists:Delete')
- if (navigation.canGoBack()) {
- navigation.goBack()
- } else {
- navigation.navigate('Home')
- }
- },
- })
+ Toast.show(_(msg`List deleted`))
+ track('Lists:Delete')
+ if (navigation.canGoBack()) {
+ navigation.goBack()
+ } else {
+ navigation.navigate('Home')
+ }
}, [
- openModal,
list,
listDeleteMutation,
navigation,
@@ -443,7 +415,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
items.push({
testID: 'listHeaderDropdownDeleteBtn',
label: _(msg`Delete List`),
- onPress: onPressDelete,
+ onPress: deleteListPromptControl.open,
icon: {
ios: {
name: 'trash',
@@ -489,7 +461,9 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
items.push({
testID: 'listHeaderDropdownMuteBtn',
label: isMuting ? _(msg`Un-mute list`) : _(msg`Mute list`),
- onPress: isMuting ? onUnsubscribeMute : onSubscribeMute,
+ onPress: isMuting
+ ? onUnsubscribeMute
+ : subscribeMutePromptControl.open,
icon: {
ios: {
name: isMuting ? 'eye' : 'eye.slash',
@@ -504,7 +478,9 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
items.push({
testID: 'listHeaderDropdownBlockBtn',
label: isBlocking ? _(msg`Un-block list`) : _(msg`Block list`),
- onPress: isBlocking ? onUnsubscribeBlock : onSubscribeBlock,
+ onPress: isBlocking
+ ? onUnsubscribeBlock
+ : subscribeBlockPromptControl.open,
icon: {
ios: {
name: 'person.fill.xmark',
@@ -517,24 +493,24 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
}
return items
}, [
- isOwner,
- onPressShare,
- onPressEdit,
- onPressDelete,
- onPressReport,
_,
+ onPressShare,
+ isOwner,
isModList,
isPinned,
- unpinFeed,
- isPending,
- list.uri,
isCurateList,
- isMuting,
+ onPressEdit,
+ deleteListPromptControl.open,
+ onPressReport,
+ isPending,
+ unpinFeed,
+ list.uri,
isBlocking,
+ isMuting,
onUnsubscribeMute,
- onSubscribeMute,
+ subscribeMutePromptControl.open,
onUnsubscribeBlock,
- onSubscribeBlock,
+ subscribeBlockPromptControl.open,
])
const subscribeDropdownItems: DropdownItem[] = useMemo(() => {
@@ -542,7 +518,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
{
testID: 'subscribeDropdownMuteBtn',
label: _(msg`Mute accounts`),
- onPress: onSubscribeMute,
+ onPress: subscribeMutePromptControl.open,
icon: {
ios: {
name: 'speaker.slash',
@@ -554,7 +530,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
{
testID: 'subscribeDropdownBlockBtn',
label: _(msg`Block accounts`),
- onPress: onSubscribeBlock,
+ onPress: subscribeBlockPromptControl.open,
icon: {
ios: {
name: 'person.fill.xmark',
@@ -564,7 +540,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
},
},
]
- }, [onSubscribeMute, onSubscribeBlock, _])
+ }, [_, subscribeMutePromptControl.open, subscribeBlockPromptControl.open])
return (
+
+
+
+
+
+
)
}
diff --git a/src/view/screens/Storybook/Dialogs.tsx b/src/view/screens/Storybook/Dialogs.tsx
index 09be124db5..c2eaf19acf 100644
--- a/src/view/screens/Storybook/Dialogs.tsx
+++ b/src/view/screens/Storybook/Dialogs.tsx
@@ -68,7 +68,7 @@ export function Dialogs() {
Cancel
- Confirm
+ {}}>Confirm
diff --git a/src/view/screens/Storybook/Menus.tsx b/src/view/screens/Storybook/Menus.tsx
index 082fb2b6e1..2f2b147215 100644
--- a/src/view/screens/Storybook/Menus.tsx
+++ b/src/view/screens/Storybook/Menus.tsx
@@ -16,7 +16,7 @@ export function Menus() {
-
+
{({state, props}) => {
return (
{
closeAllActiveElements()
@@ -74,12 +76,12 @@ export function BottomBar({navigation}: BottomTabBarProps) {
if (tabState === TabState.InsideAtRoot) {
emitSoftReset()
} else if (tabState === TabState.Inside) {
- navigation.dispatch(StackActions.popToTop())
+ dedupe(() => navigation.dispatch(StackActions.popToTop()))
} else {
- navigation.navigate(`${tab}Tab`)
+ dedupe(() => navigation.navigate(`${tab}Tab`))
}
},
- [track, navigation],
+ [track, navigation, dedupe],
)
const onPressHome = React.useCallback(() => onPressTab('Home'), [onPressTab])
const onPressSearch = React.useCallback(
diff --git a/yarn.lock b/yarn.lock
index 386bc1a3be..28d8210285 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -9475,9 +9475,9 @@ caniuse-api@^3.0.0:
lodash.uniq "^4.5.0"
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001517, caniuse-lite@^1.0.30001520:
- version "1.0.30001522"
- resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001522.tgz#44b87a406c901269adcdb834713e23582dd71856"
- integrity sha512-TKiyTVZxJGhsTszLuzb+6vUZSjVOAhClszBr2Ta2k9IwtNBT/4dzmL6aywt0HCgEZlmwJzXJd8yNiob6HgwTRg==
+ version "1.0.30001596"
+ resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001596.tgz"
+ integrity sha512-zpkZ+kEr6We7w63ORkoJ2pOfBwBkY/bJrG/UZ90qNb45Isblu8wzDgevEOrRL1r9dWayHjYiiyCMEXPn4DweGQ==
case-anything@^2.1.13:
version "2.1.13"
@@ -15059,11 +15059,6 @@ js-sha256@^0.10.1:
resolved "https://registry.yarnpkg.com/js-sha256/-/js-sha256-0.10.1.tgz#b40104ba1368e823fdd5f41b66b104b15a0da60d"
integrity sha512-5obBtsz9301ULlsgggLg542s/jqtddfOpV5KJc4hajc9JV8GeY2gZHSVpYBn4nWqAUTJ9v+xwtbJ1mIBgIH5Vw==
-js-sha256@^0.11.0:
- version "0.11.0"
- resolved "https://registry.yarnpkg.com/js-sha256/-/js-sha256-0.11.0.tgz#256a921d9292f7fe98905face82e367abaca9576"
- integrity sha512-6xNlKayMZvds9h1Y1VWc0fQHQ82BxTXizWPEtEeGvmOUYpBRy4gbWroHLpzowe6xiQhHpelCQiE7HEdznyBL9Q==
-
js-sha256@^0.9.0:
version "0.9.0"
resolved "https://registry.yarnpkg.com/js-sha256/-/js-sha256-0.9.0.tgz#0b89ac166583e91ef9123644bd3c5334ce9d0966"
@@ -20082,14 +20077,6 @@ statsig-js@4.45.1:
js-sha256 "^0.10.1"
uuid "^8.3.2"
-statsig-js@4.49.0:
- version "4.49.0"
- resolved "https://registry.yarnpkg.com/statsig-js/-/statsig-js-4.49.0.tgz#8470a9ac218a93d36f4b7b306ff9377e48064740"
- integrity sha512-N4drx6fzI168Q4NndFY3IJbSDqpWSBWvS290H/RnT/g3Et58SvtXzG5qqgzmqy4CwcmwH+IL8K15pL7hPnfvUQ==
- dependencies:
- js-sha256 "^0.11.0"
- uuid "^8.3.2"
-
statsig-react-native-expo@^4.6.1:
version "4.6.1"
resolved "https://registry.yarnpkg.com/statsig-react-native-expo/-/statsig-react-native-expo-4.6.1.tgz#0bdf49fee7112f7f28bff2405f4ba0c1727bb3d6"
@@ -20110,13 +20097,6 @@ statsig-react@^1.21.1:
dependencies:
statsig-js "4.45.1"
-statsig-react@^1.36.0:
- version "1.36.0"
- resolved "https://registry.yarnpkg.com/statsig-react/-/statsig-react-1.36.0.tgz#c2171268a6c76eee534849ec9556b836baba04b6"
- integrity sha512-QcTHla3ypfn2RvrnHGNlqWbiC2W/ZjcMM5LT6ExNV4skH7Xhspto3dMS3JVzBhOb74OEDZK4DbxQj9Wdz6XW0w==
- dependencies:
- statsig-js "4.49.0"
-
statuses@2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63"