Merge branch 'bluesky-social:main' into patch-3

zio/stable
Minseo Lee 2024-03-13 10:30:07 +09:00 committed by GitHub
commit 3ead08ab26
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
53 changed files with 1575 additions and 1382 deletions

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M8.371 3.89A2 2 0 0 1 10.035 3h3.93a2 2 0 0 1 1.664.89L17.035 6H20a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h2.965L8.37 3.89ZM12 9a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 330 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M8.371 3.89A2 2 0 0 1 10.035 3h3.93a2 2 0 0 1 1.664.89L17.035 6H20a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h2.965L8.37 3.89ZM13.965 5h-3.93L8.63 7.11A2 2 0 0 1 6.965 8H4v11h16V8h-2.965a2 2 0 0 1-1.664-.89L13.965 5ZM12 11a2 2 0 1 0 0 4 2 2 0 0 0 0-4Zm-4 2a4 4 0 1 1 8 0 4 4 0 0 1-8 0Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 447 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M2 12a2 2 0 1 1 4 0 2 2 0 0 1-4 0Zm16 0a2 2 0 1 1 4 0 2 2 0 0 1-4 0Zm-6-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 245 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M4 4a2 2 0 0 1 2-2h13.131c1.598 0 2.55 1.78 1.665 3.11L18.202 9l2.594 3.89c.886 1.33-.067 3.11-1.665 3.11H6v5a1 1 0 1 1-2 0V4Zm2 10h13.131l-2.593-3.89a2 2 0 0 1 0-2.22L19.13 4H6v10Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 323 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#000" fill-rule="evenodd" d="M10 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM5.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM16 11a1 1 0 0 1 1-1h5a1 1 0 1 1 0 2h-5a1 1 0 0 1-1-1ZM3.678 19h12.644c-.71-2.909-3.092-5-6.322-5s-5.613 2.091-6.322 5Zm-2.174.906C1.917 15.521 5.242 12 10 12c4.758 0 8.083 3.521 8.496 7.906A1 1 0 0 1 17.5 21h-15a1 1 0 0 1-.996-1.094Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 466 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.679 19c.709-2.902 3.079-5 6.321-5a6.69 6.69 0 0 1 2.612.51 1 1 0 0 0 .776-1.844A8.687 8.687 0 0 0 12 12c-4.3 0-7.447 2.884-8.304 6.696-.29 1.29.767 2.304 1.902 2.304H11a1 1 0 1 0 0-2H5.679Zm14.835-4.857a1 1 0 0 1 .344 1.371l-3 5a1 1 0 0 1-1.458.286l-2-1.5a1 1 0 0 1 1.2-1.6l1.113.835 2.43-4.05a1 1 0 0 1 1.372-.342Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 547 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.679 19c.709-2.902 3.079-5 6.321-5 .302 0 .595.018.878.053a1 1 0 0 0 .243-1.985A9.235 9.235 0 0 0 12 12c-4.3 0-7.447 2.884-8.304 6.696-.29 1.29.767 2.304 1.902 2.304H12a1 1 0 1 0 0-2H5.679Zm9.614-3.707a1 1 0 0 1 1.414 0L18 16.586l1.293-1.293a1 1 0 0 1 1.414 1.414L19.414 18l1.293 1.293a1 1 0 0 1-1.414 1.414L18 19.414l-1.293 1.293a1 1 0 0 1-1.414-1.414L16.586 18l-1.293-1.293a1 1 0 0 1 0-1.414Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 625 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M4 4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2H4Zm8 12.5c1.253 0 2.197.609 2.674 1.5H9.326c.477-.891 1.42-1.5 2.674-1.5Zm0-2c2.404 0 4.235 1.475 4.822 3.5H20V6H4v12h3.178c.587-2.025 2.418-3.5 4.822-3.5Zm-1.25-3.75a1.25 1.25 0 1 1 2.5 0 1.25 1.25 0 0 1-2.5 0ZM12 7.5a3.25 3.25 0 1 0 0 6.5 3.25 3.25 0 0 0 0-6.5Zm5.75 2a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 527 B

View File

@ -179,7 +179,6 @@
"react-responsive": "^9.0.2", "react-responsive": "^9.0.2",
"rn-fetch-blob": "^0.12.0", "rn-fetch-blob": "^0.12.0",
"sentry-expo": "~7.0.1", "sentry-expo": "~7.0.1",
"statsig-react": "^1.36.0",
"statsig-react-native-expo": "^4.6.1", "statsig-react-native-expo": "^4.6.1",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"tlds": "^1.234.0", "tlds": "^1.234.0",

View File

