Merge branch 'main' into 3p-moderators

This commit is contained in:
Paul Frazee
2024-03-12 18:44:05 -07:00
72 changed files with 5138 additions and 4001 deletions
+3 -1
View File
@@ -40,6 +40,8 @@ module.exports = function (config) {
? process.env.BSKY_ANDROID_VERSION_CODE ? process.env.BSKY_ANDROID_VERSION_CODE
: process.env.BSKY_IOS_BUILD_NUMBER : process.env.BSKY_IOS_BUILD_NUMBER
const IS_DEV = process.env.EXPO_PUBLIC_ENV === 'development'
return { return {
expo: { expo: {
version: VERSION, version: VERSION,
@@ -103,7 +105,7 @@ module.exports = function (config) {
scheme: 'https', scheme: 'https',
host: 'bsky.app', host: 'bsky.app',
}, },
{ IS_DEV && {
scheme: 'http', scheme: 'http',
host: 'localhost:19006', host: 'localhost:19006',
}, },
@@ -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

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

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

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

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

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

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

-1
View File
@@ -181,7 +181,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",
+8 -1
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',
@@ -114,6 +115,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',
}, },
+3 -1
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
+1
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
+7 -8
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},
+2 -3
View File
@@ -8,9 +8,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,
@@ -142,7 +141,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()
+32 -3
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
} }
+64 -18
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,18 +123,9 @@ export function Trigger({children, label, style}: TriggerProps) {
return ( return (
<DropdownMenu.Trigger asChild> <DropdownMenu.Trigger asChild>
<Pressable <RadixTriggerPassThrough>
accessibilityHint="" {props =>
accessibilityLabel={label} children({
onFocus={onFocus}
onBlur={onBlur}
style={flatten([style])}
onPointerDown={() => control.open()}
{...web({
onMouseEnter,
onMouseLeave,
})}>
{children({
isNative: false, isNative: false,
control, control,
state: { state: {
@@ -106,14 +133,32 @@ export function Trigger({children, label, style}: TriggerProps) {
focused, focused,
pressed: false, pressed: false,
}, },
props: {}, props: {
})} ...props,
</Pressable> // disable on web, use `onPress`
onPointerDown: () => false,
onPress: () =>
control.isOpen ? control.close() : control.open(),
onFocus: onFocus,
onBlur: onBlur,
onMouseEnter,
onMouseLeave,
accessibilityLabel: label,
},
})
}
</RadixTriggerPassThrough>
</DropdownMenu.Trigger> </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>
+30 -3
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<
+77 -14
View File
@@ -1,11 +1,11 @@
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 {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 +22,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 +37,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 +82,9 @@ 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, a.pt_md, a.pb_4xl],
]}> ]}>
{children} {children}
</View> </View>
@@ -89,18 +93,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 +123,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>
)
}
+4 -4
View File
@@ -8,7 +8,7 @@ import {
import {UITextView} from 'react-native-ui-text-view' import {UITextView} from 'react-native-ui-text-view'
import {useTheme, atoms, web, flatten} from '#/alf' import {useTheme, atoms, web, flatten} from '#/alf'
import {isIOS} from '#/platform/detection' import {isIOS, isNative} from '#/platform/detection'
export type TextProps = RNTextProps & { export type TextProps = RNTextProps & {
/** /**
@@ -45,11 +45,11 @@ export function normalizeTextStyles(styles: StyleProp<TextStyle>) {
const fontSize = s.fontSize || atoms.text_md.fontSize const fontSize = s.fontSize || atoms.text_md.fontSize
if (s?.lineHeight) { if (s?.lineHeight) {
if (s.lineHeight <= 2) { if (s.lineHeight !== 0 && s.lineHeight <= 2) {
s.lineHeight = Math.round(fontSize * s.lineHeight) s.lineHeight = Math.round(fontSize * s.lineHeight)
} }
} else { } else if (!isNative) {
s.lineHeight = fontSize s.lineHeight = s.fontSize
} }
return s return s
+11 -24
View File
@@ -254,9 +254,9 @@ function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) {
</View> </View>
{isNative && <View style={{height: 20}} />} {isNative && <View style={{height: 20}} />}
</View>
<Dialog.Close /> <Dialog.Close />
</View>
</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={[
+9
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',
})
+2 -4
View File
@@ -1,7 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE' import {createSinglePathSVG} from './TEMPLATE'
export const DotGrid1x3Horizontal_Stroke2_Corner0_Rounded = createSinglePathSVG( 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', 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',
}, })
)
+5
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',
})
+5
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',
})
+5
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',
})
+5
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',
})
+5
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',
})
+17
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
}
+80
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],
)
}
+3 -25
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,16 +16,13 @@ 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', {
@@ -53,11 +34,8 @@ export function useOTAUpdate() {
} 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
+1
View File
@@ -13,6 +13,7 @@ interface ReportOptions {
account: ReportOption[] account: ReportOption[]
post: ReportOption[] post: ReportOption[]
list: ReportOption[] list: ReportOption[]
feedgen: ReportOption[]
other: ReportOption[] other: ReportOption[]
} }
+2 -2
View File
@@ -12,9 +12,9 @@ import {Share} from 'react-native'
*/ */
export async function shareUrl(url: string) { export async function shareUrl(url: string) {
if (isAndroid) { if (isAndroid) {
Share.share({message: url}) await Share.share({message: url})
} else if (isIOS) { } else if (isIOS) {
Share.share({url}) await Share.share({url})
} else { } else {
// React Native Share is not supported by web. Web Share API // React Native Share is not supported by web. Web Share API
// has increasing but not full support, so default to clipboard // has increasing but not full support, so default to clipboard
-75
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>
)
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -238,13 +238,14 @@ function FeedCardInner({feed}: {feed: FeedSourceInfo; config: FeedConfig}) {
/> />
</View> </View>
<View style={[a.pt_2xs, a.flex_grow]}> <View style={[a.pt_2xs, a.flex_1, a.flex_grow]}>
<Text <Text
style={[ style={[
a.text_md, a.text_md,
a.font_bold, a.font_bold,
ctx.selected && styles.textSelected, ctx.selected && styles.textSelected,
]}> ]}
numberOfLines={1}>
{feed.displayName} {feed.displayName}
</Text> </Text>
<Text <Text
-290
View File
@@ -1,290 +0,0 @@
import React from 'react'
import {View} from 'react-native'
import {useQueryClient} from '@tanstack/react-query'
import {AppBskyActorDefs} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {isWeb} from 'platform/detection'
import {useModalControls} from '#/state/modals'
import {
RQKEY as profileQueryKey,
useProfileMuteMutationQueue,
useProfileBlockMutationQueue,
} from '#/state/queries/profile'
import {useAnalytics} from 'lib/analytics/analytics'
import {makeProfileLink} from 'lib/routes/links'
import {toShareUrl} from 'lib/strings/url-helpers'
import {shareUrl} from 'lib/sharing'
import {logger} from '#/logger'
import {useSession} from '#/state/session'
import {Shadow} from '#/state/cache/types'
import {atoms as a, useTheme} from '#/alf'
import * as Toast from 'view/com/util/Toast'
import {NativeDropdown, DropdownItem} from 'view/com/util/forms/NativeDropdown'
import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog'
import {DotGrid1x3Horizontal_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid'
export function ProfileHeaderDropdownBtn({
profile,
}: {
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
}) {
const t = useTheme()
const {currentAccount, hasSession} = useSession()
const {_} = useLingui()
const {openModal} = useModalControls()
const {track} = useAnalytics()
const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile)
const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
const queryClient = useQueryClient()
const reportDialogControl = useReportDialogControl()
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 () => {
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')
openModal({
name: 'confirm',
title: _(msg`Unblock Account`),
message: _(
msg`The account will be able to interact with you after unblocking.`,
),
onPressConfirm: async () => {
try {
await queueUnblock()
Toast.show(_(msg`Account unblocked`))
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('Failed to unblock account', {message: e})
Toast.show(_(msg`There was an issue! ${e.toString()}`))
}
}
},
})
}, [track, queueUnblock, openModal, _])
const onPressReportAccount = React.useCallback(() => {
track('ProfileHeader:ReportAccountButtonClicked')
reportDialogControl.open()
}, [track, reportDialogControl])
const isMe = React.useMemo(
() => currentAccount?.did === profile.did,
[currentAccount, profile],
)
const dropdownItems: DropdownItem[] = React.useMemo(() => {
let items: DropdownItem[] = []
if (hasSession && profile.associated?.labeler) {
items.push({
testID: 'profileHeaderDropdownFollowBtn',
label: _(msg`Follow Account`),
onPress: () => {}, // TODO
icon: {
ios: {
name: 'plus',
},
android: '',
web: 'plus',
},
})
}
items.push({
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.associated?.labeler,
profile.viewer?.muted,
profile.viewer?.mutedByList,
profile.viewer?.blocking,
profile.viewer?.blockingByList,
onPressShare,
onPressUnmuteAccount,
onPressMuteAccount,
onPressUnblockAccount,
onPressBlockAccount,
onPressReportAccount,
onPressAddRemoveLists,
_,
])
return dropdownItems?.length ? (
<>
<ReportDialog
control={reportDialogControl}
params={{type: 'account', did: profile.did}}
/>
<NativeDropdown
testID="profileHeaderDropdownBtn"
items={dropdownItems}
accessibilityLabel={_(msg`More options`)}
accessibilityHint="">
<View
style={[
{
height: 40,
width: 40,
},
t.atoms.bg_contrast_25,
a.flex_row,
a.align_center,
a.justify_center,
a.rounded_full,
]}>
<Ellipsis width={20} fill={t.atoms.text_contrast_medium.color} />
</View>
</NativeDropdown>
</>
) : null
}
@@ -29,7 +29,7 @@ import {Button, ButtonText} from '#/components/Button'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
import {ProfileHeaderShell} from './Shell' import {ProfileHeaderShell} from './Shell'
import {ProfileHeaderDropdownBtn} from './DropdownBtn' import {ProfileMenu} from '#/view/com/profile/ProfileMenu'
import {ProfileHeaderDisplayName} from './DisplayName' import {ProfileHeaderDisplayName} from './DisplayName'
import {ProfileHeaderHandle} from './Handle' import {ProfileHeaderHandle} from './Handle'
import {ProfileHeaderMetrics} from './Metrics' import {ProfileHeaderMetrics} from './Metrics'
@@ -200,7 +200,7 @@ let ProfileHeaderLabeler = ({
</Button> </Button>
</> </>
) : null} ) : null}
<ProfileHeaderDropdownBtn profile={profile} /> <ProfileMenu profile={profile} />
</View> </View>
<View style={[a.flex_col, a.gap_xs, a.pb_md]}> <View style={[a.flex_col, a.gap_xs, a.pb_md]}>
<ProfileHeaderDisplayName profile={profile} moderation={moderation} /> <ProfileHeaderDisplayName profile={profile} moderation={moderation} />
@@ -26,12 +26,13 @@ import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText, ButtonIcon} from '#/components/Button' import {Button, ButtonText, ButtonIcon} from '#/components/Button'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
import {ProfileHeaderShell} from './Shell' import {ProfileHeaderShell} from './Shell'
import {ProfileHeaderDropdownBtn} from './DropdownBtn' import {ProfileMenu} from '#/view/com/profile/ProfileMenu'
import {ProfileHeaderDisplayName} from './DisplayName' import {ProfileHeaderDisplayName} from './DisplayName'
import {ProfileHeaderHandle} from './Handle' import {ProfileHeaderHandle} from './Handle'
import {ProfileHeaderMetrics} from './Metrics' import {ProfileHeaderMetrics} from './Metrics'
import {ProfileHeaderSuggestedFollows} from '#/view/com/profile/ProfileHeaderSuggestedFollows' import {ProfileHeaderSuggestedFollows} from '#/view/com/profile/ProfileHeaderSuggestedFollows'
import {RichText} from '#/components/RichText' import {RichText} from '#/components/RichText'
import * as Prompt from '#/components/Prompt'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
@@ -64,6 +65,7 @@ let ProfileHeaderStandard = ({
const [showSuggestedFollows, setShowSuggestedFollows] = React.useState(false) const [showSuggestedFollows, setShowSuggestedFollows] = React.useState(false)
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile) const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile)
const [_queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) const [_queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
const unblockPromptControl = Prompt.usePromptControl()
const requireAuth = useRequireAuth() const requireAuth = useRequireAuth()
const onPressEditProfile = React.useCallback(() => { const onPressEditProfile = React.useCallback(() => {
@@ -118,15 +120,8 @@ let ProfileHeaderStandard = ({
}) })
} }
const onPressUnblockAccount = React.useCallback(async () => { const unblockAccount = React.useCallback(async () => {
track('ProfileHeader:UnblockAccountButtonClicked') track('ProfileHeader:UnblockAccountButtonClicked')
openModal({
name: 'confirm',
title: _(msg`Unblock Account`),
message: _(
msg`The account will be able to interact with you after unblocking.`,
),
onPressConfirm: async () => {
try { try {
await queueUnblock() await queueUnblock()
Toast.show(_(msg`Account unblocked`)) Toast.show(_(msg`Account unblocked`))
@@ -136,9 +131,7 @@ let ProfileHeaderStandard = ({
Toast.show(_(msg`There was an issue! ${e.toString()}`)) Toast.show(_(msg`There was an issue! ${e.toString()}`))
} }
} }
}, }, [_, queueUnblock, track])
})
}, [track, queueUnblock, openModal, _])
const isMe = React.useMemo( const isMe = React.useMemo(
() => currentAccount?.did === profile.did, () => currentAccount?.did === profile.did,
@@ -177,7 +170,7 @@ let ProfileHeaderStandard = ({
variant="solid" variant="solid"
label={_(msg`Unblock`)} label={_(msg`Unblock`)}
disabled={!hasSession} disabled={!hasSession}
onPress={onPressUnblockAccount} onPress={() => unblockPromptControl.open()}
style={a.rounded_full}> style={a.rounded_full}>
<ButtonText> <ButtonText>
<Trans context="action">Unblock</Trans> <Trans context="action">Unblock</Trans>
@@ -236,7 +229,7 @@ let ProfileHeaderStandard = ({
</Button> </Button>
</> </>
) : null} ) : null}
<ProfileHeaderDropdownBtn profile={profile} /> <ProfileMenu profile={profile} />
</View> </View>
<View style={[a.flex_col, a.gap_xs, a.pb_sm]}> <View style={[a.flex_col, a.gap_xs, a.pb_sm]}>
<ProfileHeaderDisplayName profile={profile} moderation={moderation} /> <ProfileHeaderDisplayName profile={profile} moderation={moderation} />
@@ -271,6 +264,18 @@ let ProfileHeaderStandard = ({
}} }}
/> />
)} )}
<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"
/>
</ProfileHeaderShell> </ProfileHeaderShell>
) )
} }
-13
View File
@@ -1,6 +1,5 @@
import React from 'react' import React from 'react'
import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api' import {AppBskyActorDefs, AppBskyGraphDefs} 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
@@ -189,7 +177,6 @@ export type Modal =
| InviteCodesModal | InviteCodesModal
// Generic // Generic
| ConfirmModal
| LinkWarningModal | LinkWarningModal
| EmbedConsentModal | EmbedConsentModal
| InAppBrowserConsentModal | InAppBrowserConsentModal
+25 -18
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>
) )
}) })
+18 -1
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) {
+47 -46
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,27 +96,9 @@ export function FeedSourceCardLoaded({
const isSaved = Boolean(preferences?.feeds?.saved?.includes(feed?.uri || '')) const isSaved = Boolean(preferences?.feeds?.saved?.includes(feed?.uri || ''))
const onToggleSaved = React.useCallback(async () => { const onSave = React.useCallback(async () => {
// Only feeds can be un/saved, lists are handled elsewhere if (!feed) return
if (feed?.type !== 'feed') return
if (isSaved) {
openModal({
name: 'confirm',
title: _(msg`Remove from my feeds`),
message: _(msg`Remove ${feed?.displayName} from my feeds?`),
onPressConfirm: async () => {
try {
await removeFeed({uri: feed.uri})
// await item.unsave()
Toast.show(_(msg`Removed from my feeds`))
} catch (e) {
Toast.show(_(msg`There was an issue contacting your server`))
logger.error('Failed to unsave feed', {message: e})
}
},
})
} else {
try { try {
if (pinOnSave) { if (pinOnSave) {
await pinFeed({uri: feed.uri}) await pinFeed({uri: feed.uri})
@@ -129,8 +110,31 @@ export function FeedSourceCardLoaded({
Toast.show(_(msg`There was an issue contacting your server`)) Toast.show(_(msg`There was an issue contacting your server`))
logger.error('Failed to save feed', {message: e}) 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})
} }
}, [isSaved, openModal, feed, removeFeed, saveFeed, _, pinOnSave, pinFeed]) }, [_, feed, removeFeed])
const onToggleSaved = React.useCallback(async () => {
// Only feeds can be un/saved, lists are handled elsewhere
if (feed?.type !== 'feed') return
if (isSaved) {
removePromptControl.open()
} else {
await onSave()
}
}, [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,6 +186,7 @@ export function FeedSourceCardLoaded({
) )
return ( return (
<>
<Pressable <Pressable
testID={`feed-${feed.displayName}`} testID={`feed-${feed.displayName}`}
accessibilityRole="button" accessibilityRole="button"
@@ -242,7 +229,9 @@ export function FeedSourceCardLoaded({
disabled={isSavePending || isPinPending || isRemovePending} disabled={isSavePending || isPinPending || isRemovePending}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={ accessibilityLabel={
isSaved ? _(msg`Remove from my feeds`) : _(msg`Add to my feeds`) isSaved
? _(msg`Remove from my feeds`)
: _(msg`Add to my feeds`)
} }
accessibilityHint="" accessibilityHint=""
onPress={onToggleSaved} onPress={onToggleSaved}
@@ -283,6 +272,18 @@ export function FeedSourceCardLoaded({
</Text> </Text>
) : null} ) : null}
</Pressable> </Pressable>
<Prompt.Basic
control={removePromptControl}
title={_(msg`Remove from my feeds?`)}
description={_(
msg`Are you sure you want to remove ${feed.displayName} from your feeds?`,
)}
onConfirm={onUnsave}
confirmButtonCta={_(msg`Remove`)}
confirmButtonColor="negative"
/>
</>
) )
} }
-132
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,
},
})
+1 -5
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'
@@ -62,10 +61,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 === 'create-or-edit-list') { } else if (activeModal?.name === 'create-or-edit-list') {
+1 -4
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 CreateOrEditListModal from './CreateOrEditList' import * as CreateOrEditListModal from './CreateOrEditList'
import * as UserAddRemoveLists from './UserAddRemoveLists' import * as UserAddRemoveLists from './UserAddRemoveLists'
@@ -74,9 +73,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 === 'create-or-edit-list') { } else if (modal.name === 'create-or-edit-list') {
element = <CreateOrEditListModal.Component {...modal} /> element = <CreateOrEditListModal.Component {...modal} />
+18 -13
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,19 +118,18 @@ 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({
name: 'confirm',
title: _l(msgLingui`Remove feed`),
message: _l(msgLingui`Remove this feed from your saved feeds?`),
async onPressConfirm() {
try { try {
await removeFeed({uri}) await removeFeed({uri})
} catch (err) { } catch (err) {
@@ -141,12 +140,7 @@ function FeedgenErrorMessage({
) )
logger.error('Failed to remove feed', {message: err}) logger.error('Failed to remove feed', {message: err})
} }
}, }, [uri, removeFeed, _l])
onPressCancel() {
closeModal()
},
})
}, [openModal, closeModal, uri, removeFeed, _l])
const cta = React.useMemo(() => { const cta = React.useMemo(() => {
switch (knownError) { switch (knownError) {
@@ -179,6 +173,7 @@ function FeedgenErrorMessage({
}, [knownError, onViewProfile, onRemoveFeed, _l]) }, [knownError, onViewProfile, onRemoveFeed, _l])
return ( return (
<>
<View <View
style={[ style={[
pal.border, pal.border,
@@ -200,6 +195,16 @@ function FeedgenErrorMessage({
{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"
/>
</>
) )
} }
+344
View File
@@ -0,0 +1,344 @@
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 {ReportDialog, useReportDialogControl} from '#/components/ReportDialog'
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 {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
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 reportDialogControl = useReportDialogControl()
const queryClient = useQueryClient()
const isSelf = currentAccount?.did === profile.did
const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile)
const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
const [queueFollow, 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 onPressFollowAccount = React.useCallback(async () => {
track('ProfileHeader:FollowButtonClicked')
try {
await queueFollow()
Toast.show(_(msg`Account followed`))
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('Failed to follow account', {message: e})
Toast.show(_(msg`There was an issue! ${e.toString()}`))
}
}
}, [_, queueFollow, track])
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')
reportDialogControl.open()
}, [track, reportDialogControl])
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: 10,
borderRadius: 50,
paddingHorizontal: 16,
},
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>
{!!profile.associated?.labeler &&
!(profile.viewer?.blocking || profile.viewer?.blockedBy) && (
<Menu.Item
testID="profileHeaderDropdownFollowBtn"
label={_(msg`Follow Account`)}
onPress={onPressFollowAccount}>
<Menu.ItemText>
<Trans>Follow Account</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Plus} />
</Menu.Item>
)}
<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>
<ReportDialog
control={reportDialogControl}
params={{type: 'account', did: profile.did}}
/>
<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="negative"
/>
</EventStopper>
)
}
ProfileMenu = memo(ProfileMenu)
export {ProfileMenu}
+8 -9
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>
@@ -67,7 +66,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()
@@ -179,7 +178,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()
@@ -341,7 +340,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,
+78 -72
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' | 'labeler' export type UserAvatarType = 'user' | 'algo' | 'list' | 'labeler'
@@ -219,6 +227,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()
@@ -239,20 +248,7 @@ let EditableUserAvatar = ({
} }
}, [type, size]) }, [type, size])
const dropdownItems = useMemo( const onOpenCamera = React.useCallback(async () => {
() =>
[
!isWeb && {
testID: 'changeAvatarCameraBtn',
label: _(msg`Camera`),
icon: {
ios: {
name: 'camera',
},
android: 'ic_menu_camera',
web: 'camera',
},
onPress: async () => {
if (!(await requestCameraAccessIfNeeded())) { if (!(await requestCameraAccessIfNeeded())) {
return return
} }
@@ -264,19 +260,9 @@ let EditableUserAvatar = ({
cropperCircleOverlay: true, cropperCircleOverlay: true,
}), }),
) )
}, }, [onSelectNewAvatar, requestCameraAccessIfNeeded])
},
{ const onOpenLibrary = React.useCallback(async () => {
testID: 'changeAvatarLibraryBtn',
label: _(msg`Library`),
icon: {
ios: {
name: 'photo.on.rectangle.angled',
},
android: 'ic_menu_gallery',
web: 'gallery',
},
onPress: async () => {
if (!(await requestPhotoAccessIfNeeded())) { if (!(await requestPhotoAccessIfNeeded())) {
return return
} }
@@ -298,41 +284,17 @@ let EditableUserAvatar = ({
}) })
onSelectNewAvatar(croppedImage) onSelectNewAvatar(croppedImage)
}, }, [onSelectNewAvatar, requestPhotoAccessIfNeeded])
},
!!avatar && { const onRemoveAvatar = React.useCallback(() => {
label: 'separator',
},
!!avatar && {
testID: 'changeAvatarRemoveBtn',
label: _(msg`Remove`),
icon: {
ios: {
name: 'trash',
},
android: 'ic_delete',
web: ['far', 'trash-can'],
},
onPress: async () => {
onSelectNewAvatar(null) onSelectNewAvatar(null)
}, }, [onSelectNewAvatar])
},
].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"
@@ -344,13 +306,57 @@ let EditableUserAvatar = ({
<DefaultAvatar type={type} size={size} /> <DefaultAvatar type={type} size={size} />
)} )}
<View style={[styles.editButtonContainer, pal.btn]}> <View style={[styles.editButtonContainer, pal.btn]}>
<FontAwesomeIcon <CameraFilled height={14} width={14} style={t.atoms.text} />
icon="camera"
size={12}
color={pal.text.color as string}
/>
</View> </View>
</NativeDropdown> </TouchableOpacity>
)}
</Menu.Trigger>
<Menu.Outer showCancel>
<Menu.Group>
{isNative && (
<Menu.Item
testID="changeAvatarCameraBtn"
label={_(msg`Upload from Camera`)}
onPress={onOpenCamera}>
<Menu.ItemText>
<Trans>Upload from Camera</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Camera} />
</Menu.Item>
)}
<Menu.Item
testID="changeAvatarLibraryBtn"
label={_(msg`Upload from Library`)}
onPress={onOpenLibrary}>
<Menu.ItemText>
{isNative ? (
<Trans>Upload from Library</Trans>
) : (
<Trans>Upload from Files</Trans>
)}
</Menu.ItemText>
<Menu.ItemIcon icon={Library} />
</Menu.Item>
</Menu.Group>
{!!avatar && (
<>
<Menu.Divider />
<Menu.Group>
<Menu.Item
testID="changeAvatarRemoveBtn"
label={_(`Remove Avatar`)}
onPress={onRemoveAvatar}>
<Menu.ItemText>
<Trans>Remove Avatar</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Trash} />
</Menu.Item>
</Menu.Group>
</>
)}
</Menu.Outer>
</Menu.Root>
) )
} }
EditableUserAvatar = memo(EditableUserAvatar) EditableUserAvatar = memo(EditableUserAvatar)
+79 -69
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,24 +36,12 @@ 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 () => {
() =>
[
!isWeb && {
testID: 'changeBannerCameraBtn',
label: _(msg`Camera`),
icon: {
ios: {
name: 'camera',
},
android: 'ic_menu_camera',
web: 'camera',
},
onPress: async () => {
if (!(await requestCameraAccessIfNeeded())) { if (!(await requestCameraAccessIfNeeded())) {
return return
} }
@@ -55,19 +51,9 @@ export function UserBanner({
height: 1000, height: 1000,
}), }),
) )
}, }, [onSelectNewBanner, requestCameraAccessIfNeeded])
},
{ const onOpenLibrary = React.useCallback(async () => {
testID: 'changeBannerLibraryBtn',
label: _(msg`Library`),
icon: {
ios: {
name: 'photo.on.rectangle.angled',
},
android: 'ic_menu_gallery',
web: 'gallery',
},
onPress: async () => {
if (!(await requestPhotoAccessIfNeeded())) { if (!(await requestPhotoAccessIfNeeded())) {
return return
} }
@@ -84,39 +70,19 @@ export function UserBanner({
height: 1000, height: 1000,
}), }),
) )
}, }, [onSelectNewBanner, requestPhotoAccessIfNeeded])
},
!!banner && { const onRemoveBanner = React.useCallback(() => {
testID: 'changeBannerRemoveBtn',
label: _(msg`Remove`),
icon: {
ios: {
name: 'trash',
},
android: 'ic_delete',
web: ['far', 'trash-can'],
},
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"
@@ -132,14 +98,58 @@ export function UserBanner({
/> />
)} )}
<View style={[styles.editButtonContainer, pal.btn]}> <View style={[styles.editButtonContainer, pal.btn]}>
<FontAwesomeIcon <CameraFilled height={14} width={14} style={t.atoms.text} />
icon="camera"
size={12}
style={{color: colors.white}}
color={pal.text.color as string}
/>
</View> </View>
</NativeDropdown> </TouchableOpacity>
)}
</Menu.Trigger>
<Menu.Outer showCancel>
<Menu.Group>
{isNative && (
<Menu.Item
testID="changeBannerCameraBtn"
label={_(msg`Upload from Camera`)}
onPress={onOpenCamera}>
<Menu.ItemText>
<Trans>Upload from Camera</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Camera} />
</Menu.Item>
)}
<Menu.Item
testID="changeBannerLibraryBtn"
label={_(msg`Upload from Library`)}
onPress={onOpenLibrary}>
<Menu.ItemText>
{isNative ? (
<Trans>Upload from Library</Trans>
) : (
<Trans>Upload from Files</Trans>
)}
</Menu.ItemText>
<Menu.ItemIcon icon={Library} />
</Menu.Item>
</Menu.Group>
{!!banner && (
<>
<Menu.Divider />
<Menu.Group>
<Menu.Item
testID="changeBannerRemoveBtn"
label={_(`Remove Banner`)}
onPress={onRemoveBanner}>
<Menu.ItemText>
<Trans>Remove Banner</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Trash} />
</Menu.Item>
</Menu.Group>
</>
)}
</Menu.Outer>
</Menu.Root>
</EventStopper>
) : banner && ) : banner &&
!((moderation?.blur && isAndroid) /* android crashes with blur */) ? ( !((moderation?.blur && isAndroid) /* android crashes with blur */) ? (
<Image <Image
+46 -59
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,7 +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 {useModalControls} from '#/state/modals' import {useDialogControl} from '#/components/Dialog'
import * as Prompt from '#/components/Prompt'
import {makeProfileLink} from '#/lib/routes/links' import {makeProfileLink} from '#/lib/routes/links'
import {CommonNavigatorParams} from '#/lib/routes/types' import {CommonNavigatorParams} from '#/lib/routes/types'
import {getCurrentRoute} from 'lib/routes/helpers' import {getCurrentRoute} from 'lib/routes/helpers'
@@ -36,8 +31,8 @@ import {useLingui} from '@lingui/react'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {isWeb} from '#/platform/detection' import {isWeb} from '#/platform/detection'
import {richTextToString} from '#/lib/strings/rich-text-helpers' import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog'
import {atoms as a, useTheme as useAlf} from '#/alf' import {atoms as a, useTheme as useAlf} from '#/alf'
import * as Menu from '#/components/Menu' import * as Menu from '#/components/Menu'
@@ -75,7 +70,6 @@ let PostDropdownBtn = ({
const alf = useAlf() const alf = useAlf()
const {_} = useLingui() const {_} = useLingui()
const defaultCtrlColor = theme.palette.default.postCtrl const defaultCtrlColor = theme.palette.default.postCtrl
const {openModal} = useModalControls()
const langPrefs = useLanguagePrefs() const langPrefs = useLanguagePrefs()
const mutedThreads = useMutedThreads() const mutedThreads = useMutedThreads()
const toggleThreadMute = useToggleThreadMute() const toggleThreadMute = useToggleThreadMute()
@@ -83,9 +77,11 @@ let PostDropdownBtn = ({
const hiddenPosts = useHiddenPosts() const hiddenPosts = useHiddenPosts()
const {hidePost} = useHiddenPostsApi() const {hidePost} = useHiddenPostsApi()
const openLink = useOpenLink() const openLink = useOpenLink()
const reportDialogControl = useReportDialogControl()
const navigation = useNavigation() const navigation = useNavigation()
const {mutedWordsDialogControl} = useGlobalDialogsControlContext() const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
const reportDialogControl = useReportDialogControl()
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)
@@ -169,40 +165,22 @@ let PostDropdownBtn = ({
}, [postUri, hidePost]) }, [postUri, hidePost])
return ( return (
<>
<ReportDialog
control={reportDialogControl}
params={{
type: 'post',
uri: postUri,
cid: postCid,
}}
/>
<EventStopper onKeyDown={false}> <EventStopper onKeyDown={false}>
<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.pressed) && [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}
@@ -254,9 +232,7 @@ let PostDropdownBtn = ({
<Menu.Item <Menu.Item
testID="postDropdownMuteThreadBtn" testID="postDropdownMuteThreadBtn"
label={ label={
isThreadMuted isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`)
? _(msg`Unmute thread`)
: _(msg`Mute thread`)
} }
onPress={onToggleThreadMute}> onPress={onToggleThreadMute}>
<Menu.ItemText> <Menu.ItemText>
@@ -282,16 +258,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>
@@ -317,14 +284,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>
@@ -332,8 +292,35 @@ 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`)}
/>
<ReportDialog
control={reportDialogControl}
params={{
type: 'post',
uri: postUri,
cid: postCid,
}}
/>
</EventStopper> </EventStopper>
</>
) )
} }
@@ -57,6 +57,7 @@ const styles = StyleSheet.create({
btn: { btn: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'center', justifyContent: 'center',
flexGrow: 1,
borderWidth: 1, borderWidth: 1,
borderLeftWidth: 0, borderLeftWidth: 0,
paddingHorizontal: 10, paddingHorizontal: 10,
@@ -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)',
}, },
+20 -12
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({
name: 'confirm',
title: _(msg`Delete app password`),
message: _(
msg`Are you sure you want to delete the app password "${name}"?`,
),
async onPressConfirm() {
await deleteMutation.mutateAsync({name}) await deleteMutation.mutateAsync({name})
Toast.show(_(msg`App password deleted`)) Toast.show(_(msg`App password deleted`))
}, }, [deleteMutation, name, _])
})
}, [deleteMutation, openModal, name, _]) const onPress = React.useCallback(() => {
control.open()
}, [control])
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>
) )
} }
+137 -174
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 reportDialogControl = useReportDialogControl() const reportDialogControl = useReportDialogControl()
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(
@@ -258,69 +265,9 @@ 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 <ProfileSubpageHeader
isLoading={false} isLoading={false}
href={feedInfo.route.href} href={feedInfo.route.href}
@@ -333,55 +280,117 @@ export function ProfileFeedScreenInner({
: undefined : undefined
} }
avatarType="algo"> avatarType="algo">
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
{feedInfo && hasSession && ( {feedInfo && hasSession && (
<> <NewButton
<Button
disabled={isSavePending || isRemovePending}
type="default"
label={isSaved ? _(msg`Unsave`) : _(msg`Save`)}
onPress={onToggleSaved}
style={styles.btn}
/>
<Button
testID={isPinned ? 'unpinBtn' : 'pinBtn'} testID={isPinned ? 'unpinBtn' : 'pinBtn'}
disabled={isPinPending || isUnpinPending} disabled={isPinPending || isUnpinPending}
type={isPinned ? 'default' : 'inverted'} size="small"
label={isPinned ? _(msg`Unpin`) : _(msg`Pin to home`)} variant="solid"
onPress={onTogglePinned} color={isPinned ? 'secondary' : 'primary'}
style={styles.btn} label={isPinned ? _(msg`Unpin from home`) : _(msg`Pin to home`)}
onPress={onTogglePinned}>
<ButtonText>
{isPinned ? _(msg`Unpin`) : _(msg`Pin to Home`)}
</ButtonText>
</NewButton>
)}
<Menu.Root>
<Menu.Trigger label={_(msg`Open feed options menu`)}>
{({props, state}) => {
return (
<Pressable
{...props}
hitSlop={HITSLOP_20}
style={[
a.justify_center,
a.align_center,
a.rounded_full,
{height: 36, width: 36},
t.atoms.bg_contrast_50,
(state.hovered || state.pressed) && [
t.atoms.bg_contrast_100,
],
]}
testID="headerDropdownBtn">
<Ellipsis
size="lg"
fill={t.atoms.text_contrast_medium.color}
/> />
</Pressable>
)
}}
</Menu.Trigger>
<Menu.Outer>
<Menu.Group>
{hasSession && (
<>
<Menu.Item
disabled={isSavePending || isRemovePending}
testID="feedHeaderDropdownToggleSavedBtn"
label={
isSaved
? _(msg`Remove from my feeds`)
: _(msg`Save to my feeds`)
}
onPress={onToggleSaved}>
<Menu.ItemText>
{isSaved
? _(msg`Remove from my feeds`)
: _(msg`Save to my feeds`)}
</Menu.ItemText>
<Menu.ItemIcon
icon={isSaved ? Trash : Plus}
position="right"
/>
</Menu.Item>
<Menu.Item
testID="feedHeaderDropdownReportBtn"
label={_(msg`Report feed`)}
onPress={onPressReport}>
<Menu.ItemText>{_(msg`Report feed`)}</Menu.ItemText>
<Menu.ItemIcon icon={CircleInfo} position="right" />
</Menu.Item>
</> </>
)} )}
<NativeDropdown
testID="headerDropdownBtn" <Menu.Item
items={dropdownItems} testID="feedHeaderDropdownShareBtn"
accessibilityLabel={_(msg`More options`)} label={_(msg`Share feed`)}
accessibilityHint=""> onPress={onPressShare}>
<View style={[pal.viewLight, styles.btn]}> <Menu.ItemText>{_(msg`Share feed`)}</Menu.ItemText>
<FontAwesomeIcon <Menu.ItemIcon icon={Share} position="right" />
icon="ellipsis" </Menu.Item>
size={20} </Menu.Group>
color={pal.colors.text} </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 (
@@ -408,18 +417,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
@@ -508,21 +505,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()
@@ -558,24 +548,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"
@@ -587,50 +561,34 @@ function AboutSection({
<Trans>No description</Trans> <Trans>No description</Trans>
</Text> </Text>
)} )}
<View style={{flexDirection: 'row', alignItems: 'center', gap: 10}}> </View>
<Button
type="default" <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" testID="toggleLikeBtn"
accessibilityLabel={_(msg`Like this feed`)}
accessibilityHint=""
disabled={!hasSession || isLikePending || isUnlikePending} disabled={!hasSession || isLikePending || isUnlikePending}
onPress={onToggleLiked} onPress={onToggleLiked}>
style={{paddingHorizontal: 10}}>
{isLiked ? ( {isLiked ? (
<HeartIconSolid size={19} style={s.likeColor} /> <HeartFilled size="md" fill={s.likeColor.color} />
) : ( ) : (
<HeartIcon strokeWidth={3} size={19} style={pal.textLight} /> <HeartOutline size="md" fill={t.atoms.text_contrast_medium.color} />
)} )}
</Button> </NewButton>
{typeof likeCount === 'number' && ( {typeof likeCount === 'number' && (
<TextLink <InlineLink
href={makeCustomFeedLink(feedOwnerDid, feedRkey, 'liked-by')} label={_(msg`View users who like this feed`)}
text={_( to={makeCustomFeedLink(feedOwnerDid, feedRkey, 'liked-by')}
msg`Liked by ${likeCount} ${pluralize(likeCount, 'user')}`, style={[t.atoms.text_contrast_medium, a.font_bold]}>
)} {_(msg`Liked by ${likeCount} ${pluralize(likeCount, 'user')}`)}
style={[pal.textLight, s.semiBold]} </InlineLink>
/>
)} )}
</View> </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>
) )
} }
@@ -650,4 +608,9 @@ const styles = StyleSheet.create({
paddingVertical: 14, paddingVertical: 14,
borderRadius: 6, borderRadius: 6,
}, },
aboutSectionContainer: {
paddingVertical: 4,
paddingHorizontal: 16,
gap: 12,
},
}) })
+65 -57
View File
@@ -62,6 +62,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']
@@ -235,8 +237,8 @@ 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 reportDialogControl = useReportDialogControl() const reportDialogControl = useReportDialogControl()
const {openModal} = useModalControls()
const listMuteMutation = useListMuteMutation() const listMuteMutation = useListMuteMutation()
const listBlockMutation = useListBlockMutation() const listBlockMutation = useListBlockMutation()
const listDeleteMutation = useListDeleteMutation() const listDeleteMutation = useListDeleteMutation()
@@ -253,6 +255,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)
@@ -271,15 +277,7 @@ 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({
name: 'confirm',
title: _(msg`Mute these accounts?`),
message: _(
msg`Muting is private. Muted accounts can interact with you, but you will not see their posts or receive notifications from them.`,
),
confirmBtnText: _(msg`Mute this List`),
async onPressConfirm() {
try { try {
await listMuteMutation.mutateAsync({uri: list.uri, mute: true}) await listMuteMutation.mutateAsync({uri: list.uri, mute: true})
Toast.show(_(msg`List muted`)) Toast.show(_(msg`List muted`))
@@ -291,12 +289,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
), ),
) )
} }
}, }, [list, listMuteMutation, track, _])
onPressCancel() {
closeModal()
},
})
}, [openModal, closeModal, list, listMuteMutation, track, _])
const onUnsubscribeMute = useCallback(async () => { const onUnsubscribeMute = useCallback(async () => {
try { try {
@@ -312,15 +305,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
} }
}, [list, listMuteMutation, track, _]) }, [list, listMuteMutation, track, _])
const onSubscribeBlock = useCallback(() => { const onSubscribeBlock = useCallback(async () => {
openModal({
name: 'confirm',
title: _(msg`Block these accounts?`),
message: _(
msg`Blocking is public. Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`,
),
confirmBtnText: _(msg`Block this List`),
async onPressConfirm() {
try { try {
await listBlockMutation.mutateAsync({uri: list.uri, block: true}) await listBlockMutation.mutateAsync({uri: list.uri, block: true})
Toast.show(_(msg`List blocked`)) Toast.show(_(msg`List blocked`))
@@ -332,12 +317,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
), ),
) )
} }
}, }, [list, listBlockMutation, track, _])
onPressCancel() {
closeModal()
},
})
}, [openModal, closeModal, list, listBlockMutation, track, _])
const onUnsubscribeBlock = useCallback(async () => { const onUnsubscribeBlock = useCallback(async () => {
try { try {
@@ -360,12 +340,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
}) })
}, [openModal, list]) }, [openModal, list])
const onPressDelete = useCallback(() => { const onPressDelete = useCallback(async () => {
openModal({
name: 'confirm',
title: _(msg`Delete List`),
message: _(msg`Are you sure?`),
async onPressConfirm() {
await listDeleteMutation.mutateAsync({uri: list.uri}) await listDeleteMutation.mutateAsync({uri: list.uri})
if (isSaved || isPinned) { if (isSaved || isPinned) {
@@ -384,10 +359,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
} else { } else {
navigation.navigate('Home') navigation.navigate('Home')
} }
},
})
}, [ }, [
openModal,
list, list,
listDeleteMutation, listDeleteMutation,
navigation, navigation,
@@ -441,7 +413,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',
@@ -487,7 +459,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',
@@ -502,7 +476,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',
@@ -515,24 +491,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(() => {
@@ -540,7 +516,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',
@@ -552,7 +528,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',
@@ -562,7 +538,7 @@ function Header({rkey, list}: {rkey: string; list: AppBskyGraphDefs.ListView}) {
}, },
}, },
] ]
}, [onSubscribeMute, onSubscribeBlock, _]) }, [_, subscribeMutePromptControl.open, subscribeBlockPromptControl.open])
return ( return (
<ProfileSubpageHeader <ProfileSubpageHeader
@@ -626,6 +602,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>
) )
} }
+1 -1
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>
+1 -1
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
+5 -3
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(
+3 -23
View File
@@ -9501,9 +9501,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"
@@ -15085,11 +15085,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"
@@ -20108,14 +20103,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"
@@ -20136,13 +20123,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"