@ -1,3 +1,4 @@
import {Platform} from 'react-native'
import {web, native} from '#/alf/util/platform' import {web, native} from '#/alf/util/platform'
import * as tokens from '#/alf/tokens' import * as tokens from '#/alf/tokens'
@ -6,7 +7,7 @@ export const atoms = {
* Positioning * Positioning
*/ */
fixed: { fixed: {
position: 'fixed', position: Platform.select({web: 'fixed', native: 'absolute'}) as 'absolute',
}, },
absolute: { absolute: {
position: 'absolute', position: 'absolute',
@ -111,6 +112,12 @@ export const atoms = {
flex_row: { flex_row: {
flexDirection: 'row', flexDirection: 'row',
}, },
flex_col_reverse: {
flexDirection: 'column-reverse',
},
flex_row_reverse: {
flexDirection: 'row-reverse',
},
flex_wrap: { flex_wrap: {
flexWrap: 'wrap', flexWrap: 'wrap',
}, },

View File

@ -75,6 +75,7 @@ export function Outer({
control, control,
onClose, onClose,
nativeOptions, nativeOptions,
testID,
}: React.PropsWithChildren<DialogOuterProps>) { }: React.PropsWithChildren<DialogOuterProps>) {
const t = useTheme() const t = useTheme()
const sheet = React.useRef<BottomSheet>(null) const sheet = React.useRef<BottomSheet>(null)
@ -145,7 +146,8 @@ export function Outer({
accessibilityViewIsModal accessibilityViewIsModal
// Android // Android
importantForAccessibility="yes" importantForAccessibility="yes"
style={[a.absolute, a.inset_0]}> style={[a.absolute, a.inset_0]}
testID={testID}>
<BottomSheet <BottomSheet
enableDynamicSizing={!hasSnapPoints} enableDynamicSizing={!hasSnapPoints}
enablePanDownToClose enablePanDownToClose

View File

@ -46,6 +46,7 @@ export type DialogOuterProps = {
sheet?: Omit<BottomSheetProps, 'children'> sheet?: Omit<BottomSheetProps, 'children'>
} }
webOptions?: {} webOptions?: {}
testID?: string
} }
type DialogInnerPropsBase<T> = React.PropsWithChildren<ViewStyleProp> & T type DialogInnerPropsBase<T> = React.PropsWithChildren<ViewStyleProp> & T

View File

@ -1,17 +1,13 @@
import React from 'react' import React from 'react'
import {GestureResponderEvent} from 'react-native' import {GestureResponderEvent} from 'react-native'
import { import {useLinkProps, StackActions} from '@react-navigation/native'
useLinkProps,
useNavigation,
StackActions,
} from '@react-navigation/native'
import {sanitizeUrl} from '@braintree/sanitize-url' import {sanitizeUrl} from '@braintree/sanitize-url'
import {useInteractionState} from '#/components/hooks/useInteractionState' import {useInteractionState} from '#/components/hooks/useInteractionState'
import {isWeb} from '#/platform/detection' import {isWeb} from '#/platform/detection'
import {useTheme, web, flatten, TextStyleProp, atoms as a} from '#/alf' import {useTheme, web, flatten, TextStyleProp, atoms as a} from '#/alf'
import {Button, ButtonProps} from '#/components/Button' import {Button, ButtonProps} from '#/components/Button'
import {AllNavigatorParams, NavigationProp} from '#/lib/routes/types' import {AllNavigatorParams} from '#/lib/routes/types'
import { import {
convertBskyAppUrlIfNeeded, convertBskyAppUrlIfNeeded,
isExternalUrl, isExternalUrl,
@ -21,6 +17,7 @@ import {useModalControls} from '#/state/modals'
import {router} from '#/routes' import {router} from '#/routes'
import {Text, TextProps} from '#/components/Typography' import {Text, TextProps} from '#/components/Typography'
import {useOpenLink} from 'state/preferences/in-app-browser' import {useOpenLink} from 'state/preferences/in-app-browser'
import {useNavigationDeduped} from 'lib/hooks/useNavigationDeduped'
/** /**
* Only available within a `Link`, since that inherits from `Button`. * Only available within a `Link`, since that inherits from `Button`.
@ -74,7 +71,7 @@ export function useLink({
}: BaseLinkProps & { }: BaseLinkProps & {
displayText: string displayText: string
}) { }) {
const navigation = useNavigation<NavigationProp>() const navigation = useNavigationDeduped()
const {href} = useLinkProps<AllNavigatorParams>({ const {href} = useLinkProps<AllNavigatorParams>({
to: to:
typeof to === 'string' ? convertBskyAppUrlIfNeeded(sanitizeUrl(to)) : to, typeof to === 'string' ? convertBskyAppUrlIfNeeded(sanitizeUrl(to)) : to,
@ -231,6 +228,7 @@ export function InlineLink({
onPress: outerOnPress, onPress: outerOnPress,
download, download,
selectable, selectable,
label,
...rest ...rest
}: InlineLinkProps) { }: InlineLinkProps) {
const t = useTheme() const t = useTheme()
@ -258,7 +256,8 @@ export function InlineLink({
return ( return (
<Text <Text
selectable={selectable} selectable={selectable}
label={href} accessibilityHint=""
accessibilityLabel={label || href}
{...rest} {...rest}
style={[ style={[
{color: t.palette.primary_500}, {color: t.palette.primary_500},

View File

@ -9,9 +9,8 @@ import {cleanError} from 'lib/strings/errors'
import {Button} from '#/components/Button' import {Button} from '#/components/Button'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {StackActions} from '@react-navigation/native' import {StackActions} from '@react-navigation/native'
import {useNavigation} from '@react-navigation/core'
import {NavigationProp} from 'lib/routes/types'
import {router} from '#/routes' import {router} from '#/routes'
import {useNavigationDeduped} from 'lib/hooks/useNavigationDeduped'
export function ListFooter({ export function ListFooter({
isFetching, isFetching,
@ -144,7 +143,7 @@ export function ListMaybePlaceholder({
notFoundType?: 'page' | 'results' notFoundType?: 'page' | 'results'
onRetry?: () => Promise<unknown> onRetry?: () => Promise<unknown>
}) { }) {
const navigation = useNavigation<NavigationProp>() const navigation = useNavigationDeduped()
const t = useTheme() const t = useTheme()
const {gtMobile, gtTablet} = useBreakpoints() const {gtMobile, gtTablet} = useBreakpoints()
const {_} = useLingui() const {_} = useLingui()

View File

@ -1,5 +1,5 @@
import React from 'react' 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 flattenReactChildren from 'react-keyed-flatten-children'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
@ -16,6 +16,10 @@ import {
ItemTextProps, ItemTextProps,
ItemIconProps, ItemIconProps,
} from '#/components/Menu/types' } 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' export {useDialogControl as useMenuControl} from '#/components/Dialog'
@ -68,7 +72,13 @@ export function Trigger({children, label}: TriggerProps) {
}) })
} }
export function Outer({children}: React.PropsWithChildren<{}>) { export function Outer({
children,
showCancel,
}: React.PropsWithChildren<{
showCancel?: boolean
style?: StyleProp<ViewStyle>
}>) {
const context = React.useContext(Context) const context = React.useContext(Context)
return ( return (
@ -78,7 +88,10 @@ export function Outer({children}: React.PropsWithChildren<{}>) {
{/* Re-wrap with context since Dialogs are portal-ed to root */} {/* Re-wrap with context since Dialogs are portal-ed to root */}
<Context.Provider value={context}> <Context.Provider value={context}>
<Dialog.ScrollableInner label="Menu TODO"> <Dialog.ScrollableInner label="Menu TODO">
<View style={[a.gap_lg]}>{children}</View> <View style={[a.gap_lg]}>
{children}
{isNative && showCancel && <Cancel />}
</View>
<View style={{height: a.gap_lg.gap}} /> <View style={{height: a.gap_lg.gap}} />
</Dialog.ScrollableInner> </Dialog.ScrollableInner>
</Context.Provider> </Context.Provider>
@ -185,6 +198,22 @@ export function Group({children, style}: GroupProps) {
) )
} }
function Cancel() {
const {_} = useLingui()
const {control} = React.useContext(Context)
return (
<Button
label={_(msg`Close this dialog`)}
size="small"
variant="ghost"
color="secondary"
onPress={() => control.close()}>
<ButtonText>Cancel</ButtonText>
</Button>
)
}
export function Divider() { export function Divider() {
return null return null
} }

View File

@ -1,6 +1,10 @@
/* eslint-disable react/prop-types */
import React from 'react' 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 DropdownMenu from '@radix-ui/react-dropdown-menu'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import {useInteractionState} from '#/components/hooks/useInteractionState' import {useInteractionState} from '#/components/hooks/useInteractionState'
@ -14,8 +18,10 @@ import {
GroupProps, GroupProps,
ItemTextProps, ItemTextProps,
ItemIconProps, ItemIconProps,
RadixPassThroughTriggerProps,
} from '#/components/Menu/types' } from '#/components/Menu/types'
import {Context} from '#/components/Menu/context' import {Context} from '#/components/Menu/context'
import {Portal} from '#/components/Portal'
export function useMenuControl(): Dialog.DialogControlProps { export function useMenuControl(): Dialog.DialogControlProps {
const id = React.useId() const id = React.useId()
@ -47,6 +53,7 @@ export function Root({
}: React.PropsWithChildren<{ }: React.PropsWithChildren<{
control?: Dialog.DialogOuterProps['control'] control?: Dialog.DialogOuterProps['control']
}>) { }>) {
const {_} = useLingui()
const defaultControl = useMenuControl() const defaultControl = useMenuControl()
const context = React.useMemo<ContextType>( const context = React.useMemo<ContextType>(
() => ({ () => ({
@ -67,6 +74,18 @@ export function Root({
return ( return (
<Context.Provider value={context}> <Context.Provider value={context}>
{context.control.isOpen && (
<Portal>
<Pressable
style={[a.fixed, a.inset_0, a.z_50]}
onPress={() => context.control.close()}
accessibilityHint=""
accessibilityLabel={_(
msg`Context menu backdrop, click to close the menu.`,
)}
/>
</Portal>
)}
<DropdownMenu.Root <DropdownMenu.Root
open={context.control.isOpen} open={context.control.isOpen}
onOpenChange={onOpenChange}> onOpenChange={onOpenChange}>
@ -76,7 +95,24 @@ export function Root({
) )
} }
export function Trigger({children, label, style}: TriggerProps) { const RadixTriggerPassThrough = React.forwardRef(
(
props: {
children: (
props: RadixPassThroughTriggerProps & {
ref: React.Ref<any>
},
) => React.ReactNode
},
ref,
) => {
// @ts-expect-error Radix provides no types of this stuff
return props.children({...props, ref})
},
)
RadixTriggerPassThrough.displayName = 'RadixTriggerPassThrough'
export function Trigger({children, label}: TriggerProps) {
const {control} = React.useContext(Context) const {control} = React.useContext(Context)
const { const {
state: hovered, state: hovered,
@ -87,33 +123,42 @@ export function Trigger({children, label, style}: TriggerProps) {
return ( return (
<DropdownMenu.Trigger asChild> <DropdownMenu.Trigger asChild>
<Pressable <RadixTriggerPassThrough>
accessibilityHint="" {props =>
accessibilityLabel={label} children({
onFocus={onFocus} isNative: false,
onBlur={onBlur} control,
style={flatten([style, focused && web({outline: 0})])} state: {
onPointerDown={() => control.open()} hovered,
{...web({ focused,
onMouseEnter, pressed: false,
onMouseLeave, },
})}> props: {
{children({ ...props,
isNative: false, // disable on web, use `onPress`
control, onPointerDown: () => false,
state: { onPress: () =>
hovered, control.isOpen ? control.close() : control.open(),
focused, onFocus: onFocus,
pressed: false, onBlur: onBlur,
}, onMouseEnter,
props: {}, onMouseLeave,
})} accessibilityLabel: label,
</Pressable> },
})
}
</RadixTriggerPassThrough>
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
) )
} }
export function Outer({children}: React.PropsWithChildren<{}>) { export function Outer({
children,
style,
}: React.PropsWithChildren<{
showCancel?: boolean
style?: StyleProp<ViewStyle>
}>) {
const t = useTheme() const t = useTheme()
return ( return (
@ -125,6 +170,7 @@ export function Outer({children}: React.PropsWithChildren<{}>) {
a.p_xs, a.p_xs,
t.name === 'light' ? t.atoms.bg : t.atoms.bg_contrast_25, t.name === 'light' ? t.atoms.bg : t.atoms.bg_contrast_25,
t.atoms.shadow_md, t.atoms.shadow_md,
style,
]}> ]}>
{children} {children}
</View> </View>

View File

@ -1,5 +1,9 @@
import React from 'react' 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 {Props as SVGIconProps} from '#/components/icons/common'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
@ -9,7 +13,23 @@ export type ContextType = {
control: Dialog.DialogOuterProps['control'] 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 children(props: TriggerChildProps): React.ReactNode
label: string label: string
} }
@ -52,7 +72,14 @@ export type TriggerChildProps =
*/ */
pressed: false pressed: false
} }
props: {} props: RadixPassThroughTriggerProps & {
onPress: () => void
onFocus: () => void
onBlur: () => void
onMouseEnter: () => void
onMouseLeave: () => void
accessibilityLabel: string
}
} }
export type ItemProps = React.PropsWithChildren< export type ItemProps = React.PropsWithChildren<

View File

@ -1,11 +1,12 @@
import React from 'react' import React from 'react'
import {View, PressableProps} from 'react-native' import {View} from 'react-native'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {isNative} from '#/platform/detection'
import {useTheme, atoms as a, useBreakpoints} from '#/alf' import {useTheme, atoms as a, useBreakpoints} from '#/alf'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {Button} from '#/components/Button' import {Button, ButtonColor, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
@ -22,8 +23,10 @@ const Context = React.createContext<{
export function Outer({ export function Outer({
children, children,
control, control,
testID,
}: React.PropsWithChildren<{ }: React.PropsWithChildren<{
control: Dialog.DialogOuterProps['control'] control: Dialog.DialogOuterProps['control']
testID?: string
}>) { }>) {
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const titleId = React.useId() const titleId = React.useId()
@ -35,7 +38,7 @@ export function Outer({
) )
return ( return (
<Dialog.Outer control={control}> <Dialog.Outer control={control} testID={testID}>
<Context.Provider value={context}> <Context.Provider value={context}>
<Dialog.Handle /> <Dialog.Handle />
@ -80,7 +83,10 @@ export function Actions({children}: React.PropsWithChildren<{}>) {
a.w_full, a.w_full,
a.gap_sm, a.gap_sm,
a.justify_end, 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} {children}
</View> </View>
@ -89,18 +95,29 @@ export function Actions({children}: React.PropsWithChildren<{}>) {
export function Cancel({ export function Cancel({
children, 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 {_} = useLingui()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const {close} = Dialog.useDialogContext() const {close} = Dialog.useDialogContext()
const onPress = React.useCallback(() => {
close()
}, [close])
return ( return (
<Button <Button
variant="solid" variant="solid"
color="secondary" color="secondary"
size={gtMobile ? 'small' : 'medium'} size={gtMobile ? 'small' : 'medium'}
label={_(msg`Cancel`)} label={cta || _(msg`Cancel`)}
onPress={() => close()}> onPress={onPress}>
{children} {children ? children : <ButtonText>{cta || _(msg`Cancel`)}</ButtonText>}
</Button> </Button>
) )
} }
@ -108,22 +125,70 @@ export function Cancel({
export function Action({ export function Action({
children, children,
onPress, 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 {_} = useLingui()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const {close} = Dialog.useDialogContext() const {close} = Dialog.useDialogContext()
const handleOnPress = React.useCallback(() => { const handleOnPress = React.useCallback(() => {
close() close()
onPress?.() onPress()
}, [close, onPress]) }, [close, onPress])
return ( return (
<Button <Button
variant="solid" variant="solid"
color="primary" color={color}
size={gtMobile ? 'small' : 'medium'} size={gtMobile ? 'small' : 'medium'}
label={_(msg`Confirm`)} label={cta || _(msg`Confirm`)}
onPress={handleOnPress}> onPress={handleOnPress}
{children} testID={testID}>
{children ? children : <ButtonText>{cta || _(msg`Confirm`)}</ButtonText>}
</Button> </Button>
) )
} }
export function Basic({
control,
title,
description,
cancelButtonCta,
confirmButtonCta,
onConfirm,
confirmButtonColor,
}: React.PropsWithChildren<{
control: Dialog.DialogOuterProps['control']
title: string
description: string
cancelButtonCta?: string
confirmButtonCta?: string
onConfirm: () => void
confirmButtonColor?: ButtonColor
}>) {
return (
<Outer control={control} testID="confirmModal">
<Title>{title}</Title>
<Description>{description}</Description>
<Actions>
<Action
cta={confirmButtonCta}
onPress={onConfirm}
color={confirmButtonColor}
testID="confirmBtn"
/>
<Cancel cta={cancelButtonCta} />
</Actions>
</Outer>
)
}

View File

@ -254,9 +254,9 @@ function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) {
</View> </View>
{isNative && <View style={{height: 20}} />} {isNative && <View style={{height: 20}} />}
<Dialog.Close />
</View> </View>
<Dialog.Close />
</Dialog.ScrollableInner> </Dialog.ScrollableInner>
) )
} }
@ -277,29 +277,16 @@ function MutedWordRow({
return ( return (
<> <>
<Prompt.Outer control={control}> <Prompt.Basic
<Prompt.Title> control={control}
<Trans>Are you sure?</Trans> title={_(msg`Are you sure?`)}
</Prompt.Title> description={_(
<Prompt.Description> msg`This will delete ${word.value} from your muted words. You can always add it back later.`,
<Trans> )}
This will delete {word.value} from your muted words. You can always onConfirm={remove}
add it back later. confirmButtonCta={_(msg`Remove`)}
</Trans> confirmButtonColor="negative"
</Prompt.Description> />
<Prompt.Actions>
<Prompt.Cancel>
<ButtonText>
<Trans>Nevermind</Trans>
</ButtonText>
</Prompt.Cancel>
<Prompt.Action onPress={remove}>
<ButtonText>
<Trans>Remove</Trans>
</ButtonText>
</Prompt.Action>
</Prompt.Actions>
</Prompt.Outer>
<View <View
style={[ style={[

View File

@ -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',
})

View File

@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const DotGrid_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M2 12a2 2 0 1 1 4 0 2 2 0 0 1-4 0Zm16 0a2 2 0 1 1 4 0 2 2 0 0 1-4 0Zm-6-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z',
})

View File

@ -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',
})

View File

@ -0,0 +1,9 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Heart2_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M16.734 5.091c-1.238-.276-2.708.047-4.022 1.38a1 1 0 0 1-1.424 0C9.974 5.137 8.504 4.814 7.266 5.09c-1.263.282-2.379 1.206-2.92 2.556C3.33 10.18 4.252 14.84 12 19.348c7.747-4.508 8.67-9.168 7.654-11.7-.541-1.351-1.657-2.275-2.92-2.557Zm4.777 1.812c1.604 4-.494 9.69-9.022 14.47a1 1 0 0 1-.978 0C2.983 16.592.885 10.902 2.49 6.902c.779-1.942 2.414-3.334 4.342-3.764 1.697-.378 3.552.003 5.169 1.286 1.617-1.283 3.472-1.664 5.17-1.286 1.927.43 3.562 1.822 4.34 3.764Z',
})
export const Heart2_Filled_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M12.489 21.372c8.528-4.78 10.626-10.47 9.022-14.47-.779-1.941-2.414-3.333-4.342-3.763-1.697-.378-3.552.003-5.169 1.287-1.617-1.284-3.472-1.665-5.17-1.287-1.927.43-3.562 1.822-4.34 3.764-1.605 4 .493 9.69 9.021 14.47a1 1 0 0 0 .978 0Z',
})

View File

@ -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',
})

View File

@ -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',
})

View File

@ -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',
})

View File

@ -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',
})

View File

@ -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
}

View File

@ -0,0 +1,80 @@
import React from 'react'
import {useNavigation} from '@react-navigation/core'
import {AllNavigatorParams, NavigationProp} from 'lib/routes/types'
import type {NavigationAction} from '@react-navigation/routers'
import {NavigationState} from '@react-navigation/native'
import {useDedupe} from 'lib/hooks/useDedupe'
export type DebouncedNavigationProp = Pick<
NavigationProp,
| 'popToTop'
| 'push'
| 'navigate'
| 'canGoBack'
| 'replace'
| 'dispatch'
| 'goBack'
| 'getState'
>
export function useNavigationDeduped() {
const navigation = useNavigation<NavigationProp>()
const dedupe = useDedupe()
return React.useMemo(
(): DebouncedNavigationProp => ({
// Types from @react-navigation/routers/lib/typescript/src/StackRouter.ts
push: <RouteName extends keyof AllNavigatorParams>(
...args: undefined extends AllNavigatorParams[RouteName]
?
| [screen: RouteName]
| [screen: RouteName, params: AllNavigatorParams[RouteName]]
: [screen: RouteName, params: AllNavigatorParams[RouteName]]
) => {
dedupe(() => navigation.push(...args))
},
// Types from @react-navigation/core/src/types.tsx
navigate: <RouteName extends keyof AllNavigatorParams>(
...args: RouteName extends unknown
? undefined extends AllNavigatorParams[RouteName]
?
| [screen: RouteName]
| [screen: RouteName, params: AllNavigatorParams[RouteName]]
: [screen: RouteName, params: AllNavigatorParams[RouteName]]
: never
) => {
dedupe(() => navigation.navigate(...args))
},
// Types from @react-navigation/routers/lib/typescript/src/StackRouter.ts
replace: <RouteName extends keyof AllNavigatorParams>(
...args: undefined extends AllNavigatorParams[RouteName]
?
| [screen: RouteName]
| [screen: RouteName, params: AllNavigatorParams[RouteName]]
: [screen: RouteName, params: AllNavigatorParams[RouteName]]
) => {
dedupe(() => navigation.replace(...args))
},
dispatch: (
action:
| NavigationAction
| ((state: NavigationState) => NavigationAction),
) => {
dedupe(() => navigation.dispatch(action))
},
popToTop: () => {
dedupe(() => navigation.popToTop())
},
goBack: () => {
dedupe(() => navigation.goBack())
},
canGoBack: () => {
return navigation.canGoBack()
},
getState: () => {
return navigation.getState()
},
}),
[dedupe, navigation],
)
}

View File

@ -2,25 +2,9 @@ import * as Updates from 'expo-updates'
import {useCallback, useEffect} from 'react' import {useCallback, useEffect} from 'react'
import {AppState} from 'react-native' import {AppState} from 'react-native'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useModalControls} from '#/state/modals'
import {t} from '@lingui/macro'
export function useOTAUpdate() { export function useOTAUpdate() {
const {openModal} = useModalControls()
// HELPER FUNCTIONS // 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 () => { const checkForUpdate = useCallback(async () => {
logger.debug('useOTAUpdate: Checking for update...') logger.debug('useOTAUpdate: Checking for update...')
try { 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. // 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() await Updates.fetchUpdateAsync()
// show a popup modal
showUpdatePopup()
} catch (e) { } catch (e) {
logger.error('useOTAUpdate: Error while checking for update', { logger.error('useOTAUpdate: Error while checking for update', {
message: e, message: e,
}) })
} }
}, [showUpdatePopup]) }, [])
const updateEventListener = useCallback( const updateEventListener = useCallback((event: Updates.UpdateEvent) => {
(event: Updates.UpdateEvent) => { logger.debug('useOTAUpdate: Listening for update...')
logger.debug('useOTAUpdate: Listening for update...') if (event.type === Updates.UpdateEventType.ERROR) {
if (event.type === Updates.UpdateEventType.ERROR) { logger.error('useOTAUpdate: Error while listening for update', {
logger.error('useOTAUpdate: Error while listening for update', { message: event.message,
message: event.message, })
}) } else if (event.type === Updates.UpdateEventType.NO_UPDATE_AVAILABLE) {
} else if (event.type === Updates.UpdateEventType.NO_UPDATE_AVAILABLE) { // Handle no update available
// Handle no update available // do nothing
// do nothing } else if (event.type === Updates.UpdateEventType.UPDATE_AVAILABLE) {
} else if (event.type === Updates.UpdateEventType.UPDATE_AVAILABLE) { // Handle update available
// Handle update available // open modal, ask for user confirmation, and reload the app
// open modal, ask for user confirmation, and reload the app }
showUpdatePopup() }, [])
}
},
[showUpdatePopup],
)
useEffect(() => { useEffect(() => {
// ADD EVENT LISTENERS // ADD EVENT LISTENERS

View File

@ -1,75 +0,0 @@
import React from 'react'
import {
Statsig,
StatsigProvider,
useGate as useStatsigGate,
} from 'statsig-react'
import {useSession} from '../../state/session'
import {sha256} from 'js-sha256'
const statsigOptions = {
environment: {
tier: process.env.NODE_ENV === 'development' ? 'development' : 'production',
},
// Don't block on waiting for network. The fetched config will kick in on next load.
// This ensures the UI is always consistent and doesn't update mid-session.
// Note this makes cold load (no local storage) and private mode return `false` for all gates.
initTimeoutMs: 1,
}
export function logEvent(
eventName: string,
value?: string | number | null,
metadata?: Record<string, string> | null,
) {
Statsig.logEvent(eventName, value, metadata)
}
export function useGate(gateName: string) {
const {isLoading, value} = useStatsigGate(gateName)
if (isLoading) {
// This should not happen because of waitForInitialization={true}.
console.error('Did not expected isLoading to ever be true.')
}
return value
}
function toStatsigUser(did: string | undefined) {
let userID: string | undefined
if (did) {
userID = sha256(did)
}
return {userID}
}
export function Provider({children}: {children: React.ReactNode}) {
const {currentAccount} = useSession()
const currentStatsigUser = React.useMemo(
() => toStatsigUser(currentAccount?.did),
[currentAccount?.did],
)
React.useEffect(() => {
function refresh() {
// Intentionally refetching the config using the JS SDK rather than React SDK
// so that the new config is stored in cache but isn't used during this session.
// It will kick in for the next reload.
Statsig.updateUser(currentStatsigUser)
}
const id = setInterval(refresh, 3 * 60e3 /* 3 min */)
return () => clearInterval(id)
}, [currentStatsigUser])
return (
<StatsigProvider
sdkKey="client-SXJakO39w9vIhl3D44u8UupyzFl4oZ2qPIkjwcvuPsV"
mountKey={currentStatsigUser.userID}
user={currentStatsigUser}
// This isn't really blocking due to short initTimeoutMs above.
// However, it ensures `isLoading` is always `false`.
waitForInitialization={true}
options={statsigOptions}>
{children}
</StatsigProvider>
)
}

View File

@ -1,6 +1,5 @@
import React from 'react' import React from 'react'
import {AppBskyActorDefs, AppBskyGraphDefs, ModerationUI} from '@atproto/api' import {AppBskyActorDefs, AppBskyGraphDefs, ModerationUI} from '@atproto/api'
import {StyleProp, ViewStyle} from 'react-native'
import {Image as RNImage} from 'react-native-image-crop-picker' import {Image as RNImage} from 'react-native-image-crop-picker'
import {ImageModel} from '#/state/models/media/image' 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 {EmbedPlayerSource} from '#/lib/strings/embed-player'
import {ThreadgateSetting} from '../queries/threadgate' import {ThreadgateSetting} from '../queries/threadgate'
export interface ConfirmModal {
name: 'confirm'
title: string
message: string | (() => JSX.Element)
onPressConfirm: () => void | Promise<void>
onPressCancel?: () => void | Promise<void>
confirmBtnText?: string
confirmBtnStyle?: StyleProp<ViewStyle>
cancelBtnText?: string
}
export interface EditProfileModal { export interface EditProfileModal {
name: 'edit-profile' name: 'edit-profile'
profile: AppBskyActorDefs.ProfileViewDetailed profile: AppBskyActorDefs.ProfileViewDetailed
@ -225,7 +213,6 @@ export type Modal =
| InviteCodesModal | InviteCodesModal
// Generic // Generic
| ConfirmModal
| LinkWarningModal | LinkWarningModal
| EmbedConsentModal | EmbedConsentModal
| InAppBrowserConsentModal | InAppBrowserConsentModal

View File

@ -49,7 +49,7 @@ import {SuggestedLanguage} from './select-language/SuggestedLanguage'
import {insertMentionAt} from 'lib/strings/mention-manip' import {insertMentionAt} from 'lib/strings/mention-manip'
import {Trans, msg} from '@lingui/macro' import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useModals, useModalControls} from '#/state/modals' import {useModals} from '#/state/modals'
import {useRequireAltTextEnabled} from '#/state/preferences' import {useRequireAltTextEnabled} from '#/state/preferences'
import { import {
useLanguagePrefs, useLanguagePrefs,
@ -63,6 +63,8 @@ import {emitPostCreated} from '#/state/events'
import {ThreadgateSetting} from '#/state/queries/threadgate' import {ThreadgateSetting} from '#/state/queries/threadgate'
import {logger} from '#/logger' import {logger} from '#/logger'
import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo' import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo'
import * as Prompt from '#/components/Prompt'
import {useDialogStateControlContext} from 'state/dialogs'
type Props = ComposerOpts type Props = ComposerOpts
export const ComposePost = observer(function ComposePost({ export const ComposePost = observer(function ComposePost({
@ -76,8 +78,7 @@ export const ComposePost = observer(function ComposePost({
}: Props) { }: Props) {
const {currentAccount} = useSession() const {currentAccount} = useSession()
const {data: currentProfile} = useProfileQuery({did: currentAccount!.did}) const {data: currentProfile} = useProfileQuery({did: currentAccount!.did})
const {isModalActive, activeModals} = useModals() const {isModalActive} = useModals()
const {openModal, closeModal} = useModalControls()
const {closeComposer} = useComposerControls() const {closeComposer} = useComposerControls()
const {track} = useAnalytics() const {track} = useAnalytics()
const pal = usePalette('default') const pal = usePalette('default')
@ -87,6 +88,9 @@ export const ComposePost = observer(function ComposePost({
const langPrefs = useLanguagePrefs() const langPrefs = useLanguagePrefs()
const setLangPrefs = useLanguagePrefsApi() const setLangPrefs = useLanguagePrefsApi()
const textInput = useRef<TextInputRef>(null) const textInput = useRef<TextInputRef>(null)
const discardPromptControl = Prompt.usePromptControl()
const {closeAllDialogs} = useDialogStateControlContext()
const [isKeyboardVisible] = useIsKeyboardVisible({iosUseWillEvents: true}) const [isKeyboardVisible] = useIsKeyboardVisible({iosUseWillEvents: true})
const [isProcessing, setIsProcessing] = useState(false) const [isProcessing, setIsProcessing] = useState(false)
const [processingState, setProcessingState] = useState('') const [processingState, setProcessingState] = useState('')
@ -134,27 +138,21 @@ export const ComposePost = observer(function ComposePost({
const onPressCancel = useCallback(() => { const onPressCancel = useCallback(() => {
if (graphemeLength > 0 || !gallery.isEmpty) { if (graphemeLength > 0 || !gallery.isEmpty) {
if (activeModals.some(modal => modal.name === 'confirm')) { closeAllDialogs()
closeModal()
}
if (Keyboard) { if (Keyboard) {
Keyboard.dismiss() Keyboard.dismiss()
} }
openModal({ discardPromptControl.open()
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},
})
} else { } else {
onClose() onClose()
} }
}, [openModal, closeModal, activeModals, onClose, graphemeLength, gallery, _]) }, [
graphemeLength,
gallery.isEmpty,
closeAllDialogs,
discardPromptControl,
onClose,
])
// android back button // android back button
useEffect(() => { useEffect(() => {
if (!isAndroid) { if (!isAndroid) {
@ -488,6 +486,15 @@ export const ComposePost = observer(function ComposePost({
<CharProgress count={graphemeLength} /> <CharProgress count={graphemeLength} />
</View> </View>
</View> </View>
<Prompt.Basic
control={discardPromptControl}
title={_(msg`Discard draft?`)}
description={_(msg`Are you sure you'd like to discard this draft?`)}
onConfirm={onClose}
confirmButtonCta={_(msg`Discard`)}
confirmButtonColor="negative"
/>
</KeyboardAvoidingView> </KeyboardAvoidingView>
) )
}) })

View File

@ -1,5 +1,6 @@
import React, {useCallback} from 'react' import React, {useCallback} from 'react'
import {TouchableOpacity, StyleSheet} from 'react-native' import {TouchableOpacity, StyleSheet} from 'react-native'
import * as MediaLibrary from 'expo-media-library'
import { import {
FontAwesomeIcon, FontAwesomeIcon,
FontAwesomeIconStyle, FontAwesomeIconStyle,
@ -24,6 +25,8 @@ export function OpenCameraBtn({gallery}: Props) {
const {track} = useAnalytics() const {track} = useAnalytics()
const {_} = useLingui() const {_} = useLingui()
const {requestCameraAccessIfNeeded} = useCameraPermission() const {requestCameraAccessIfNeeded} = useCameraPermission()
const [mediaPermissionRes, requestMediaPermission] =
MediaLibrary.usePermissions()
const onPressTakePicture = useCallback(async () => { const onPressTakePicture = useCallback(async () => {
track('Composer:CameraOpened') track('Composer:CameraOpened')
@ -31,6 +34,9 @@ export function OpenCameraBtn({gallery}: Props) {
if (!(await requestCameraAccessIfNeeded())) { if (!(await requestCameraAccessIfNeeded())) {
return return
} }
if (!mediaPermissionRes?.granted && mediaPermissionRes?.canAskAgain) {
await requestMediaPermission()
}
const img = await openCamera({ const img = await openCamera({
width: POST_IMG_MAX.width, width: POST_IMG_MAX.width,
@ -38,12 +44,23 @@ export function OpenCameraBtn({gallery}: Props) {
freeStyleCropEnabled: true, 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) gallery.add(img)
} catch (err: any) { } catch (err: any) {
// ignore // ignore
logger.warn('Error using camera', {error: err}) logger.warn('Error using camera', {error: err})
} }
}, [gallery, track, requestCameraAccessIfNeeded]) }, [
gallery,
track,
requestCameraAccessIfNeeded,
mediaPermissionRes,
requestMediaPermission,
])
const shouldShowCameraButton = isNative || isMobileWeb const shouldShowCameraButton = isNative || isMobileWeb
if (!shouldShowCameraButton) { if (!shouldShowCameraButton) {

View File

@ -6,14 +6,11 @@ import {RichText} from '#/components/RichText'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {s} from 'lib/styles' import {s} from 'lib/styles'
import {UserAvatar} from '../util/UserAvatar' import {UserAvatar} from '../util/UserAvatar'
import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from 'lib/routes/types'
import {pluralize} from 'lib/strings/helpers' import {pluralize} from 'lib/strings/helpers'
import {AtUri} from '@atproto/api' import {AtUri} from '@atproto/api'
import * as Toast from 'view/com/util/Toast' import * as Toast from 'view/com/util/Toast'
import {sanitizeHandle} from 'lib/strings/handles' import {sanitizeHandle} from 'lib/strings/handles'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useModalControls} from '#/state/modals'
import {Trans, msg} from '@lingui/macro' import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import { import {
@ -26,6 +23,8 @@ import {
import {useFeedSourceInfoQuery, FeedSourceInfo} from '#/state/queries/feed' import {useFeedSourceInfoQuery, FeedSourceInfo} from '#/state/queries/feed'
import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {useTheme} from '#/alf' import {useTheme} from '#/alf'
import * as Prompt from '#/components/Prompt'
import {useNavigationDeduped} from 'lib/hooks/useNavigationDeduped'
export function FeedSourceCard({ export function FeedSourceCard({
feedUri, feedUri,
@ -86,8 +85,8 @@ export function FeedSourceCardLoaded({
const t = useTheme() const t = useTheme()
const pal = usePalette('default') const pal = usePalette('default')
const {_} = useLingui() const {_} = useLingui()
const navigation = useNavigation<NavigationProp>() const removePromptControl = Prompt.usePromptControl()
const {openModal} = useModalControls() const navigation = useNavigationDeduped()
const {isPending: isSavePending, mutateAsync: saveFeed} = const {isPending: isSavePending, mutateAsync: saveFeed} =
useSaveFeedMutation() useSaveFeedMutation()
@ -97,40 +96,45 @@ export function FeedSourceCardLoaded({
const isSaved = Boolean(preferences?.feeds?.saved?.includes(feed?.uri || '')) 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 () => { const onToggleSaved = React.useCallback(async () => {
// Only feeds can be un/saved, lists are handled elsewhere // Only feeds can be un/saved, lists are handled elsewhere
if (feed?.type !== 'feed') return if (feed?.type !== 'feed') return
if (isSaved) { if (isSaved) {
openModal({ removePromptControl.open()
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})
}
},
})
} else { } else {
try { await onSave()
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})
}
} }
}, [isSaved, openModal, feed, removeFeed, saveFeed, _, pinOnSave, pinFeed]) }, [feed?.type, isSaved, removePromptControl, onSave])
/* /*
* LOAD STATE * LOAD STATE
@ -168,25 +172,7 @@ export function FeedSourceCardLoaded({
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={_(msg`Remove from my feeds`)} accessibilityLabel={_(msg`Remove from my feeds`)}
accessibilityHint="" accessibilityHint=""
onPress={() => { onPress={onToggleSaved}
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})
}
},
})
}}
hitSlop={15} hitSlop={15}
style={styles.btn}> style={styles.btn}>
<FontAwesomeIcon <FontAwesomeIcon
@ -200,89 +186,104 @@ export function FeedSourceCardLoaded({
) )
return ( return (
<Pressable <>
testID={`feed-${feed.displayName}`} <Pressable
accessibilityRole="button" testID={`feed-${feed.displayName}`}
style={[styles.container, pal.border, style]} accessibilityRole="button"
onPress={() => { style={[styles.container, pal.border, style]}
if (feed.type === 'feed') { onPress={() => {
navigation.push('ProfileFeed', { if (feed.type === 'feed') {
name: feed.creatorDid, navigation.push('ProfileFeed', {
rkey: new AtUri(feed.uri).rkey, name: feed.creatorDid,
}) rkey: new AtUri(feed.uri).rkey,
} else if (feed.type === 'list') { })
navigation.push('ProfileList', { } else if (feed.type === 'list') {
name: feed.creatorDid, navigation.push('ProfileList', {
rkey: new AtUri(feed.uri).rkey, name: feed.creatorDid,
}) rkey: new AtUri(feed.uri).rkey,
} })
}} }
key={feed.uri}> }}
<View style={[styles.headerContainer]}> key={feed.uri}>
<View style={[s.mr10]}> <View style={[styles.headerContainer]}>
<UserAvatar type="algo" size={36} avatar={feed.avatar} /> <View style={[s.mr10]}>
</View> <UserAvatar type="algo" size={36} avatar={feed.avatar} />
<View style={[styles.headerTextContainer]}>
<Text style={[pal.text, s.bold]} numberOfLines={3}>
{feed.displayName}
</Text>
<Text style={[pal.textLight]} numberOfLines={3}>
{feed.type === 'feed' ? (
<Trans>Feed by {sanitizeHandle(feed.creatorHandle, '@')}</Trans>
) : (
<Trans>List by {sanitizeHandle(feed.creatorHandle, '@')}</Trans>
)}
</Text>
</View>
{showSaveBtn && feed.type === 'feed' && (
<View style={[s.justifyCenter]}>
<Pressable
testID={`feed-${feed.displayName}-toggleSave`}
disabled={isSavePending || isPinPending || isRemovePending}
accessibilityRole="button"
accessibilityLabel={
isSaved ? _(msg`Remove from my feeds`) : _(msg`Add to my feeds`)
}
accessibilityHint=""
onPress={onToggleSaved}
hitSlop={15}
style={styles.btn}>
{isSaved ? (
<FontAwesomeIcon
icon={['far', 'trash-can']}
size={19}
color={pal.colors.icon}
/>
) : (
<FontAwesomeIcon
icon="plus"
size={18}
color={pal.colors.link}
/>
)}
</Pressable>
</View> </View>
<View style={[styles.headerTextContainer]}>
<Text style={[pal.text, s.bold]} numberOfLines={3}>
{feed.displayName}
</Text>
<Text style={[pal.textLight]} numberOfLines={3}>
{feed.type === 'feed' ? (
<Trans>Feed by {sanitizeHandle(feed.creatorHandle, '@')}</Trans>
) : (
<Trans>List by {sanitizeHandle(feed.creatorHandle, '@')}</Trans>
)}
</Text>
</View>
{showSaveBtn && feed.type === 'feed' && (
<View style={[s.justifyCenter]}>
<Pressable
testID={`feed-${feed.displayName}-toggleSave`}
disabled={isSavePending || isPinPending || isRemovePending}
accessibilityRole="button"
accessibilityLabel={
isSaved
? _(msg`Remove from my feeds`)
: _(msg`Add to my feeds`)
}
accessibilityHint=""
onPress={onToggleSaved}
hitSlop={15}
style={styles.btn}>
{isSaved ? (
<FontAwesomeIcon
icon={['far', 'trash-can']}
size={19}
color={pal.colors.icon}
/>
) : (
<FontAwesomeIcon
icon="plus"
size={18}
color={pal.colors.link}
/>
)}
</Pressable>
</View>
)}
</View>
{showDescription && feed.description ? (
<RichText
style={[t.atoms.text_contrast_high, styles.description]}
value={feed.description}
numberOfLines={3}
/>
) : null}
{showLikes && feed.type === 'feed' ? (
<Text type="sm-medium" style={[pal.text, pal.textLight]}>
<Trans>
Liked by {feed.likeCount || 0}{' '}
{pluralize(feed.likeCount || 0, 'user')}
</Trans>
</Text>
) : null}
</Pressable>
<Prompt.Basic
control={removePromptControl}
title={_(msg`Remove from my feeds?`)}
description={_(
msg`Are you sure you want to remove ${feed.displayName} from your feeds?`,
)} )}
</View> onConfirm={onUnsave}
confirmButtonCta={_(msg`Remove`)}
{showDescription && feed.description ? ( confirmButtonColor="negative"
<RichText />
style={[t.atoms.text_contrast_high, styles.description]} </>
value={feed.description}
numberOfLines={3}
/>
) : null}
{showLikes && feed.type === 'feed' ? (
<Text type="sm-medium" style={[pal.text, pal.textLight]}>
<Trans>
Liked by {feed.likeCount || 0}{' '}
{pluralize(feed.likeCount || 0, 'user')}
</Trans>
</Text>
) : null}
</Pressable>
) )
} }

View File

@ -1,132 +0,0 @@
import React, {useState} from 'react'
import {
ActivityIndicator,
StyleSheet,
TouchableOpacity,
View,
} from 'react-native'
import {Text} from '../util/text/Text'
import {s, colors} from 'lib/styles'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {cleanError} from 'lib/strings/errors'
import {usePalette} from 'lib/hooks/usePalette'
import {isWeb} from 'platform/detection'
import {useLingui} from '@lingui/react'
import {Trans, msg} from '@lingui/macro'
import type {ConfirmModal} from '#/state/modals'
import {useModalControls} from '#/state/modals'
export const snapPoints = ['50%']
export function Component({
title,
message,
onPressConfirm,
onPressCancel,
confirmBtnText,
confirmBtnStyle,
cancelBtnText,
}: ConfirmModal) {
const pal = usePalette('default')
const {_} = useLingui()
const {closeModal} = useModalControls()
const [isProcessing, setIsProcessing] = useState<boolean>(false)
const [error, setError] = useState<string>('')
const onPress = async () => {
setError('')
setIsProcessing(true)
try {
await onPressConfirm()
closeModal()
return
} catch (e: any) {
setError(cleanError(e))
setIsProcessing(false)
}
}
return (
<View testID="confirmModal" style={[pal.view, styles.container]}>
<Text type="title-xl" style={[pal.text, styles.title]}>
{title}
</Text>
{typeof message === 'string' ? (
<Text type="xl" style={[pal.textLight, styles.description]}>
{message}
</Text>
) : (
message()
)}
{error ? (
<View style={s.mt10}>
<ErrorMessage message={error} />
</View>
) : undefined}
<View style={s.flex1} />
{isProcessing ? (
<View style={[styles.btn, s.mt10]}>
<ActivityIndicator />
</View>
) : (
<TouchableOpacity
testID="confirmBtn"
onPress={onPress}
style={[styles.btn, confirmBtnStyle]}
accessibilityRole="button"
accessibilityLabel={_(msg({message: 'Confirm', context: 'action'}))}
accessibilityHint="">
<Text style={[s.white, s.bold, s.f18]}>
{confirmBtnText ?? <Trans context="action">Confirm</Trans>}
</Text>
</TouchableOpacity>
)}
{onPressCancel === undefined ? null : (
<TouchableOpacity
testID="cancelBtn"
onPress={onPressCancel}
style={[styles.btnCancel, s.mt10]}
accessibilityRole="button"
accessibilityLabel={_(msg({message: 'Cancel', context: 'action'}))}
accessibilityHint="">
<Text type="button-lg" style={pal.textLight}>
{cancelBtnText ?? <Trans context="action">Cancel</Trans>}
</Text>
</TouchableOpacity>
)}
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 10,
paddingBottom: isWeb ? 0 : 60,
},
title: {
textAlign: 'center',
marginBottom: 12,
},
description: {
textAlign: 'center',
paddingHorizontal: 22,
marginBottom: 10,
},
btn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 32,
padding: 14,
marginTop: 22,
marginHorizontal: 44,
backgroundColor: colors.blue3,
},
btnCancel: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 32,
padding: 14,
marginHorizontal: 20,
},
})

View File

@ -6,7 +6,6 @@ import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useModals, useModalControls} from '#/state/modals' import {useModals, useModalControls} from '#/state/modals'
import * as ConfirmModal from './Confirm'
import * as EditProfileModal from './EditProfile' import * as EditProfileModal from './EditProfile'
import * as RepostModal from './Repost' import * as RepostModal from './Repost'
import * as SelfLabelModal from './SelfLabel' import * as SelfLabelModal from './SelfLabel'
@ -66,10 +65,7 @@ export function ModalsContainer() {
let snapPoints: (string | number)[] = DEFAULT_SNAPPOINTS let snapPoints: (string | number)[] = DEFAULT_SNAPPOINTS
let element let element
if (activeModal?.name === 'confirm') { if (activeModal?.name === 'edit-profile') {
snapPoints = ConfirmModal.snapPoints
element = <ConfirmModal.Component {...activeModal} />
} else if (activeModal?.name === 'edit-profile') {
snapPoints = EditProfileModal.snapPoints snapPoints = EditProfileModal.snapPoints
element = <EditProfileModal.Component {...activeModal} /> element = <EditProfileModal.Component {...activeModal} />
} else if (activeModal?.name === 'report') { } else if (activeModal?.name === 'report') {

View File

@ -7,7 +7,6 @@ import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock'
import {useModals, useModalControls} from '#/state/modals' import {useModals, useModalControls} from '#/state/modals'
import type {Modal as ModalIface} from '#/state/modals' import type {Modal as ModalIface} from '#/state/modals'
import * as ConfirmModal from './Confirm'
import * as EditProfileModal from './EditProfile' import * as EditProfileModal from './EditProfile'
import * as ReportModal from './report/Modal' import * as ReportModal from './report/Modal'
import * as AppealLabelModal from './AppealLabel' import * as AppealLabelModal from './AppealLabel'
@ -78,9 +77,7 @@ function Modal({modal}: {modal: ModalIface}) {
} }
let element let element
if (modal.name === 'confirm') { if (modal.name === 'edit-profile') {
element = <ConfirmModal.Component {...modal} />
} else if (modal.name === 'edit-profile') {
element = <EditProfileModal.Component {...modal} /> element = <EditProfileModal.Component {...modal} />
} else if (modal.name === 'report') { } else if (modal.name === 'report') {
element = <ReportModal.Component {...modal} /> element = <ReportModal.Component {...modal} />

View File

@ -9,13 +9,13 @@ import {usePalette} from 'lib/hooks/usePalette'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from 'lib/routes/types' import {NavigationProp} from 'lib/routes/types'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useModalControls} from '#/state/modals'
import {msg as msgLingui, Trans} from '@lingui/macro' import {msg as msgLingui, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {FeedDescriptor} from '#/state/queries/post-feed' import {FeedDescriptor} from '#/state/queries/post-feed'
import {EmptyState} from '../util/EmptyState' import {EmptyState} from '../util/EmptyState'
import {cleanError} from '#/lib/strings/errors' import {cleanError} from '#/lib/strings/errors'
import {useRemoveFeedMutation} from '#/state/queries/preferences' import {useRemoveFeedMutation} from '#/state/queries/preferences'
import * as Prompt from '#/components/Prompt'
export enum KnownError { export enum KnownError {
Block = 'Block', Block = 'Block',
@ -118,35 +118,29 @@ function FeedgenErrorMessage({
) )
const [_, uri] = feedDesc.split('|') const [_, uri] = feedDesc.split('|')
const [ownerDid] = safeParseFeedgenUri(uri) const [ownerDid] = safeParseFeedgenUri(uri)
const {openModal, closeModal} = useModalControls() const removePromptControl = Prompt.usePromptControl()
const {mutateAsync: removeFeed} = useRemoveFeedMutation() const {mutateAsync: removeFeed} = useRemoveFeedMutation()
const onViewProfile = React.useCallback(() => { const onViewProfile = React.useCallback(() => {
navigation.navigate('Profile', {name: ownerDid}) navigation.navigate('Profile', {name: ownerDid})
}, [navigation, ownerDid]) }, [navigation, ownerDid])
const onPressRemoveFeed = React.useCallback(() => {
removePromptControl.open()
}, [removePromptControl])
const onRemoveFeed = React.useCallback(async () => { const onRemoveFeed = React.useCallback(async () => {
openModal({ try {
name: 'confirm', await removeFeed({uri})
title: _l(msgLingui`Remove feed`), } catch (err) {
message: _l(msgLingui`Remove this feed from your saved feeds?`), Toast.show(
async onPressConfirm() { _l(
try { msgLingui`There was an an issue removing this feed. Please check your internet connection and try again.`,
await removeFeed({uri}) ),
} catch (err) { )
Toast.show( logger.error('Failed to remove feed', {message: err})
_l( }
msgLingui`There was an an issue removing this feed. Please check your internet connection and try again.`, }, [uri, removeFeed, _l])
),
)
logger.error('Failed to remove feed', {message: err})
}
},
onPressCancel() {
closeModal()
},
})
}, [openModal, closeModal, uri, removeFeed, _l])
const cta = React.useMemo(() => { const cta = React.useMemo(() => {
switch (knownError) { switch (knownError) {
@ -179,27 +173,38 @@ function FeedgenErrorMessage({
}, [knownError, onViewProfile, onRemoveFeed, _l]) }, [knownError, onViewProfile, onRemoveFeed, _l])
return ( return (
<View <>
style={[ <View
pal.border, style={[
pal.viewLight, pal.border,
{ pal.viewLight,
borderTopWidth: 1, {
paddingHorizontal: 20, borderTopWidth: 1,
paddingVertical: 18, paddingHorizontal: 20,
gap: 12, paddingVertical: 18,
}, gap: 12,
]}> },
<Text style={pal.text}>{msg}</Text> ]}>
<Text style={pal.text}>{msg}</Text>
{rawError?.message && ( {rawError?.message && (
<Text style={pal.textLight}> <Text style={pal.textLight}>
<Trans>Message from server: {rawError.message}</Trans> <Trans>Message from server: {rawError.message}</Trans>
</Text> </Text>
)} )}
{cta} {cta}
</View> </View>
<Prompt.Basic
control={removePromptControl}
title={_l(msgLingui`Remove feed?`)}
description={_l(msgLingui`Remove this feed from your saved feeds`)}
onConfirm={onPressRemoveFeed}
confirmButtonCta={_l(msgLingui`Remove`)}
confirmButtonColor="negative"
/>
</>
) )
} }

View File

@ -7,7 +7,6 @@ import {
} from 'react-native' } from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
import { import {
AppBskyActorDefs, AppBskyActorDefs,
ModerationOpts, ModerationOpts,
@ -17,7 +16,7 @@ import {
import {Trans, msg} from '@lingui/macro' import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {NavigationProp} from 'lib/routes/types' import {NavigationProp} from 'lib/routes/types'
import {isNative, isWeb} from 'platform/detection' import {isNative} from 'platform/detection'
import {BlurView} from '../util/BlurView' import {BlurView} from '../util/BlurView'
import * as Toast from '../util/Toast' import * as Toast from '../util/Toast'
import {LoadingPlaceholder} from '../util/LoadingPlaceholder' import {LoadingPlaceholder} from '../util/LoadingPlaceholder'
@ -28,14 +27,11 @@ import {UserAvatar} from '../util/UserAvatar'
import {UserBanner} from '../util/UserBanner' import {UserBanner} from '../util/UserBanner'
import {ProfileHeaderAlerts} from '../util/moderation/ProfileHeaderAlerts' import {ProfileHeaderAlerts} from '../util/moderation/ProfileHeaderAlerts'
import {formatCount} from '../util/numeric/format' import {formatCount} from '../util/numeric/format'
import {NativeDropdown, DropdownItem} from '../util/forms/NativeDropdown'
import {Link} from '../util/Link' import {Link} from '../util/Link'
import {ProfileHeaderSuggestedFollows} from './ProfileHeaderSuggestedFollows' import {ProfileHeaderSuggestedFollows} from './ProfileHeaderSuggestedFollows'
import {useModalControls} from '#/state/modals' import {useModalControls} from '#/state/modals'
import {useLightboxControls, ProfileImageLightbox} from '#/state/lightbox' import {useLightboxControls, ProfileImageLightbox} from '#/state/lightbox'
import { import {
RQKEY as profileQueryKey,
useProfileMuteMutationQueue,
useProfileBlockMutationQueue, useProfileBlockMutationQueue,
useProfileFollowMutationQueue, useProfileFollowMutationQueue,
} from '#/state/queries/profile' } from '#/state/queries/profile'
@ -46,9 +42,7 @@ import {BACK_HITSLOP} from 'lib/constants'
import {isInvalidHandle, sanitizeHandle} from 'lib/strings/handles' import {isInvalidHandle, sanitizeHandle} from 'lib/strings/handles'
import {makeProfileLink} from 'lib/routes/links' import {makeProfileLink} from 'lib/routes/links'
import {pluralize} from 'lib/strings/helpers' import {pluralize} from 'lib/strings/helpers'
import {toShareUrl} from 'lib/strings/url-helpers'
import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeDisplayName} from 'lib/strings/display-names'
import {shareUrl} from 'lib/sharing'
import {s, colors} from 'lib/styles' import {s, colors} from 'lib/styles'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
@ -57,6 +51,8 @@ import {useRequireAuth} from '#/state/session'
import {LabelInfo} from '../util/moderation/LabelInfo' import {LabelInfo} from '../util/moderation/LabelInfo'
import {useProfileShadow} from 'state/cache/profile-shadow' import {useProfileShadow} from 'state/cache/profile-shadow'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
import {ProfileMenu} from 'view/com/profile/ProfileMenu'
import * as Prompt from '#/components/Prompt'
let ProfileHeaderLoading = (_props: {}): React.ReactNode => { let ProfileHeaderLoading = (_props: {}): React.ReactNode => {
const pal = usePalette('default') const pal = usePalette('default')
@ -108,20 +104,13 @@ let ProfileHeader = ({
const {isDesktop} = useWebMediaQueries() const {isDesktop} = useWebMediaQueries()
const [showSuggestedFollows, setShowSuggestedFollows] = React.useState(false) const [showSuggestedFollows, setShowSuggestedFollows] = React.useState(false)
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile) const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile)
const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile) const [__, queueUnblock] = useProfileBlockMutationQueue(profile)
const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) const unblockPromptControl = Prompt.usePromptControl()
const queryClient = useQueryClient()
const moderation = useMemo( const moderation = useMemo(
() => moderateProfile(profile, moderationOpts), () => moderateProfile(profile, moderationOpts),
[profile, moderationOpts], [profile, moderationOpts],
) )
const invalidateProfileQuery = React.useCallback(() => {
queryClient.invalidateQueries({
queryKey: profileQueryKey(profile.did),
})
}, [queryClient, profile.did])
const onPressBack = React.useCallback(() => { const onPressBack = React.useCallback(() => {
if (navigation.canGoBack()) { if (navigation.canGoBack()) {
navigation.goBack() navigation.goBack()
@ -189,204 +178,23 @@ let ProfileHeader = ({
}) })
}, [track, openModal, profile]) }, [track, openModal, profile])
const onPressShare = React.useCallback(() => { const unblockAccount = React.useCallback(async () => {
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 () => {
track('ProfileHeader:UnblockAccountButtonClicked') track('ProfileHeader:UnblockAccountButtonClicked')
openModal({ try {
name: 'confirm', await queueUnblock()
title: _(msg`Unblock Account`), Toast.show(_(msg`Account unblocked`))
message: _( } catch (e: any) {
msg`The account will be able to interact with you after unblocking.`, if (e?.name !== 'AbortError') {
), logger.error('Failed to unblock account', {message: e})
onPressConfirm: async () => { Toast.show(_(msg`There was an issue! ${e.toString()}`))
try { }
await queueUnblock() }
Toast.show(_(msg`Account unblocked`)) }, [_, queueUnblock, track])
} 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])
const isMe = React.useMemo( const isMe = React.useMemo(
() => currentAccount?.did === profile.did, () => currentAccount?.did === profile.did,
[currentAccount, profile], [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 = const blockHide =
!isMe && (profile.viewer?.blocking || profile.viewer?.blockedBy) !isMe && (profile.viewer?.blocking || profile.viewer?.blockedBy)
@ -427,7 +235,7 @@ let ProfileHeader = ({
profile.viewer?.blockingByList ? null : ( profile.viewer?.blockingByList ? null : (
<TouchableOpacity <TouchableOpacity
testID="unblockBtn" testID="unblockBtn"
onPress={onPressUnblockAccount} onPress={() => unblockPromptControl.open()}
style={[styles.btn, styles.mainBtn, pal.btn]} style={[styles.btn, styles.mainBtn, pal.btn]}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={_(msg`Unblock`)} accessibilityLabel={_(msg`Unblock`)}
@ -516,17 +324,7 @@ let ProfileHeader = ({
)} )}
</> </>
) : null} ) : null}
{dropdownItems?.length ? ( <ProfileMenu profile={profile} />
<NativeDropdown
testID="profileHeaderDropdownBtn"
items={dropdownItems}
accessibilityLabel={_(msg`More options`)}
accessibilityHint="">
<View style={[styles.btn, styles.secondaryBtn, pal.btn]}>
<FontAwesomeIcon icon="ellipsis" size={20} style={[pal.text]} />
</View>
</NativeDropdown>
) : undefined}
</View> </View>
<View pointerEvents="none"> <View pointerEvents="none">
<Text <Text
@ -670,6 +468,18 @@ let ProfileHeader = ({
/> />
</View> </View>
</TouchableWithoutFeedback> </TouchableWithoutFeedback>
<Prompt.Basic
control={unblockPromptControl}
title={_(msg`Unblock Account?`)}
description={_(
msg`The account will be able to interact with you after unblocking.`,
)}
onConfirm={unblockAccount}
confirmButtonCta={
profile.viewer?.blocking ? _(msg`Unblock`) : _(msg`Block`)
}
confirmButtonColor="negative"
/>
</View> </View>
) )
} }

View File

@ -0,0 +1,315 @@
import React, {memo} from 'react'
import {TouchableOpacity} from 'react-native'
import {AppBskyActorDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {useQueryClient} from '@tanstack/react-query'
import * as Toast from 'view/com/util/Toast'
import {EventStopper} from 'view/com/util/EventStopper'
import {useSession} from 'state/session'
import * as Menu from '#/components/Menu'
import {useTheme} from '#/alf'
import {usePalette} from 'lib/hooks/usePalette'
import {HITSLOP_10} from 'lib/constants'
import {shareUrl} from 'lib/sharing'
import {toShareUrl} from 'lib/strings/url-helpers'
import {makeProfileLink} from 'lib/routes/links'
import {useAnalytics} from 'lib/analytics/analytics'
import {useModalControls} from 'state/modals'
import {
RQKEY as profileQueryKey,
useProfileBlockMutationQueue,
useProfileFollowMutationQueue,
useProfileMuteMutationQueue,
} from 'state/queries/profile'
import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox'
import {ListSparkle_Stroke2_Corner0_Rounded as List} from '#/components/icons/ListSparkle'
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag'
import {PersonCheck_Stroke2_Corner0_Rounded as PersonCheck} from '#/components/icons/PersonCheck'
import {PersonX_Stroke2_Corner0_Rounded as PersonX} from '#/components/icons/PersonX'
import {PeopleRemove2_Stroke2_Corner0_Rounded as UserMinus} from '#/components/icons/PeopleRemove2'
import {logger} from '#/logger'
import {Shadow} from 'state/cache/types'
import * as Prompt from '#/components/Prompt'
let ProfileMenu = ({
profile,
}: {
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
}): React.ReactNode => {
const {_} = useLingui()
const {currentAccount, hasSession} = useSession()
const t = useTheme()
// TODO ALF this
const pal = usePalette('default')
const {track} = useAnalytics()
const {openModal} = useModalControls()
const queryClient = useQueryClient()
const isSelf = currentAccount?.did === profile.did
const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile)
const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
const [, queueUnfollow] = useProfileFollowMutationQueue(profile)
const blockPromptControl = Prompt.usePromptControl()
const invalidateProfileQuery = React.useCallback(() => {
queryClient.invalidateQueries({
queryKey: profileQueryKey(profile.did),
})
}, [queryClient, profile.did])
const onPressShare = React.useCallback(() => {
track('ProfileHeader:ShareButtonClicked')
shareUrl(toShareUrl(makeProfileLink(profile)))
}, [track, profile])
const onPressAddRemoveLists = React.useCallback(() => {
track('ProfileHeader:AddToListsButtonClicked')
openModal({
name: 'user-add-remove-lists',
subject: profile.did,
handle: profile.handle,
displayName: profile.displayName || profile.handle,
onAdd: invalidateProfileQuery,
onRemove: invalidateProfileQuery,
})
}, [track, profile, openModal, invalidateProfileQuery])
const onPressMuteAccount = React.useCallback(async () => {
if (profile.viewer?.muted) {
track('ProfileHeader:UnmuteAccountButtonClicked')
try {
await queueUnmute()
Toast.show(_(msg`Account unmuted`))
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('Failed to unmute account', {message: e})
Toast.show(_(msg`There was an issue! ${e.toString()}`))
}
}
} else {
track('ProfileHeader:MuteAccountButtonClicked')
try {
await queueMute()
Toast.show(_(msg`Account muted`))
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('Failed to mute account', {message: e})
Toast.show(_(msg`There was an issue! ${e.toString()}`))
}
}
}
}, [profile.viewer?.muted, track, queueUnmute, _, queueMute])
const blockAccount = React.useCallback(async () => {
if (profile.viewer?.blocking) {
track('ProfileHeader:UnblockAccountButtonClicked')
try {
await queueUnblock()
Toast.show(_(msg`Account unblocked`))
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('Failed to unblock account', {message: e})
Toast.show(_(msg`There was an issue! ${e.toString()}`))
}
}
} else {
track('ProfileHeader:BlockAccountButtonClicked')
try {
await queueBlock()
Toast.show(_(msg`Account blocked`))
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('Failed to block account', {message: e})
Toast.show(_(msg`There was an issue! ${e.toString()}`))
}
}
}
}, [profile.viewer?.blocking, track, _, queueUnblock, queueBlock])
const onPressUnfollowAccount = React.useCallback(async () => {
track('ProfileHeader:UnfollowButtonClicked')
try {
await queueUnfollow()
Toast.show(_(msg`Account unfollowed`))
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('Failed to unfollow account', {message: e})
Toast.show(_(msg`There was an issue! ${e.toString()}`))
}
}
}, [_, queueUnfollow, track])
const onPressReportAccount = React.useCallback(() => {
track('ProfileHeader:ReportAccountButtonClicked')
openModal({
name: 'report',
did: profile.did,
})
}, [track, openModal, profile])
return (
<EventStopper onKeyDown={false}>
<Menu.Root>
<Menu.Trigger label={_(`More options`)}>
{({props}) => {
return (
<TouchableOpacity
{...props}
hitSlop={HITSLOP_10}
testID="profileHeaderDropdownBtn"
style={[
{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 7,
borderRadius: 50,
marginLeft: 6,
paddingHorizontal: 14,
},
pal.btn,
]}>
<FontAwesomeIcon
icon="ellipsis"
size={20}
style={t.atoms.text}
/>
</TouchableOpacity>
)
}}
</Menu.Trigger>
<Menu.Outer style={{minWidth: 170}}>
<Menu.Group>
<Menu.Item
testID="profileHeaderDropdownShareBtn"
label={_(msg`Share`)}
onPress={onPressShare}>
<Menu.ItemText>
<Trans>Share</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Share} />
</Menu.Item>
</Menu.Group>
{hasSession && (
<>
<Menu.Divider />
<Menu.Group>
<Menu.Item
testID="profileHeaderDropdownListAddRemoveBtn"
label={_(msg`Add to Lists`)}
onPress={onPressAddRemoveLists}>
<Menu.ItemText>
<Trans>Add to Lists</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={List} />
</Menu.Item>
{!isSelf && (
<>
{profile.viewer?.following &&
(profile.viewer.blocking || profile.viewer.blockedBy) && (
<Menu.Item
testID="profileHeaderDropdownUnfollowBtn"
label={_(msg`Unfollow Account`)}
onPress={onPressUnfollowAccount}>
<Menu.ItemText>
<Trans>Unfollow Account</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={UserMinus} />
</Menu.Item>
)}
{!profile.viewer?.blocking &&
!profile.viewer?.mutedByList && (
<Menu.Item
testID="profileHeaderDropdownMuteBtn"
label={
profile.viewer?.muted
? _(msg`Unmute Account`)
: _(msg`Mute Account`)
}
onPress={onPressMuteAccount}>
<Menu.ItemText>
{profile.viewer?.muted ? (
<Trans>Unmute Account</Trans>
) : (
<Trans>Mute Account</Trans>
)}
</Menu.ItemText>
<Menu.ItemIcon
icon={profile.viewer?.muted ? Unmute : Mute}
/>
</Menu.Item>
)}
{!profile.viewer?.blockingByList && (
<Menu.Item
testID="profileHeaderDropdownBlockBtn"
label={
profile.viewer
? _(msg`Unblock Account`)
: _(msg`Block Account`)
}
onPress={() => blockPromptControl.open()}>
<Menu.ItemText>
{profile.viewer?.blocking ? (
<Trans>Unblock Account</Trans>
) : (
<Trans>Block Account</Trans>
)}
</Menu.ItemText>
<Menu.ItemIcon
icon={
profile.viewer?.blocking ? PersonCheck : PersonX
}
/>
</Menu.Item>
)}
<Menu.Item
testID="profileHeaderDropdownReportBtn"
label={_(msg`Report Account`)}
onPress={onPressReportAccount}>
<Menu.ItemText>
<Trans>Report Account</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Flag} />
</Menu.Item>
</>
)}
</Menu.Group>
</>
)}
</Menu.Outer>
</Menu.Root>
<Prompt.Basic
control={blockPromptControl}
title={
profile.viewer?.blocking
? _(msg`Unblock Account?`)
: _(msg`Block Account?`)
}
description={
profile.viewer?.blocking
? _(
msg`The account will be able to interact with you after unblocking.`,
)
: _(
msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`,
)
}
onConfirm={blockAccount}
confirmButtonCta={
profile.viewer?.blocking ? _(msg`Unblock`) : _(msg`Block`)
}
confirmButtonColor={profile.viewer?.blocking ? undefined : 'negative'}
/>
</EventStopper>
)
}
ProfileMenu = memo(ProfileMenu)
export {ProfileMenu}

View File

@ -11,14 +11,9 @@ import {
TouchableWithoutFeedback, TouchableWithoutFeedback,
TouchableOpacity, TouchableOpacity,
} from 'react-native' } from 'react-native'
import { import {useLinkProps, StackActions} from '@react-navigation/native'
useLinkProps,
useNavigation,
StackActions,
} from '@react-navigation/native'
import {Text} from './text/Text' import {Text} from './text/Text'
import {TypographyVariant} from 'lib/ThemeContext' import {TypographyVariant} from 'lib/ThemeContext'
import {NavigationProp} from 'lib/routes/types'
import {router} from '../../../routes' import {router} from '../../../routes'
import { import {
convertBskyAppUrlIfNeeded, convertBskyAppUrlIfNeeded,
@ -32,6 +27,10 @@ import FixedTouchableHighlight from '../pager/FixedTouchableHighlight'
import {useModalControls} from '#/state/modals' import {useModalControls} from '#/state/modals'
import {useOpenLink} from '#/state/preferences/in-app-browser' import {useOpenLink} from '#/state/preferences/in-app-browser'
import {WebAuxClickWrapper} from 'view/com/util/WebAuxClickWrapper' import {WebAuxClickWrapper} from 'view/com/util/WebAuxClickWrapper'
import {
DebouncedNavigationProp,
useNavigationDeduped,
} from 'lib/hooks/useNavigationDeduped'
type Event = type Event =
| React.MouseEvent<HTMLAnchorElement, MouseEvent> | React.MouseEvent<HTMLAnchorElement, MouseEvent>
@ -65,7 +64,7 @@ export const Link = memo(function Link({
...props ...props
}: Props) { }: Props) {
const {closeModal} = useModalControls() const {closeModal} = useModalControls()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigationDeduped()
const anchorHref = asAnchor ? sanitizeUrl(href) : undefined const anchorHref = asAnchor ? sanitizeUrl(href) : undefined
const openLink = useOpenLink() const openLink = useOpenLink()
@ -176,7 +175,7 @@ export const TextLink = memo(function TextLink({
navigationAction?: 'push' | 'replace' | 'navigate' navigationAction?: 'push' | 'replace' | 'navigate'
} & TextProps) { } & TextProps) {
const {...props} = useLinkProps({to: sanitizeUrl(href)}) const {...props} = useLinkProps({to: sanitizeUrl(href)})
const navigation = useNavigation<NavigationProp>() const navigation = useNavigationDeduped()
const {openModal, closeModal} = useModalControls() const {openModal, closeModal} = useModalControls()
const openLink = useOpenLink() const openLink = useOpenLink()
@ -335,7 +334,7 @@ const EXEMPT_PATHS = ['/robots.txt', '/security.txt', '/.well-known/']
// -prf // -prf
function onPressInner( function onPressInner(
closeModal = () => {}, closeModal = () => {},
navigation: NavigationProp, navigation: DebouncedNavigationProp,
href: string, href: string,
navigationAction: 'push' | 'replace' | 'navigate' = 'push', navigationAction: 'push' | 'replace' | 'navigate' = 'push',
openLink: (href: string) => void, openLink: (href: string) => void,

View File

@ -1,9 +1,13 @@
import React, {memo, useMemo} from 'react' 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 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 {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {HighPriorityImage} from 'view/com/util/images/Image'
import {ModerationUI} from '@atproto/api' import {ModerationUI} from '@atproto/api'
import {HighPriorityImage} from 'view/com/util/images/Image'
import {openCamera, openCropper, openPicker} from '../../../lib/media/picker' import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
import { import {
usePhotoLibraryPermission, usePhotoLibraryPermission,
@ -11,12 +15,16 @@ import {
} from 'lib/hooks/usePermissions' } from 'lib/hooks/usePermissions'
import {colors} from 'lib/styles' import {colors} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {isWeb, isAndroid} from 'platform/detection' import {isWeb, isAndroid, isNative} from 'platform/detection'
import {Image as RNImage} from 'react-native-image-crop-picker'
import {UserPreviewLink} from './UserPreviewLink' import {UserPreviewLink} from './UserPreviewLink'
import {DropdownItem, NativeDropdown} from './forms/NativeDropdown' import * as Menu from '#/components/Menu'
import {useLingui} from '@lingui/react' import {
import {msg} from '@lingui/macro' 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' export type UserAvatarType = 'user' | 'algo' | 'list'
@ -196,6 +204,7 @@ let EditableUserAvatar = ({
avatar, avatar,
onSelectNewAvatar, onSelectNewAvatar,
}: EditableUserAvatarProps): React.ReactNode => { }: EditableUserAvatarProps): React.ReactNode => {
const t = useTheme()
const pal = usePalette('default') const pal = usePalette('default')
const {_} = useLingui() const {_} = useLingui()
const {requestCameraAccessIfNeeded} = useCameraPermission() const {requestCameraAccessIfNeeded} = useCameraPermission()
@ -216,118 +225,115 @@ let EditableUserAvatar = ({
} }
}, [type, size]) }, [type, size])
const dropdownItems = useMemo( const onOpenCamera = React.useCallback(async () => {
() => if (!(await requestCameraAccessIfNeeded())) {
[ return
!isWeb && { }
testID: 'changeAvatarCameraBtn',
label: _(msg`Camera`),
icon: {
ios: {
name: 'camera',
},
android: 'ic_menu_camera',
web: 'camera',
},
onPress: async () => {
if (!(await requestCameraAccessIfNeeded())) {
return
}
onSelectNewAvatar( onSelectNewAvatar(
await openCamera({ await openCamera({
width: 1000, width: 1000,
height: 1000, height: 1000,
cropperCircleOverlay: true, cropperCircleOverlay: true,
}), }),
) )
}, }, [onSelectNewAvatar, requestCameraAccessIfNeeded])
},
{
testID: 'changeAvatarLibraryBtn',
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({ const onOpenLibrary = React.useCallback(async () => {
aspect: [1, 1], if (!(await requestPhotoAccessIfNeeded())) {
}) return
const item = items[0] }
if (!item) {
return
}
const croppedImage = await openCropper({ const items = await openPicker({
mediaType: 'photo', aspect: [1, 1],
cropperCircleOverlay: true, })
height: item.height, const item = items[0]
width: item.width, if (!item) {
path: item.path, return
}) }
onSelectNewAvatar(croppedImage) const croppedImage = await openCropper({
}, mediaType: 'photo',
}, cropperCircleOverlay: true,
!!avatar && { height: item.height,
label: 'separator', width: item.width,
}, path: item.path,
!!avatar && { })
testID: 'changeAvatarRemoveBtn',
label: _(msg`Remove`), onSelectNewAvatar(croppedImage)
icon: { }, [onSelectNewAvatar, requestPhotoAccessIfNeeded])
ios: {
name: 'trash', const onRemoveAvatar = React.useCallback(() => {
}, onSelectNewAvatar(null)
android: 'ic_delete', }, [onSelectNewAvatar])
web: ['far', 'trash-can'],
},
onPress: async () => {
onSelectNewAvatar(null)
},
},
].filter(Boolean) as DropdownItem[],
[
avatar,
onSelectNewAvatar,
requestCameraAccessIfNeeded,
requestPhotoAccessIfNeeded,
_,
],
)
return ( return (
<NativeDropdown <Menu.Root>
testID="changeAvatarBtn" <Menu.Trigger label={_(msg`Edit avatar`)}>
items={dropdownItems} {({props}) => (
accessibilityLabel={_(msg`Image options`)} <TouchableOpacity {...props} activeOpacity={0.8}>
accessibilityHint=""> {avatar ? (
{avatar ? ( <HighPriorityImage
<HighPriorityImage testID="userAvatarImage"
testID="userAvatarImage" style={aviStyle}
style={aviStyle} source={{uri: avatar}}
source={{uri: avatar}} accessibilityRole="image"
accessibilityRole="image" />
/> ) : (
) : ( <DefaultAvatar type={type} size={size} />
<DefaultAvatar type={type} size={size} /> )}
)} <View style={[styles.editButtonContainer, pal.btn]}>
<View style={[styles.editButtonContainer, pal.btn]}> <CameraFilled height={14} width={14} style={t.atoms.text} />
<FontAwesomeIcon </View>
icon="camera" </TouchableOpacity>
size={12} )}
color={pal.text.color as string} </Menu.Trigger>
/> <Menu.Outer showCancel>
</View> <Menu.Group>
</NativeDropdown> {isNative && (
<Menu.Item
testID="changeAvatarCameraBtn"
label={_(msg`Upload from Camera`)}
onPress={onOpenCamera}>
<Menu.ItemText>
<Trans>Upload from Camera</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Camera} />
</Menu.Item>
)}
<Menu.Item
testID="changeAvatarLibraryBtn"
label={_(msg`Upload from Library`)}
onPress={onOpenLibrary}>
<Menu.ItemText>
{isNative ? (
<Trans>Upload from Library</Trans>
) : (
<Trans>Upload from Files</Trans>
)}
</Menu.ItemText>
<Menu.ItemIcon icon={Library} />
</Menu.Item>
</Menu.Group>
{!!avatar && (
<>
<Menu.Divider />
<Menu.Group>
<Menu.Item
testID="changeAvatarRemoveBtn"
label={_(`Remove Avatar`)}
onPress={onRemoveAvatar}>
<Menu.ItemText>
<Trans>Remove Avatar</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Trash} />
</Menu.Item>
</Menu.Group>
</>
)}
</Menu.Outer>
</Menu.Root>
) )
} }
EditableUserAvatar = memo(EditableUserAvatar) EditableUserAvatar = memo(EditableUserAvatar)

View File

@ -1,21 +1,29 @@
import React, {useMemo} from 'react' import React from 'react'
import {StyleSheet, View} from 'react-native' import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {ModerationUI} from '@atproto/api' import {ModerationUI} from '@atproto/api'
import {Image} from 'expo-image' import {Image} from 'expo-image'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {colors} from 'lib/styles' import {colors} from 'lib/styles'
import {useTheme} from 'lib/ThemeContext' import {useTheme} from 'lib/ThemeContext'
import {useTheme as useAlfTheme} from '#/alf'
import {openCamera, openCropper, openPicker} from '../../../lib/media/picker' import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
import { import {
usePhotoLibraryPermission, usePhotoLibraryPermission,
useCameraPermission, useCameraPermission,
} from 'lib/hooks/usePermissions' } from 'lib/hooks/usePermissions'
import {usePalette} from 'lib/hooks/usePalette' 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 {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({ export function UserBanner({
banner, banner,
@ -28,118 +36,120 @@ export function UserBanner({
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
const theme = useTheme() const theme = useTheme()
const t = useAlfTheme()
const {_} = useLingui() const {_} = useLingui()
const {requestCameraAccessIfNeeded} = useCameraPermission() const {requestCameraAccessIfNeeded} = useCameraPermission()
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
const dropdownItems: DropdownItem[] = useMemo( const onOpenCamera = React.useCallback(async () => {
() => if (!(await requestCameraAccessIfNeeded())) {
[ return
!isWeb && { }
testID: 'changeBannerCameraBtn', onSelectNewBanner?.(
label: _(msg`Camera`), await openCamera({
icon: { width: 3000,
ios: { height: 1000,
name: 'camera', }),
}, )
android: 'ic_menu_camera', }, [onSelectNewBanner, requestCameraAccessIfNeeded])
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
}
onSelectNewBanner?.( const onOpenLibrary = React.useCallback(async () => {
await openCropper({ if (!(await requestPhotoAccessIfNeeded())) {
mediaType: 'photo', return
path: items[0].path, }
width: 3000, const items = await openPicker()
height: 1000, if (!items[0]) {
}), return
) }
},
}, onSelectNewBanner?.(
!!banner && { await openCropper({
testID: 'changeBannerRemoveBtn', mediaType: 'photo',
label: _(msg`Remove`), path: items[0].path,
icon: { width: 3000,
ios: { height: 1000,
name: 'trash', }),
}, )
android: 'ic_delete', }, [onSelectNewBanner, requestPhotoAccessIfNeeded])
web: ['far', 'trash-can'],
}, const onRemoveBanner = React.useCallback(() => {
onPress: () => { onSelectNewBanner?.(null)
onSelectNewBanner?.(null) }, [onSelectNewBanner])
},
},
].filter(Boolean) as DropdownItem[],
[
banner,
onSelectNewBanner,
requestCameraAccessIfNeeded,
requestPhotoAccessIfNeeded,
_,
],
)
// setUserBanner is only passed as prop on the EditProfile component // setUserBanner is only passed as prop on the EditProfile component
return onSelectNewBanner ? ( return onSelectNewBanner ? (
<NativeDropdown <EventStopper onKeyDown={false}>
testID="changeBannerBtn" <Menu.Root>
items={dropdownItems} <Menu.Trigger label={_(msg`Edit avatar`)}>
accessibilityLabel={_(msg`Image options`)} {({props}) => (
accessibilityHint=""> <TouchableOpacity {...props} activeOpacity={0.8}>
{banner ? ( {banner ? (
<Image <Image
testID="userBannerImage" testID="userBannerImage"
style={styles.bannerImage} style={styles.bannerImage}
source={{uri: banner}} source={{uri: banner}}
accessible={true} accessible={true}
accessibilityIgnoresInvertColors accessibilityIgnoresInvertColors
/> />
) : ( ) : (
<View <View
testID="userBannerFallback" testID="userBannerFallback"
style={[styles.bannerImage, styles.defaultBanner]} style={[styles.bannerImage, styles.defaultBanner]}
/> />
)} )}
<View style={[styles.editButtonContainer, pal.btn]}> <View style={[styles.editButtonContainer, pal.btn]}>
<FontAwesomeIcon <CameraFilled height={14} width={14} style={t.atoms.text} />
icon="camera" </View>
size={12} </TouchableOpacity>
style={{color: colors.white}} )}
color={pal.text.color as string} </Menu.Trigger>
/> <Menu.Outer showCancel>
</View> <Menu.Group>
</NativeDropdown> {isNative && (
<Menu.Item
testID="changeBannerCameraBtn"
label={_(msg`Upload from Camera`)}
onPress={onOpenCamera}>
<Menu.ItemText>
<Trans>Upload from Camera</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Camera} />
</Menu.Item>
)}
<Menu.Item
testID="changeBannerLibraryBtn"
label={_(msg`Upload from Library`)}
onPress={onOpenLibrary}>
<Menu.ItemText>
{isNative ? (
<Trans>Upload from Library</Trans>
) : (
<Trans>Upload from Files</Trans>
)}
</Menu.ItemText>
<Menu.ItemIcon icon={Library} />
</Menu.Item>
</Menu.Group>
{!!banner && (
<>
<Menu.Divider />
<Menu.Group>
<Menu.Item
testID="changeBannerRemoveBtn"
label={_(`Remove Banner`)}
onPress={onRemoveBanner}>
<Menu.ItemText>
<Trans>Remove Banner</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Trash} />
</Menu.Item>
</Menu.Group>
</>
)}
</Menu.Outer>
</Menu.Root>
</EventStopper>
) : banner && ) : banner &&
!((moderation?.blur && isAndroid) /* android crashes with blur */) ? ( !((moderation?.blur && isAndroid) /* android crashes with blur */) ? (
<Image <Image

View File

@ -1,11 +1,5 @@
import React, {memo} from 'react' import React, {memo} from 'react'
import { import {StyleProp, ViewStyle, Pressable, PressableProps} from 'react-native'
StyleProp,
ViewStyle,
Pressable,
View,
PressableProps,
} from 'react-native'
import Clipboard from '@react-native-clipboard/clipboard' import Clipboard from '@react-native-clipboard/clipboard'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
@ -20,6 +14,8 @@ import {useTheme} from 'lib/ThemeContext'
import {shareUrl} from 'lib/sharing' import {shareUrl} from 'lib/sharing'
import * as Toast from '../Toast' import * as Toast from '../Toast'
import {EventStopper} from '../EventStopper' import {EventStopper} from '../EventStopper'
import {useDialogControl} from '#/components/Dialog'
import * as Prompt from '#/components/Prompt'
import {useModalControls} from '#/state/modals' import {useModalControls} from '#/state/modals'
import {makeProfileLink} from '#/lib/routes/links' import {makeProfileLink} from '#/lib/routes/links'
import {CommonNavigatorParams} from '#/lib/routes/types' import {CommonNavigatorParams} from '#/lib/routes/types'
@ -38,7 +34,7 @@ import {isWeb} from '#/platform/detection'
import {richTextToString} from '#/lib/strings/rich-text-helpers' import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' 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 * as Menu from '#/components/Menu'
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter' import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter'
@ -87,6 +83,8 @@ let PostDropdownBtn = ({
const openLink = useOpenLink() const openLink = useOpenLink()
const navigation = useNavigation() const navigation = useNavigation()
const {mutedWordsDialogControl} = useGlobalDialogsControlContext() const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
const deletePromptControl = useDialogControl()
const hidePromptControl = useDialogControl()
const rootUri = record.reply?.root?.uri || postUri const rootUri = record.reply?.root?.uri || postUri
const isThreadMuted = mutedThreads.includes(rootUri) const isThreadMuted = mutedThreads.includes(rootUri)
@ -174,29 +172,18 @@ let PostDropdownBtn = ({
<Menu.Root> <Menu.Root>
<Menu.Trigger label={_(msg`Open post options menu`)}> <Menu.Trigger label={_(msg`Open post options menu`)}>
{({props, state}) => { {({props, state}) => {
const styles = [ return (
style,
a.rounded_full,
(state.hovered || state.focused || state.pressed) && [
web({outline: 0}),
alf.atoms.bg_contrast_25,
],
]
return isWeb ? (
<View {...props} testID={testID} style={styles}>
<FontAwesomeIcon
icon="ellipsis"
size={20}
color={defaultCtrlColor}
style={{pointerEvents: 'none'}}
/>
</View>
) : (
<Pressable <Pressable
{...props} {...props}
hitSlop={hitSlop} hitSlop={hitSlop}
testID={testID} testID={testID}
style={styles}> style={[
style,
a.rounded_full,
(state.hovered || state.pressed) && [
alf.atoms.bg_contrast_50,
],
]}>
<FontAwesomeIcon <FontAwesomeIcon
icon="ellipsis" icon="ellipsis"
size={20} size={20}
@ -274,16 +261,7 @@ let PostDropdownBtn = ({
<Menu.Item <Menu.Item
testID="postDropdownHideBtn" testID="postDropdownHideBtn"
label={_(msg`Hide post`)} label={_(msg`Hide post`)}
onPress={() => { onPress={hidePromptControl.open}>
openModal({
name: 'confirm',
title: _(msg`Hide this post?`),
message: _(
msg`This will hide this post from your feeds.`,
),
onPressConfirm: onHidePost,
})
}}>
<Menu.ItemText>{_(msg`Hide post`)}</Menu.ItemText> <Menu.ItemText>{_(msg`Hide post`)}</Menu.ItemText>
<Menu.ItemIcon icon={EyeSlash} position="right" /> <Menu.ItemIcon icon={EyeSlash} position="right" />
</Menu.Item> </Menu.Item>
@ -315,14 +293,7 @@ let PostDropdownBtn = ({
<Menu.Item <Menu.Item
testID="postDropdownDeleteBtn" testID="postDropdownDeleteBtn"
label={_(msg`Delete post`)} label={_(msg`Delete post`)}
onPress={() => { onPress={deletePromptControl.open}>
openModal({
name: 'confirm',
title: _(msg`Delete this post?`),
message: _(msg`Are you sure? This cannot be undone.`),
onPressConfirm: onDeletePost,
})
}}>
<Menu.ItemText>{_(msg`Delete post`)}</Menu.ItemText> <Menu.ItemText>{_(msg`Delete post`)}</Menu.ItemText>
<Menu.ItemIcon icon={Trash} position="right" /> <Menu.ItemIcon icon={Trash} position="right" />
</Menu.Item> </Menu.Item>
@ -352,6 +323,25 @@ let PostDropdownBtn = ({
</Menu.Group> </Menu.Group>
</Menu.Outer> </Menu.Outer>
</Menu.Root> </Menu.Root>
<Prompt.Basic
control={deletePromptControl}
title={_(msg`Delete this post?`)}
description={_(
msg`If you remove this post, you won't be able to recover it.`,
)}
onConfirm={onDeletePost}
confirmButtonCta={_(msg`Delete`)}
confirmButtonColor="negative"
/>
<Prompt.Basic
control={hidePromptControl}
title={_(msg`Hide this post?`)}
description={_(msg`This post will be hidden from feeds.`)}
onConfirm={onHidePost}
confirmButtonCta={_(msg`Hide`)}
/>
</EventStopper> </EventStopper>
) )
} }

View File

@ -1,12 +1,13 @@
import React from 'react' import React from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native' import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 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 {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {colors} from 'lib/styles' import {colors} from 'lib/styles'
import {HITSLOP_20} from 'lib/constants' import {HITSLOP_20} from 'lib/constants'
import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode' import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
import Animated from 'react-native-reanimated'
const AnimatedTouchableOpacity = const AnimatedTouchableOpacity =
Animated.createAnimatedComponent(TouchableOpacity) Animated.createAnimatedComponent(TouchableOpacity)
import {isWeb} from 'platform/detection' import {isWeb} from 'platform/detection'
@ -26,6 +27,9 @@ export function LoadLatestBtn({
const {isDesktop, isTablet, isMobile, isTabletOrMobile} = useWebMediaQueries() const {isDesktop, isTablet, isMobile, isTabletOrMobile} = useWebMediaQueries()
const {fabMinimalShellTransform} = useMinimalShellMode() 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 // 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) // it on both tablet and mobile since we are showing the bottom bar (see createNativeStackNavigatorWithAuth)
const showBottomBar = hasSession ? isMobile : isTabletOrMobile const showBottomBar = hasSession ? isMobile : isTabletOrMobile
@ -34,8 +38,11 @@ export function LoadLatestBtn({
<AnimatedTouchableOpacity <AnimatedTouchableOpacity
style={[ style={[
styles.loadLatest, styles.loadLatest,
isDesktop && styles.loadLatestDesktop, isDesktop &&
isTablet && styles.loadLatestTablet, (isTallViewport
? styles.loadLatestOutOfLine
: styles.loadLatestInline),
isTablet && styles.loadLatestInline,
pal.borderDark, pal.borderDark,
pal.view, pal.view,
showBottomBar && fabMinimalShellTransform, showBottomBar && fabMinimalShellTransform,
@ -65,11 +72,11 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}, },
loadLatestTablet: { loadLatestInline: {
// @ts-ignore web only // @ts-ignore web only
left: 'calc(50vw - 282px)', left: 'calc(50vw - 282px)',
}, },
loadLatestDesktop: { loadLatestOutOfLine: {
// @ts-ignore web only // @ts-ignore web only
left: 'calc(50vw - 382px)', left: 'calc(50vw - 382px)',
}, },

View File

@ -29,6 +29,8 @@ import {
} from '#/state/queries/app-passwords' } from '#/state/queries/app-passwords'
import {ErrorScreen} from '../com/util/error/ErrorScreen' import {ErrorScreen} from '../com/util/error/ErrorScreen'
import {cleanError} from '#/lib/strings/errors' import {cleanError} from '#/lib/strings/errors'
import * as Prompt from '#/components/Prompt'
import {useDialogControl} from '#/components/Dialog'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'AppPasswords'> type Props = NativeStackScreenProps<CommonNavigatorParams, 'AppPasswords'>
export function AppPasswords({}: Props) { export function AppPasswords({}: Props) {
@ -212,23 +214,18 @@ function AppPassword({
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
const {_} = useLingui() const {_} = useLingui()
const {openModal} = useModalControls() const control = useDialogControl()
const {contentLanguages} = useLanguagePrefs() const {contentLanguages} = useLanguagePrefs()
const deleteMutation = useAppPasswordDeleteMutation() const deleteMutation = useAppPasswordDeleteMutation()
const onDelete = React.useCallback(async () => { const onDelete = React.useCallback(async () => {
openModal({ await deleteMutation.mutateAsync({name})
name: 'confirm', Toast.show(_(msg`App password deleted`))
title: _(msg`Delete app password`), }, [deleteMutation, name, _])
message: _(
msg`Are you sure you want to delete the app password "${name}"?`, const onPress = React.useCallback(() => {
), control.open()
async onPressConfirm() { }, [control])
await deleteMutation.mutateAsync({name})
Toast.show(_(msg`App password deleted`))
},
})
}, [deleteMutation, openModal, name, _])
const primaryLocale = const primaryLocale =
contentLanguages.length > 0 ? contentLanguages[0] : 'en-US' contentLanguages.length > 0 ? contentLanguages[0] : 'en-US'
@ -237,7 +234,7 @@ function AppPassword({
<TouchableOpacity <TouchableOpacity
testID={testID} testID={testID}
style={[styles.item, pal.border]} style={[styles.item, pal.border]}
onPress={onDelete} onPress={onPress}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={_(msg`Delete app password`)} accessibilityLabel={_(msg`Delete app password`)}
accessibilityHint=""> accessibilityHint="">
@ -260,6 +257,17 @@ function AppPassword({
</Text> </Text>
</View> </View>
<FontAwesomeIcon icon={['far', 'trash-can']} style={styles.trashIcon} /> <FontAwesomeIcon icon={['far', 'trash-can']} style={styles.trashIcon} />
<Prompt.Basic
control={control}
title={_(msg`Delete app password?`)}
description={_(
msg`Are you sure you want to delete the app password "${name}"?`,
)}
onConfirm={onDelete}
confirmButtonCta={_(msg`Delete`)}
confirmButtonColor="negative"
/>
</TouchableOpacity> </TouchableOpacity>
) )
} }

View File

@ -1,11 +1,9 @@
import React, {useMemo, useCallback} from 'react' 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 {NativeStackScreenProps} from '@react-navigation/native-stack'
import {useIsFocused, useNavigation} from '@react-navigation/native' import {useIsFocused, useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query' import {useQueryClient} from '@tanstack/react-query'
import {usePalette} from 'lib/hooks/usePalette' 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 {CommonNavigatorParams} from 'lib/routes/types'
import {makeRecordUri} from 'lib/strings/url-helpers' import {makeRecordUri} from 'lib/strings/url-helpers'
import {s} from 'lib/styles' import {s} from 'lib/styles'
@ -13,7 +11,7 @@ import {FeedDescriptor} from '#/state/queries/post-feed'
import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' import {PagerWithHeader} from 'view/com/pager/PagerWithHeader'
import {ProfileSubpageHeader} from 'view/com/profile/ProfileSubpageHeader' import {ProfileSubpageHeader} from 'view/com/profile/ProfileSubpageHeader'
import {Feed} from 'view/com/posts/Feed' 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 {ListRef} from 'view/com/util/List'
import {Button} from 'view/com/util/forms/Button' import {Button} from 'view/com/util/forms/Button'
import {Text} from 'view/com/util/text/Text' 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 {toShareUrl} from 'lib/strings/url-helpers'
import {Haptics} from 'lib/haptics' import {Haptics} from 'lib/haptics'
import {useAnalytics} from 'lib/analytics/analytics' 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 {makeCustomFeedLink} from 'lib/routes/links'
import {pluralize} from 'lib/strings/helpers' 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 {NavigationProp} from 'lib/routes/types'
import {sanitizeHandle} from 'lib/strings/handles'
import {makeProfileLink} from 'lib/routes/links'
import {ComposeIcon2} from 'lib/icons' import {ComposeIcon2} from 'lib/icons'
import {logger} from '#/logger' import {logger} from '#/logger'
import {Trans, msg} from '@lingui/macro' import {Trans, msg} from '@lingui/macro'
@ -59,9 +52,21 @@ import {useComposerControls} from '#/state/shell/composer'
import {truncateAndInvalidate} from '#/state/queries/util' import {truncateAndInvalidate} from '#/state/queries/util'
import {isNative} from '#/platform/detection' import {isNative} from '#/platform/detection'
import {listenSoftReset} from '#/state/events' 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 { interface SectionRef {
scrollToTop: () => void scrollToTop: () => void
@ -148,7 +153,7 @@ export function ProfileFeedScreenInner({
feedInfo: FeedSourceFeedInfo feedInfo: FeedSourceFeedInfo
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const pal = usePalette('default') const t = useTheme()
const {hasSession, currentAccount} = useSession() const {hasSession, currentAccount} = useSession()
const {openModal} = useModalControls() const {openModal} = useModalControls()
const {openComposer} = useComposerControls() const {openComposer} = useComposerControls()
@ -200,9 +205,11 @@ export function ProfileFeedScreenInner({
if (isSaved) { if (isSaved) {
await removeFeed({uri: feedInfo.uri}) await removeFeed({uri: feedInfo.uri})
resetRemoveFeed() resetRemoveFeed()
Toast.show(_(msg`Removed from your feeds`))
} else { } else {
await saveFeed({uri: feedInfo.uri}) await saveFeed({uri: feedInfo.uri})
resetSaveFeed() resetSaveFeed()
Toast.show(_(msg`Saved to your feeds`))
} }
} catch (err) { } catch (err) {
Toast.show( Toast.show(
@ -263,130 +270,132 @@ export function ProfileFeedScreenInner({
[feedSectionRef], [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(() => { const renderHeader = useCallback(() => {
return ( return (
<ProfileSubpageHeader <>
isLoading={false} <ProfileSubpageHeader
href={feedInfo.route.href} isLoading={false}
title={feedInfo?.displayName} href={feedInfo.route.href}
avatar={feedInfo?.avatar} title={feedInfo?.displayName}
isOwner={feedInfo.creatorDid === currentAccount?.did} avatar={feedInfo?.avatar}
creator={ isOwner={feedInfo.creatorDid === currentAccount?.did}
feedInfo creator={
? {did: feedInfo.creatorDid, handle: feedInfo.creatorHandle} feedInfo
: undefined ? {did: feedInfo.creatorDid, handle: feedInfo.creatorHandle}
} : undefined
avatarType="algo"> }
{feedInfo && hasSession && ( avatarType="algo">
<> <View style={[a.flex_row, a.align_center, a.gap_sm]}>
<Button {feedInfo && hasSession && (
disabled={isSavePending || isRemovePending} <NewButton
type="default" testID={isPinned ? 'unpinBtn' : 'pinBtn'}
label={isSaved ? _(msg`Unsave`) : _(msg`Save`)} disabled={isPinPending || isUnpinPending}
onPress={onToggleSaved} size="small"
style={styles.btn} variant="solid"
/> color={isPinned ? 'secondary' : 'primary'}
<Button label={isPinned ? _(msg`Unpin from home`) : _(msg`Pin to home`)}
testID={isPinned ? 'unpinBtn' : 'pinBtn'} onPress={onTogglePinned}>
disabled={isPinPending || isUnpinPending} <ButtonText>
type={isPinned ? 'default' : 'inverted'} {isPinned ? _(msg`Unpin`) : _(msg`Pin to Home`)}
label={isPinned ? _(msg`Unpin`) : _(msg`Pin to home`)} </ButtonText>
onPress={onTogglePinned} </NewButton>
style={styles.btn} )}
/> <Menu.Root>
</> <Menu.Trigger label={_(msg`Open feed options menu`)}>
)} {({props, state}) => {
<NativeDropdown return (
testID="headerDropdownBtn" <Pressable
items={dropdownItems} {...props}
accessibilityLabel={_(msg`More options`)} hitSlop={HITSLOP_20}
accessibilityHint=""> style={[
<View style={[pal.viewLight, styles.btn]}> a.justify_center,
<FontAwesomeIcon a.align_center,
icon="ellipsis" a.rounded_full,
size={20} {height: 36, width: 36},
color={pal.colors.text} t.atoms.bg_contrast_50,
/> (state.hovered || state.pressed) && [
t.atoms.bg_contrast_100,
],
]}
testID="headerDropdownBtn">
<Ellipsis
size="lg"
fill={t.atoms.text_contrast_medium.color}
/>
</Pressable>
)
}}
</Menu.Trigger>
<Menu.Outer>
<Menu.Group>
{hasSession && (
<>
<Menu.Item
disabled={isSavePending || isRemovePending}
testID="feedHeaderDropdownToggleSavedBtn"
label={
isSaved
? _(msg`Remove from my feeds`)
: _(msg`Save to my feeds`)
}
onPress={onToggleSaved}>
<Menu.ItemText>
{isSaved
? _(msg`Remove from my feeds`)
: _(msg`Save to my feeds`)}
</Menu.ItemText>
<Menu.ItemIcon
icon={isSaved ? Trash : Plus}
position="right"
/>
</Menu.Item>
<Menu.Item
testID="feedHeaderDropdownReportBtn"
label={_(msg`Report feed`)}
onPress={onPressReport}>
<Menu.ItemText>{_(msg`Report feed`)}</Menu.ItemText>
<Menu.ItemIcon icon={CircleInfo} position="right" />
</Menu.Item>
</>
)}
<Menu.Item
testID="feedHeaderDropdownShareBtn"
label={_(msg`Share feed`)}
onPress={onPressShare}>
<Menu.ItemText>{_(msg`Share feed`)}</Menu.ItemText>
<Menu.ItemIcon icon={Share} position="right" />
</Menu.Item>
</Menu.Group>
</Menu.Outer>
</Menu.Root>
</View> </View>
</NativeDropdown> </ProfileSubpageHeader>
</ProfileSubpageHeader> <AboutSection
feedOwnerDid={feedInfo.creatorDid}
feedRkey={feedInfo.route.params.rkey}
feedInfo={feedInfo}
/>
</>
) )
}, [ }, [
_, _,
hasSession, hasSession,
pal,
feedInfo, feedInfo,
isPinned, isPinned,
onTogglePinned, onTogglePinned,
onToggleSaved, onToggleSaved,
dropdownItems,
currentAccount?.did, currentAccount?.did,
isPinPending, isPinPending,
isRemovePending, isRemovePending,
isSavePending, isSavePending,
isSaved, isSaved,
isUnpinPending, isUnpinPending,
onPressReport,
onPressShare,
t,
]) ])
return ( return (
@ -405,18 +414,6 @@ export function ProfileFeedScreenInner({
isFocused={isScreenFocused && isFocused} isFocused={isScreenFocused && isFocused}
/> />
)} )}
{({headerHeight, scrollElRef}) => (
<AboutSection
feedOwnerDid={feedInfo.creatorDid}
feedRkey={feedInfo.route.params.rkey}
feedInfo={feedInfo}
headerHeight={headerHeight}
scrollElRef={
scrollElRef as React.MutableRefObject<ScrollView | null>
}
isOwner={feedInfo.creatorDid === currentAccount?.did}
/>
)}
</PagerWithHeader> </PagerWithHeader>
{hasSession && ( {hasSession && (
<FAB <FAB
@ -505,21 +502,14 @@ function AboutSection({
feedOwnerDid, feedOwnerDid,
feedRkey, feedRkey,
feedInfo, feedInfo,
headerHeight,
scrollElRef,
isOwner,
}: { }: {
feedOwnerDid: string feedOwnerDid: string
feedRkey: string feedRkey: string
feedInfo: FeedSourceFeedInfo feedInfo: FeedSourceFeedInfo
headerHeight: number
scrollElRef: React.MutableRefObject<ScrollView | null>
isOwner: boolean
}) { }) {
const t = useTheme()
const pal = usePalette('default') const pal = usePalette('default')
const {_} = useLingui() const {_} = useLingui()
const scrollHandlers = useScrollHandlers()
const onScroll = useAnimatedScrollHandler(scrollHandlers)
const [likeUri, setLikeUri] = React.useState(feedInfo.likeUri) const [likeUri, setLikeUri] = React.useState(feedInfo.likeUri)
const {hasSession} = useSession() const {hasSession} = useSession()
const {track} = useAnalytics() const {track} = useAnalytics()
@ -555,24 +545,8 @@ function AboutSection({
}, [likeUri, isLiked, feedInfo, likeFeed, unlikeFeed, track, _]) }, [likeUri, isLiked, feedInfo, likeFeed, unlikeFeed, track, _])
return ( return (
<ScrollView <View style={[styles.aboutSectionContainer]}>
ref={scrollElRef} <View style={[a.pt_sm]}>
onScroll={onScroll}
scrollEventThrottle={1}
contentContainerStyle={{
paddingTop: headerHeight,
minHeight: Dimensions.get('window').height * 1.5,
}}>
<View
style={[
{
borderTopWidth: 1,
paddingVertical: 20,
paddingHorizontal: 20,
gap: 12,
},
pal.border,
]}>
{feedInfo.description ? ( {feedInfo.description ? (
<RichText <RichText
testID="listDescription" testID="listDescription"
@ -584,50 +558,34 @@ function AboutSection({
<Trans>No description</Trans> <Trans>No description</Trans>
</Text> </Text>
)} )}
<View style={{flexDirection: 'row', alignItems: 'center', gap: 10}}>
<Button
type="default"
testID="toggleLikeBtn"
accessibilityLabel={_(msg`Like this feed`)}
accessibilityHint=""
disabled={!hasSession || isLikePending || isUnlikePending}
onPress={onToggleLiked}
style={{paddingHorizontal: 10}}>
{isLiked ? (
<HeartIconSolid size={19} style={s.likeColor} />
) : (
<HeartIcon strokeWidth={3} size={19} style={pal.textLight} />
)}
</Button>
{typeof likeCount === 'number' && (
<TextLink
href={makeCustomFeedLink(feedOwnerDid, feedRkey, 'liked-by')}
text={_(
msg`Liked by ${likeCount} ${pluralize(likeCount, 'user')}`,
)}
style={[pal.textLight, s.semiBold]}
/>
)}
</View>
<Text type="md" style={[pal.textLight]} numberOfLines={1}>
{isOwner ? (
<Trans>Created by you</Trans>
) : (
<Trans>
Created by{' '}
<TextLink
text={sanitizeHandle(feedInfo.creatorHandle, '@')}
href={makeProfileLink({
did: feedInfo.creatorDid,
handle: feedInfo.creatorHandle,
})}
style={pal.textLight}
/>
</Trans>
)}
</Text>
</View> </View>
</ScrollView>
<View style={[a.flex_row, a.gap_sm, a.align_center, a.pb_sm]}>
<NewButton
size="small"
variant="solid"
color="secondary"
shape="round"
label={isLiked ? _(msg`Unlike this feed`) : _(msg`Like this feed`)}
testID="toggleLikeBtn"
disabled={!hasSession || isLikePending || isUnlikePending}
onPress={onToggleLiked}>
{isLiked ? (
<HeartFilled size="md" fill={s.likeColor.color} />
) : (
<HeartOutline size="md" fill={t.atoms.text_contrast_medium.color} />
)}
</NewButton>
{typeof likeCount === 'number' && (
<InlineLink
label={_(msg`View users who like this feed`)}
to={makeCustomFeedLink(feedOwnerDid, feedRkey, 'liked-by')}
style={[t.atoms.text_contrast_medium, a.font_bold]}>
{_(msg`Liked by ${likeCount} ${pluralize(likeCount, 'user')}`)}
</InlineLink>
)}
</View>
</View>
) )
} }
@ -647,4 +605,9 @@ const styles = StyleSheet.create({
paddingVertical: 14, paddingVertical: 14,
borderRadius: 6, borderRadius: 6,
}, },
aboutSectionContainer: {
paddingVertical: 4,
paddingHorizontal: 16,
gap: 12,
},
}) })

View File

@ -61,6 +61,8 @@ import {logger} from '#/logger'
import {useAnalytics} from '#/lib/analytics/analytics' import {useAnalytics} from '#/lib/analytics/analytics'
import {listenSoftReset} from '#/state/events' import {listenSoftReset} from '#/state/events'
import {atoms as a, useTheme} from '#/alf' 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_CURATE = ['Posts', 'About']
const SECTION_TITLES_MOD = ['About'] const SECTION_TITLES_MOD = ['About']
@ -234,7 +236,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
const {_} = useLingui() const {_} = useLingui()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const {openModal, closeModal} = useModalControls() const {openModal} = useModalControls()
const listMuteMutation = useListMuteMutation() const listMuteMutation = useListMuteMutation()
const listBlockMutation = useListBlockMutation() const listBlockMutation = useListBlockMutation()
const listDeleteMutation = useListDeleteMutation() const listDeleteMutation = useListDeleteMutation()
@ -251,6 +253,10 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
const {mutate: setSavedFeeds} = useSetSaveFeedsMutation() const {mutate: setSavedFeeds} = useSetSaveFeedsMutation()
const {track} = useAnalytics() const {track} = useAnalytics()
const deleteListPromptControl = useDialogControl()
const subscribeMutePromptControl = useDialogControl()
const subscribeBlockPromptControl = useDialogControl()
const isPinned = preferences?.feeds?.pinned?.includes(list.uri) const isPinned = preferences?.feeds?.pinned?.includes(list.uri)
const isSaved = preferences?.feeds?.saved?.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, _]) }, [list.uri, isPinned, pinFeed, unpinFeed, _])
const onSubscribeMute = useCallback(() => { const onSubscribeMute = useCallback(async () => {
openModal({ try {
name: 'confirm', await listMuteMutation.mutateAsync({uri: list.uri, mute: true})
title: _(msg`Mute these accounts?`), Toast.show(_(msg`List muted`))
message: _( track('Lists:Mute')
msg`Muting is private. Muted accounts can interact with you, but you will not see their posts or receive notifications from them.`, } catch {
), Toast.show(
confirmBtnText: _(msg`Mute this List`), _(
async onPressConfirm() { msg`There was an issue. Please check your internet connection and try again.`,
try { ),
await listMuteMutation.mutateAsync({uri: list.uri, mute: true}) )
Toast.show(_(msg`List muted`)) }
track('Lists:Mute') }, [list, listMuteMutation, track, _])
} catch {
Toast.show(
_(
msg`There was an issue. Please check your internet connection and try again.`,
),
)
}
},
onPressCancel() {
closeModal()
},
})
}, [openModal, closeModal, list, listMuteMutation, track, _])
const onUnsubscribeMute = useCallback(async () => { const onUnsubscribeMute = useCallback(async () => {
try { try {
@ -310,32 +303,19 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
} }
}, [list, listMuteMutation, track, _]) }, [list, listMuteMutation, track, _])
const onSubscribeBlock = useCallback(() => { const onSubscribeBlock = useCallback(async () => {
openModal({ try {
name: 'confirm', await listBlockMutation.mutateAsync({uri: list.uri, block: true})
title: _(msg`Block these accounts?`), Toast.show(_(msg`List blocked`))
message: _( track('Lists:Block')
msg`Blocking is public. Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`, } catch {
), Toast.show(
confirmBtnText: _(msg`Block this List`), _(
async onPressConfirm() { msg`There was an issue. Please check your internet connection and try again.`,
try { ),
await listBlockMutation.mutateAsync({uri: list.uri, block: true}) )
Toast.show(_(msg`List blocked`)) }
track('Lists:Block') }, [list, listBlockMutation, track, _])
} catch {
Toast.show(
_(
msg`There was an issue. Please check your internet connection and try again.`,
),
)
}
},
onPressCancel() {
closeModal()
},
})
}, [openModal, closeModal, list, listBlockMutation, track, _])
const onUnsubscribeBlock = useCallback(async () => { const onUnsubscribeBlock = useCallback(async () => {
try { try {
@ -358,34 +338,26 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
}) })
}, [openModal, list]) }, [openModal, list])
const onPressDelete = useCallback(() => { const onPressDelete = useCallback(async () => {
openModal({ await listDeleteMutation.mutateAsync({uri: list.uri})
name: 'confirm',
title: _(msg`Delete List`),
message: _(msg`Are you sure?`),
async onPressConfirm() {
await listDeleteMutation.mutateAsync({uri: list.uri})
if (isSaved || isPinned) { if (isSaved || isPinned) {
const {saved, pinned} = preferences!.feeds const {saved, pinned} = preferences!.feeds
setSavedFeeds({ setSavedFeeds({
saved: isSaved ? saved.filter(uri => uri !== list.uri) : saved, saved: isSaved ? saved.filter(uri => uri !== list.uri) : saved,
pinned: isPinned ? pinned.filter(uri => uri !== list.uri) : pinned, pinned: isPinned ? pinned.filter(uri => uri !== list.uri) : pinned,
}) })
} }
Toast.show(_(msg`List deleted`)) Toast.show(_(msg`List deleted`))
track('Lists:Delete') track('Lists:Delete')
if (navigation.canGoBack()) { if (navigation.canGoBack()) {
navigation.goBack() navigation.goBack()
} else { } else {
navigation.navigate('Home') navigation.navigate('Home')
} }
},
})
}, [ }, [
openModal,
list, list,
listDeleteMutation, listDeleteMutation,
navigation, navigation,
@ -443,7 +415,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
items.push({ items.push({
testID: 'listHeaderDropdownDeleteBtn', testID: 'listHeaderDropdownDeleteBtn',
label: _(msg`Delete List`), label: _(msg`Delete List`),
onPress: onPressDelete, onPress: deleteListPromptControl.open,
icon: { icon: {
ios: { ios: {
name: 'trash', name: 'trash',
@ -489,7 +461,9 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
items.push({ items.push({
testID: 'listHeaderDropdownMuteBtn', testID: 'listHeaderDropdownMuteBtn',
label: isMuting ? _(msg`Un-mute list`) : _(msg`Mute list`), label: isMuting ? _(msg`Un-mute list`) : _(msg`Mute list`),
onPress: isMuting ? onUnsubscribeMute : onSubscribeMute, onPress: isMuting
? onUnsubscribeMute
: subscribeMutePromptControl.open,
icon: { icon: {
ios: { ios: {
name: isMuting ? 'eye' : 'eye.slash', name: isMuting ? 'eye' : 'eye.slash',
@ -504,7 +478,9 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
items.push({ items.push({
testID: 'listHeaderDropdownBlockBtn', testID: 'listHeaderDropdownBlockBtn',
label: isBlocking ? _(msg`Un-block list`) : _(msg`Block list`), label: isBlocking ? _(msg`Un-block list`) : _(msg`Block list`),
onPress: isBlocking ? onUnsubscribeBlock : onSubscribeBlock, onPress: isBlocking
? onUnsubscribeBlock
: subscribeBlockPromptControl.open,
icon: { icon: {
ios: { ios: {
name: 'person.fill.xmark', name: 'person.fill.xmark',
@ -517,24 +493,24 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
} }
return items return items
}, [ }, [
isOwner,
onPressShare,
onPressEdit,
onPressDelete,
onPressReport,
_, _,
onPressShare,
isOwner,
isModList, isModList,
isPinned, isPinned,
unpinFeed,
isPending,
list.uri,
isCurateList, isCurateList,
isMuting, onPressEdit,
deleteListPromptControl.open,
onPressReport,
isPending,
unpinFeed,
list.uri,
isBlocking, isBlocking,
isMuting,
onUnsubscribeMute, onUnsubscribeMute,
onSubscribeMute, subscribeMutePromptControl.open,
onUnsubscribeBlock, onUnsubscribeBlock,
onSubscribeBlock, subscribeBlockPromptControl.open,
]) ])
const subscribeDropdownItems: DropdownItem[] = useMemo(() => { const subscribeDropdownItems: DropdownItem[] = useMemo(() => {
@ -542,7 +518,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
{ {
testID: 'subscribeDropdownMuteBtn', testID: 'subscribeDropdownMuteBtn',
label: _(msg`Mute accounts`), label: _(msg`Mute accounts`),
onPress: onSubscribeMute, onPress: subscribeMutePromptControl.open,
icon: { icon: {
ios: { ios: {
name: 'speaker.slash', name: 'speaker.slash',
@ -554,7 +530,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
{ {
testID: 'subscribeDropdownBlockBtn', testID: 'subscribeDropdownBlockBtn',
label: _(msg`Block accounts`), label: _(msg`Block accounts`),
onPress: onSubscribeBlock, onPress: subscribeBlockPromptControl.open,
icon: { icon: {
ios: { ios: {
name: 'person.fill.xmark', name: 'person.fill.xmark',
@ -564,7 +540,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
}, },
}, },
] ]
}, [onSubscribeMute, onSubscribeBlock, _]) }, [_, subscribeMutePromptControl.open, subscribeBlockPromptControl.open])
return ( return (
<ProfileSubpageHeader <ProfileSubpageHeader
@ -620,6 +596,38 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
<FontAwesomeIcon icon="ellipsis" size={20} color={pal.colors.text} /> <FontAwesomeIcon icon="ellipsis" size={20} color={pal.colors.text} />
</View> </View>
</NativeDropdown> </NativeDropdown>
<Prompt.Basic
control={deleteListPromptControl}
title={_(msg`Delete this list?`)}
description={_(
msg`If you delete this list, you won't be able to recover it.`,
)}
onConfirm={onPressDelete}
confirmButtonCta={_(msg`Delete`)}
confirmButtonColor="negative"
/>
<Prompt.Basic
control={subscribeMutePromptControl}
title={_(msg`Mute these accounts?`)}
description={_(
msg`Muting is private. Muted accounts can interact with you, but you will not see their posts or receive notifications from them.`,
)}
onConfirm={onSubscribeMute}
confirmButtonCta={_(msg`Mute list`)}
/>
<Prompt.Basic
control={subscribeBlockPromptControl}
title={_(msg`Block these accounts?`)}
description={_(
msg`Blocking is public. Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`,
)}
onConfirm={onSubscribeBlock}
confirmButtonCta={_(msg`Block list`)}
confirmButtonColor="negative"
/>
</ProfileSubpageHeader> </ProfileSubpageHeader>
) )
} }

View File

@ -68,7 +68,7 @@ export function Dialogs() {
</Prompt.Description> </Prompt.Description>
<Prompt.Actions> <Prompt.Actions>
<Prompt.Cancel>Cancel</Prompt.Cancel> <Prompt.Cancel>Cancel</Prompt.Cancel>
<Prompt.Action>Confirm</Prompt.Action> <Prompt.Action onPress={() => {}}>Confirm</Prompt.Action>
</Prompt.Actions> </Prompt.Actions>
</Prompt.Outer> </Prompt.Outer>

View File

@ -16,7 +16,7 @@ export function Menus() {
<View style={[a.gap_md]}> <View style={[a.gap_md]}>
<View style={[a.flex_row, a.align_start]}> <View style={[a.flex_row, a.align_start]}>
<Menu.Root control={menuControl}> <Menu.Root control={menuControl}>
<Menu.Trigger label="Open basic menu" style={[a.flex_1]}> <Menu.Trigger label="Open basic menu">
{({state, props}) => { {({state, props}) => {
return ( return (
<Text <Text

View File

@ -36,6 +36,7 @@ import {Button} from '#/view/com/util/forms/Button'
import {s} from 'lib/styles' import {s} from 'lib/styles'
import {Logo} from '#/view/icons/Logo' import {Logo} from '#/view/icons/Logo'
import {Logotype} from '#/view/icons/Logotype' import {Logotype} from '#/view/icons/Logotype'
import {useDedupe} from 'lib/hooks/useDedupe'
type TabOptions = 'Home' | 'Search' | 'Notifications' | 'MyProfile' | 'Feeds' type TabOptions = 'Home' | 'Search' | 'Notifications' | 'MyProfile' | 'Feeds'
@ -54,6 +55,7 @@ export function BottomBar({navigation}: BottomTabBarProps) {
const {data: profile} = useProfileQuery({did: currentAccount?.did}) const {data: profile} = useProfileQuery({did: currentAccount?.did})
const {requestSwitchToAccount} = useLoggedOutViewControls() const {requestSwitchToAccount} = useLoggedOutViewControls()
const closeAllActiveElements = useCloseAllActiveElements() const closeAllActiveElements = useCloseAllActiveElements()
const dedupe = useDedupe()
const showSignIn = React.useCallback(() => { const showSignIn = React.useCallback(() => {
closeAllActiveElements() closeAllActiveElements()
@ -74,12 +76,12 @@ export function BottomBar({navigation}: BottomTabBarProps) {
if (tabState === TabState.InsideAtRoot) { if (tabState === TabState.InsideAtRoot) {
emitSoftReset() emitSoftReset()
} else if (tabState === TabState.Inside) { } else if (tabState === TabState.Inside) {
navigation.dispatch(StackActions.popToTop()) dedupe(() => navigation.dispatch(StackActions.popToTop()))
} else { } else {
navigation.navigate(`${tab}Tab`) dedupe(() => navigation.navigate(`${tab}Tab`))
} }
}, },
[track, navigation], [track, navigation, dedupe],
) )
const onPressHome = React.useCallback(() => onPressTab('Home'), [onPressTab]) const onPressHome = React.useCallback(() => onPressTab('Home'), [onPressTab])
const onPressSearch = React.useCallback( const onPressSearch = React.useCallback(

View File

@ -9475,9 +9475,9 @@ caniuse-api@^3.0.0:
lodash.uniq "^4.5.0" lodash.uniq "^4.5.0"
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001517, caniuse-lite@^1.0.30001520: caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001517, caniuse-lite@^1.0.30001520:
version "1.0.30001522" version "1.0.30001596"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001522.tgz#44b87a406c901269adcdb834713e23582dd71856" resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001596.tgz"
integrity sha512-TKiyTVZxJGhsTszLuzb+6vUZSjVOAhClszBr2Ta2k9IwtNBT/4dzmL6aywt0HCgEZlmwJzXJd8yNiob6HgwTRg== integrity sha512-zpkZ+kEr6We7w63ORkoJ2pOfBwBkY/bJrG/UZ90qNb45Isblu8wzDgevEOrRL1r9dWayHjYiiyCMEXPn4DweGQ==
case-anything@^2.1.13: case-anything@^2.1.13:
version "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" resolved "https://registry.yarnpkg.com/js-sha256/-/js-sha256-0.10.1.tgz#b40104ba1368e823fdd5f41b66b104b15a0da60d"
integrity sha512-5obBtsz9301ULlsgggLg542s/jqtddfOpV5KJc4hajc9JV8GeY2gZHSVpYBn4nWqAUTJ9v+xwtbJ1mIBgIH5Vw== 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: js-sha256@^0.9.0:
version "0.9.0" version "0.9.0"
resolved "https://registry.yarnpkg.com/js-sha256/-/js-sha256-0.9.0.tgz#0b89ac166583e91ef9123644bd3c5334ce9d0966" 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" js-sha256 "^0.10.1"
uuid "^8.3.2" 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: statsig-react-native-expo@^4.6.1:
version "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" 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: dependencies:
statsig-js "4.45.1" 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: statuses@2.0.1:
version "2.0.1" version "2.0.1"
resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63"