Merge branch 'main' into hailey/onboarding-profile

This commit is contained in:
Hailey
2024-03-15 12:22:04 -07:00
committed by GitHub
113 changed files with 5737 additions and 4235 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="M5.002 17.036V5h14v12.036h-3.986a1 1 0 0 0-.639.23l-2.375 1.968-2.344-1.965a1 1 0 0 0-.643-.233H5.002ZM20.002 3h-16a1 1 0 0 0-1 1v14.036a1 1 0 0 0 1 1h4.65l2.704 2.266a1 1 0 0 0 1.28.004l2.74-2.27h4.626a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1Zm-7.878 3.663c-1.39 0-2.5 1.135-2.5 2.515a1 1 0 0 0 2 0c0-.294.232-.515.5-.515a.507.507 0 0 1 .489.6.174.174 0 0 1-.027.048 1.1 1.1 0 0 1-.267.226c-.508.345-1.128.923-1.286 1.978a1 1 0 1 0 1.978.297.762.762 0 0 1 .14-.359c.063-.086.155-.169.293-.262.436-.297 1.18-.885 1.18-2.013 0-1.38-1.11-2.515-2.5-2.515ZM12 15.75a1.25 1.25 0 1 1 0-2.5 1.25 1.25 0 0 1 0 2.5Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 738 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.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="M2 12a2 2 0 1 1 4 0 2 2 0 0 1-4 0Zm16 0a2 2 0 1 1 4 0 2 2 0 0 1-4 0Zm-6-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 245 B

@@ -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="M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v4a1 1 0 0 1-.293.707L15 14.414V20a1 1 0 0 1-.758.97l-4 1A1 1 0 0 1 9 21v-6.586L3.293 8.707A1 1 0 0 1 3 8V4Zm2 1v2.586l5.707 5.707A1 1 0 0 1 11 14v5.72l2-.5V14a1 1 0 0 1 .293-.707L19 7.586V5H5Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 371 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="M12.472 3.118A1 1 0 0 1 13 4v16a1 1 0 0 1-1.555.832L5.697 17H2a1 1 0 0 1-1-1V8a1 1 0 0 1 1-1h3.697l5.748-3.832a1 1 0 0 1 1.027-.05ZM11 5.868 6.555 8.833A1 1 0 0 1 6 9H3v6h3a1 1 0 0 1 .555.168L11 18.131V5.87Zm7.364-1.645a1 1 0 0 1 1.414 0A10.969 10.969 0 0 1 23 12c0 3.037-1.232 5.788-3.222 7.778a1 1 0 1 1-1.414-1.414A8.969 8.969 0 0 0 21 12a8.969 8.969 0 0 0-2.636-6.364 1 1 0 0 1 0-1.414Zm-3.182 3.181a1 1 0 0 1 1.414 0A6.483 6.483 0 0 1 18.5 12a6.483 6.483 0 0 1-1.904 4.597 1 1 0 0 1-1.414-1.415A4.483 4.483 0 0 0 16.5 12a4.483 4.483 0 0 0-1.318-3.182 1 1 0 0 1 0-1.414Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 717 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="M7.416 5H3a1 1 0 0 0 0 2h1.064l.938 14.067A1 1 0 0 0 6 22h12a1 1 0 0 0 .998-.933L19.936 7H21a1 1 0 1 0 0-2h-4.416a5 5 0 0 0-9.168 0Zm2.348 0h4.472c-.55-.614-1.348-1-2.236-1-.888 0-1.687.386-2.236 1Zm6.087 2H6.07l.867 13h10.128l.867-13h-2.036a1 1 0 0 1-.044 0ZM10 10a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0v-5a1 1 0 0 1 1-1Zm4 0a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0v-5a1 1 0 0 1 1-1Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 508 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="M11.14 4.494a.995.995 0 0 1 1.72 0l7.001 12.008a.996.996 0 0 1-.86 1.498H4.999a.996.996 0 0 1-.86-1.498L11.14 4.494Zm3.447-1.007c-1.155-1.983-4.019-1.983-5.174 0L2.41 15.494C1.247 17.491 2.686 20 4.998 20h14.004c2.312 0 3.751-2.509 2.587-4.506L14.587 3.487ZM13 9.019a1 1 0 1 0-2 0v2.994a1 1 0 1 0 2 0V9.02Zm-1 4.731a1.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: 503 B

+3
View File
@@ -43,6 +43,9 @@
height: calc(100% + env(safe-area-inset-top)); height: calc(100% + env(safe-area-inset-top));
scrollbar-gutter: stable both-edges; scrollbar-gutter: stable both-edges;
} }
html, body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
/* Buttons and inputs have a font set by UA, so we'll have to reset that */ /* Buttons and inputs have a font set by UA, so we'll have to reset that */
button, input, textarea { button, input, textarea {
+1 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "bsky.app", "name": "bsky.app",
"version": "1.72.0", "version": "1.73.0",
"private": true, "private": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@@ -180,7 +180,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",
+9 -2
View File
@@ -78,7 +78,7 @@ import {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStack
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {i18n, MessageDescriptor} from '@lingui/core' import {i18n, MessageDescriptor} from '@lingui/core'
import HashtagScreen from '#/screens/Hashtag' import HashtagScreen from '#/screens/Hashtag'
import {logEvent} from './lib/statsig/statsig' import {logEvent, attachRouteToLogEvents} from './lib/statsig/statsig'
const navigationRef = createNavigationContainerRef<AllNavigatorParams>() const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
@@ -543,6 +543,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
linking={LINKING} linking={LINKING}
theme={theme} theme={theme}
onReady={() => { onReady={() => {
attachRouteToLogEvents(getCurrentRouteName)
logModuleInitTime() logModuleInitTime()
onReady() onReady()
}}> }}>
@@ -551,6 +552,10 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
) )
} }
function getCurrentRouteName() {
return navigationRef.getCurrentRoute()?.name
}
/** /**
* These helpers can be used from outside of the RoutesContainer * These helpers can be used from outside of the RoutesContainer
* (eg in the state models). * (eg in the state models).
@@ -656,7 +661,9 @@ function logModuleInitTime() {
performance.now() - global.__BUNDLE_START_TIME__, performance.now() - global.__BUNDLE_START_TIME__,
) )
console.log(`Time to first paint: ${initMs} ms`) console.log(`Time to first paint: ${initMs} ms`)
logEvent('init', initMs) logEvent('init', {
initMs,
})
if (__DEV__) { if (__DEV__) {
// This log is noisy, so keep false committed // This log is noisy, so keep false committed
+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',
@@ -111,6 +112,12 @@ export const atoms = {
flex_row: { flex_row: {
flexDirection: 'row', flexDirection: 'row',
}, },
flex_col_reverse: {
flexDirection: 'column-reverse',
},
flex_row_reverse: {
flexDirection: 'row-reverse',
},
flex_wrap: { flex_wrap: {
flexWrap: 'wrap', flexWrap: 'wrap',
}, },
+3 -1
View File
@@ -27,7 +27,7 @@ export type ButtonColor =
| 'gradient_sunset' | 'gradient_sunset'
| 'gradient_nordic' | 'gradient_nordic'
| 'gradient_bonfire' | 'gradient_bonfire'
export type ButtonSize = 'tiny' | 'small' | 'large' export type ButtonSize = 'tiny' | 'small' | 'medium' | 'large'
export type ButtonShape = 'round' | 'square' | 'default' export type ButtonShape = 'round' | 'square' | 'default'
export type VariantProps = { export type VariantProps = {
/** /**
@@ -274,6 +274,8 @@ export function Button({
if (shape === 'default') { if (shape === 'default') {
if (size === 'large') { if (size === 'large') {
baseStyles.push({paddingVertical: 15}, a.px_2xl, a.rounded_sm, a.gap_md) baseStyles.push({paddingVertical: 15}, a.px_2xl, a.rounded_sm, a.gap_md)
} else if (size === 'medium') {
baseStyles.push({paddingVertical: 12}, a.px_2xl, a.rounded_sm, a.gap_md)
} else if (size === 'small') { } else if (size === 'small') {
baseStyles.push({paddingVertical: 9}, a.px_lg, a.rounded_sm, a.gap_sm) baseStyles.push({paddingVertical: 9}, a.px_lg, a.rounded_sm, a.gap_sm)
} else if (size === 'tiny') { } else if (size === 'tiny') {
+2 -4
View File
@@ -21,8 +21,7 @@ export function useDialogControl(): DialogOuterProps['control'] {
open: () => {}, open: () => {},
close: () => {}, close: () => {},
}) })
const {activeDialogs, openDialogs} = useDialogStateContext() const {activeDialogs} = useDialogStateContext()
const isOpen = openDialogs.includes(id)
React.useEffect(() => { React.useEffect(() => {
activeDialogs.current.set(id, control) activeDialogs.current.set(id, control)
@@ -36,7 +35,6 @@ export function useDialogControl(): DialogOuterProps['control'] {
() => ({ () => ({
id, id,
ref: control, ref: control,
isOpen,
open: () => { open: () => {
control.current.open() control.current.open()
}, },
@@ -44,6 +42,6 @@ export function useDialogControl(): DialogOuterProps['control'] {
control.current.close(cb) control.current.close(cb)
}, },
}), }),
[id, control, isOpen], [id, control],
) )
} }
+6 -2
View File
@@ -23,6 +23,7 @@ import {
DialogInnerProps, DialogInnerProps,
} from '#/components/Dialog/types' } from '#/components/Dialog/types'
import {Context} from '#/components/Dialog/context' import {Context} from '#/components/Dialog/context'
import {isNative} from 'platform/detection'
export {useDialogControl, useDialogContext} from '#/components/Dialog/context' export {useDialogControl, useDialogContext} from '#/components/Dialog/context'
export * from '#/components/Dialog/types' export * from '#/components/Dialog/types'
@@ -75,6 +76,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 +147,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
@@ -224,7 +227,8 @@ export function ScrollableInner({children, style}: DialogInnerProps) {
borderTopRightRadius: 40, borderTopRightRadius: 40,
}, },
flatten(style), flatten(style),
]}> ]}
contentContainerStyle={isNative ? a.pb_4xl : undefined}>
{children} {children}
<View style={{height: insets.bottom + a.pt_5xl.paddingTop}} /> <View style={{height: insets.bottom + a.pt_5xl.paddingTop}} />
</BottomSheetScrollView> </BottomSheetScrollView>
+2 -1
View File
@@ -22,7 +22,7 @@ export type DialogControlRefProps = {
export type DialogControlProps = DialogControlRefProps & { export type DialogControlProps = DialogControlRefProps & {
id: string id: string
ref: React.RefObject<DialogControlRefProps> ref: React.RefObject<DialogControlRefProps>
isOpen: boolean isOpen?: boolean
} }
export type DialogContextProps = { export type DialogContextProps = {
@@ -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
} }
+73 -27
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,35 +123,42 @@ export function Trigger({children, label, style}: TriggerProps) {
return ( return (
<DropdownMenu.Trigger asChild> <DropdownMenu.Trigger asChild>
<Pressable <RadixTriggerPassThrough>
accessibilityHint="" {props =>
accessibilityLabel={label} children({
onFocus={onFocus} isNative: false,
onBlur={onBlur} control,
style={flatten([style, web({outline: 0})])} state: {
onPointerDown={() => { hovered,
control.open() focused,
}} pressed: false,
{...web({ },
onMouseEnter, props: {
onMouseLeave, ...props,
})}> // disable on web, use `onPress`
{children({ onPointerDown: () => false,
isNative: false, onPress: () =>
control, control.isOpen ? control.close() : control.open(),
state: { onFocus: onFocus,
hovered, onBlur: onBlur,
focused, onMouseEnter,
pressed: false, onMouseLeave,
}, accessibilityLabel: label,
props: {}, },
})} })
</Pressable> }
</RadixTriggerPassThrough>
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
) )
} }
export function Outer({children}: React.PropsWithChildren<{}>) { export function Outer({
children,
style,
}: React.PropsWithChildren<{
showCancel?: boolean
style?: StyleProp<ViewStyle>
}>) {
const t = useTheme() const t = useTheme()
return ( return (
@@ -127,10 +170,12 @@ 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>
{/* Disabled until we can fix positioning
<DropdownMenu.Arrow <DropdownMenu.Arrow
className="DropdownMenuArrow" className="DropdownMenuArrow"
fill={ fill={
@@ -138,6 +183,7 @@ export function Outer({children}: React.PropsWithChildren<{}>) {
.backgroundColor .backgroundColor
} }
/> />
*/}
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Portal> </DropdownMenu.Portal>
) )
+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<
+94 -20
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} 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,9 +22,12 @@ 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 titleId = React.useId() const titleId = React.useId()
const descriptionId = React.useId() const descriptionId = React.useId()
@@ -34,16 +37,16 @@ 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 />
<Dialog.Inner <Dialog.ScrollableInner
accessibilityLabelledBy={titleId} accessibilityLabelledBy={titleId}
accessibilityDescribedBy={descriptionId} accessibilityDescribedBy={descriptionId}
style={[{width: 'auto', maxWidth: 400}]}> style={[gtMobile ? {width: 'auto', maxWidth: 400} : a.w_full]}>
{children} {children}
</Dialog.Inner> </Dialog.ScrollableInner>
</Context.Provider> </Context.Provider>
</Dialog.Outer> </Dialog.Outer>
) )
@@ -71,8 +74,18 @@ export function Description({children}: React.PropsWithChildren<{}>) {
} }
export function Actions({children}: React.PropsWithChildren<{}>) { export function Actions({children}: React.PropsWithChildren<{}>) {
const {gtMobile} = useBreakpoints()
return ( return (
<View style={[a.w_full, a.flex_row, a.gap_sm, a.justify_end]}> <View
style={[
a.w_full,
a.gap_sm,
a.justify_end,
gtMobile
? [a.flex_row, a.flex_row_reverse, a.justify_start]
: [a.flex_col],
]}>
{children} {children}
</View> </View>
) )
@@ -80,17 +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 {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="small" 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>
) )
} }
@@ -98,21 +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 {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="small" 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
@@ -3,7 +3,7 @@ import {Text as RNText, TextStyle, TextProps as RNTextProps} from 'react-native'
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 & {
/** /**
@@ -40,11 +40,11 @@ function normalizeTextStyles(styles: 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
@@ -0,0 +1,124 @@
import React from 'react'
import {useLingui} from '@lingui/react'
import {Trans, msg} from '@lingui/macro'
import * as Dialog from '#/components/Dialog'
import {Text} from '../Typography'
import {DateInput} from '#/view/com/util/forms/DateInput'
import {logger} from '#/logger'
import {
usePreferencesSetBirthDateMutation,
UsePreferencesQueryResponse,
} from '#/state/queries/preferences'
import {Button, ButtonText} from '../Button'
import {atoms as a, useTheme} from '#/alf'
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
import {cleanError} from '#/lib/strings/errors'
import {ActivityIndicator, View} from 'react-native'
import {isIOS, isWeb} from '#/platform/detection'
export function BirthDateSettingsDialog({
control,
preferences,
}: {
control: Dialog.DialogControlProps
preferences: UsePreferencesQueryResponse | undefined
}) {
const {_} = useLingui()
const {isPending, isError, error, mutateAsync} =
usePreferencesSetBirthDateMutation()
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner label={_(msg`My Birthday`)}>
{preferences && !isPending ? (
<BirthdayInner
control={control}
preferences={preferences}
isError={isError}
error={error}
setBirthDate={mutateAsync}
/>
) : (
<ActivityIndicator size="large" style={a.my_5xl} />
)}
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}
function BirthdayInner({
control,
preferences,
isError,
error,
setBirthDate,
}: {
control: Dialog.DialogControlProps
preferences: UsePreferencesQueryResponse
isError: boolean
error: unknown
setBirthDate: (args: {birthDate: Date}) => Promise<unknown>
}) {
const {_} = useLingui()
const [date, setDate] = React.useState(preferences.birthDate || new Date())
const t = useTheme()
const hasChanged = date !== preferences.birthDate
const onSave = React.useCallback(async () => {
try {
// skip if date is the same
if (hasChanged) {
await setBirthDate({birthDate: date})
}
control.close()
} catch (e) {
logger.error(`setBirthDate failed`, {message: e})
}
}, [date, setBirthDate, control, hasChanged])
return (
<View style={a.gap_lg} testID="birthDateSettingsDialog">
<View style={[a.gap_sm]}>
<Text style={[a.text_2xl, a.font_bold]}>
<Trans>My Birthday</Trans>
</Text>
<Text style={t.atoms.text_contrast_medium}>
<Trans>This information is not shared with other users.</Trans>
</Text>
</View>
<View style={isIOS && [a.w_full, a.align_center]}>
<DateInput
handleAsUTC
testID="birthdayInput"
value={date}
onChange={setDate}
buttonType="default-light"
buttonStyle={[a.rounded_sm]}
buttonLabelType="lg"
accessibilityLabel={_(msg`Birthday`)}
accessibilityHint={_(msg`Enter your birth date`)}
accessibilityLabelledBy="birthDate"
/>
</View>
{isError ? (
<ErrorMessage message={cleanError(error)} style={[a.rounded_sm]} />
) : undefined}
<View style={isWeb && [a.flex_row, a.justify_end]}>
<Button
label={hasChanged ? _(msg`Save birthday`) : _(msg`Done`)}
size={isWeb ? 'small' : 'medium'}
onPress={onSave}
variant="solid"
color="primary">
<ButtonText>
{hasChanged ? <Trans>Save</Trans> : <Trans>Done</Trans>}
</ButtonText>
</Button>
</View>
</View>
)
}
+12 -25
View File
@@ -254,9 +254,9 @@ function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) {
</View> </View>
{isNative && <View style={{height: 20}} />} {isNative && <View style={{height: 20}} />}
<Dialog.Close />
</View> </View>
<Dialog.Close />
</Dialog.ScrollableInner> </Dialog.ScrollableInner>
) )
} }
@@ -277,29 +277,16 @@ function MutedWordRow({
return ( return (
<> <>
<Prompt.Outer control={control}> <Prompt.Basic
<Prompt.Title> control={control}
<Trans>Are you sure?</Trans> title={_(msg`Are you sure?`)}
</Prompt.Title> description={_(
<Prompt.Description> msg`This will delete ${word.value} from your muted words. You can always add it back later.`,
<Trans> )}
This will delete {word.value} from your muted words. You can always onConfirm={remove}
add it back later. confirmButtonCta={_(msg`Remove`)}
</Trans> confirmButtonColor="negative"
</Prompt.Description> />
<Prompt.Actions>
<Prompt.Cancel>
<ButtonText>
<Trans>Nevermind</Trans>
</ButtonText>
</Prompt.Cancel>
<Prompt.Action onPress={remove}>
<ButtonText>
<Trans>Remove</Trans>
</ButtonText>
</Prompt.Action>
</Prompt.Actions>
</Prompt.Outer>
<View <View
style={[ style={[
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const BubbleQuestion_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M5.002 17.036V5h14v12.036h-3.986a1 1 0 0 0-.639.23l-2.375 1.968-2.344-1.965a1 1 0 0 0-.643-.233H5.002ZM20.002 3h-16a1 1 0 0 0-1 1v14.036a1 1 0 0 0 1 1h4.65l2.704 2.266a1 1 0 0 0 1.28.004l2.74-2.27h4.626a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1Zm-7.878 3.663c-1.39 0-2.5 1.135-2.5 2.515a1 1 0 0 0 2 0c0-.294.232-.515.5-.515a.507.507 0 0 1 .489.6.174.174 0 0 1-.027.048 1.1 1.1 0 0 1-.267.226c-.508.345-1.128.923-1.286 1.978a1 1 0 1 0 1.978.297.762.762 0 0 1 .14-.359c.063-.086.155-.169.293-.262.436-.297 1.18-.885 1.18-2.013 0-1.38-1.11-2.515-2.5-2.515ZM12 15.75a1.25 1.25 0 1 1 0-2.5 1.25 1.25 0 0 1 0 2.5Z',
})
+4
View File
@@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE'
export const Camera_Stroke2_Corner0_Rounded = createSinglePathSVG({ 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', 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',
})
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const DotGrid_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M2 12a2 2 0 1 1 4 0 2 2 0 0 1-4 0Zm16 0a2 2 0 1 1 4 0 2 2 0 0 1-4 0Zm-6-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z',
})
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Filter_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v4a1 1 0 0 1-.293.707L15 14.414V20a1 1 0 0 1-.758.97l-4 1A1 1 0 0 1 9 21v-6.586L3.293 8.707A1 1 0 0 1 3 8V4Zm2 1v2.586l5.707 5.707A1 1 0 0 1 11 14v5.72l2-.5V14a1 1 0 0 1 .293-.707L19 7.586V5H5Z',
})
+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',
})
+9
View File
@@ -0,0 +1,9 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Heart2_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M16.734 5.091c-1.238-.276-2.708.047-4.022 1.38a1 1 0 0 1-1.424 0C9.974 5.137 8.504 4.814 7.266 5.09c-1.263.282-2.379 1.206-2.92 2.556C3.33 10.18 4.252 14.84 12 19.348c7.747-4.508 8.67-9.168 7.654-11.7-.541-1.351-1.657-2.275-2.92-2.557Zm4.777 1.812c1.604 4-.494 9.69-9.022 14.47a1 1 0 0 1-.978 0C2.983 16.592.885 10.902 2.49 6.902c.779-1.942 2.414-3.334 4.342-3.764 1.697-.378 3.552.003 5.169 1.286 1.617-1.283 3.472-1.664 5.17-1.286 1.927.43 3.562 1.822 4.34 3.764Z',
})
export const Heart2_Filled_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M12.489 21.372c8.528-4.78 10.626-10.47 9.022-14.47-.779-1.941-2.414-3.333-4.342-3.763-1.697-.378-3.552.003-5.169 1.287-1.617-1.284-3.472-1.665-5.17-1.287-1.927.43-3.562 1.822-4.34 3.764-1.605 4 .493 9.69 9.021 14.47a1 1 0 0 0 .978 0Z',
})
+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 SpeakerVolumeFull_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M12.472 3.118A1 1 0 0 1 13 4v16a1 1 0 0 1-1.555.832L5.697 17H2a1 1 0 0 1-1-1V8a1 1 0 0 1 1-1h3.697l5.748-3.832a1 1 0 0 1 1.027-.05ZM11 5.868 6.555 8.833A1 1 0 0 1 6 9H3v6h3a1 1 0 0 1 .555.168L11 18.131V5.87Zm7.364-1.645a1 1 0 0 1 1.414 0A10.969 10.969 0 0 1 23 12c0 3.037-1.232 5.788-3.222 7.778a1 1 0 1 1-1.414-1.414A8.969 8.969 0 0 0 21 12a8.969 8.969 0 0 0-2.636-6.364 1 1 0 0 1 0-1.414Zm-3.182 3.181a1 1 0 0 1 1.414 0A6.483 6.483 0 0 1 18.5 12a6.483 6.483 0 0 1-1.904 4.597 1 1 0 0 1-1.414-1.415A4.483 4.483 0 0 0 16.5 12a4.483 4.483 0 0 0-1.318-3.182 1 1 0 0 1 0-1.414Z',
})
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Trash_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M7.416 5H3a1 1 0 0 0 0 2h1.064l.938 14.067A1 1 0 0 0 6 22h12a1 1 0 0 0 .998-.933L19.936 7H21a1 1 0 1 0 0-2h-4.416a5 5 0 0 0-9.168 0Zm2.348 0h4.472c-.55-.614-1.348-1-2.236-1-.888 0-1.687.386-2.236 1Zm6.087 2H6.07l.867 13h10.128l.867-13h-2.036a1 1 0 0 1-.044 0ZM10 10a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0v-5a1 1 0 0 1 1-1Zm4 0a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0v-5a1 1 0 0 1 1-1Z',
})
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Warning_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M11.14 4.494a.995.995 0 0 1 1.72 0l7.001 12.008a.996.996 0 0 1-.86 1.498H4.999a.996.996 0 0 1-.86-1.498L11.14 4.494Zm3.447-1.007c-1.155-1.983-4.019-1.983-5.174 0L2.41 15.494C1.247 17.491 2.686 20 4.998 20h14.004c2.312 0 3.751-2.509 2.587-4.506L14.587 3.487ZM13 9.019a1 1 0 1 0-2 0v2.994a1 1 0 1 0 2 0V9.02Zm-1 4.731a1.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],
)
}
+15 -37
View File
@@ -2,25 +2,9 @@ import * as Updates from 'expo-updates'
import {useCallback, useEffect} from 'react' import {useCallback, useEffect} from 'react'
import {AppState} from 'react-native' import {AppState} from 'react-native'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useModalControls} from '#/state/modals'
import {t} from '@lingui/macro'
export function useOTAUpdate() { export function useOTAUpdate() {
const {openModal} = useModalControls()
// HELPER FUNCTIONS // HELPER FUNCTIONS
const showUpdatePopup = useCallback(() => {
openModal({
name: 'confirm',
title: t`Update Available`,
message: t`A new version of the app is available. Please update to continue using the app.`,
onPressConfirm: async () => {
Updates.reloadAsync().catch(err => {
throw err
})
},
})
}, [openModal])
const checkForUpdate = useCallback(async () => { const checkForUpdate = useCallback(async () => {
logger.debug('useOTAUpdate: Checking for update...') logger.debug('useOTAUpdate: Checking for update...')
try { try {
@@ -32,32 +16,26 @@ export function useOTAUpdate() {
} }
// Otherwise fetch the update in the background, so even if the user rejects switching to latest version it will be done automatically on next relaunch. // Otherwise fetch the update in the background, so even if the user rejects switching to latest version it will be done automatically on next relaunch.
await Updates.fetchUpdateAsync() await Updates.fetchUpdateAsync()
// show a popup modal
showUpdatePopup()
} catch (e) { } catch (e) {
logger.error('useOTAUpdate: Error while checking for update', { logger.error('useOTAUpdate: Error while checking for update', {
message: e, message: e,
}) })
} }
}, [showUpdatePopup]) }, [])
const updateEventListener = useCallback( const updateEventListener = useCallback((event: Updates.UpdateEvent) => {
(event: Updates.UpdateEvent) => { logger.debug('useOTAUpdate: Listening for update...')
logger.debug('useOTAUpdate: Listening for update...') if (event.type === Updates.UpdateEventType.ERROR) {
if (event.type === Updates.UpdateEventType.ERROR) { logger.error('useOTAUpdate: Error while listening for update', {
logger.error('useOTAUpdate: Error while listening for update', { message: event.message,
message: event.message, })
}) } else if (event.type === Updates.UpdateEventType.NO_UPDATE_AVAILABLE) {
} else if (event.type === Updates.UpdateEventType.NO_UPDATE_AVAILABLE) { // Handle no update available
// Handle no update available // do nothing
// do nothing } else if (event.type === Updates.UpdateEventType.UPDATE_AVAILABLE) {
} else if (event.type === Updates.UpdateEventType.UPDATE_AVAILABLE) { // Handle update available
// Handle update available // open modal, ask for user confirmation, and reload the app
// open modal, ask for user confirmation, and reload the app }
showUpdatePopup() }, [])
}
},
[showUpdatePopup],
)
useEffect(() => { useEffect(() => {
// ADD EVENT LISTENERS // ADD EVENT LISTENERS
+14 -7
View File
@@ -18,11 +18,18 @@ export async function openPicker(opts?: ImagePickerOptions) {
Toast.show('You may only select up to 4 images') Toast.show('You may only select up to 4 images')
} }
return (response.assets ?? []).slice(0, 4).map(image => ({ return (response.assets ?? [])
mime: 'image/jpeg', .slice(0, 4)
height: image.height, .filter(asset => {
width: image.width, if (asset.mimeType?.startsWith('image/')) return true
path: image.uri, Toast.show('Only image files are supported')
size: getDataUriSize(image.uri), return false
})) })
.map(image => ({
mime: 'image/jpeg',
height: image.height,
width: image.width,
path: image.uri,
size: getDataUriSize(image.uri),
}))
} }
+8 -2
View File
@@ -2,9 +2,15 @@ import {RouteParams, Route} from './types'
export class Router { export class Router {
routes: [string, Route][] = [] routes: [string, Route][] = []
constructor(description: Record<string, string>) { constructor(description: Record<string, string | string[]>) {
for (const [screen, pattern] of Object.entries(description)) { for (const [screen, pattern] of Object.entries(description)) {
this.routes.push([screen, createRoute(pattern)]) if (typeof pattern === 'string') {
this.routes.push([screen, createRoute(pattern)])
} else {
pattern.forEach(subPattern => {
this.routes.push([screen, createRoute(subPattern)])
})
}
} }
} }
+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
+47
View File
@@ -0,0 +1,47 @@
export type LogEvents = {
init: {
initMs: number
}
'feed:endReached': {
feedType: string
itemCount: number
}
'post:create': {
imageCount: number
isReply: boolean
hasLink: boolean
hasQuote: boolean
langs: string
logContext: 'Composer'
}
'post:like': {
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
}
'post:repost': {
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
}
'post:unlike': {
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
}
'post:unrepost': {
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
}
'profile:follow': {
logContext:
| 'RecommendedFollowsItem'
| 'PostThreadItem'
| 'ProfileCard'
| 'ProfileHeader'
| 'ProfileHeaderSuggestedFollows'
| 'ProfileMenu'
}
'profile:unfollow': {
logContext:
| 'RecommendedFollowsItem'
| 'PostThreadItem'
| 'ProfileCard'
| 'ProfileHeader'
| 'ProfileHeaderSuggestedFollows'
| 'ProfileMenu'
}
}
+36 -5
View File
@@ -6,6 +6,9 @@ import {
} from 'statsig-react-native-expo' } from 'statsig-react-native-expo'
import {useSession} from '../../state/session' import {useSession} from '../../state/session'
import {sha256} from 'js-sha256' import {sha256} from 'js-sha256'
import {LogEvents} from './events'
export type {LogEvents}
const statsigOptions = { const statsigOptions = {
environment: { environment: {
@@ -17,12 +20,28 @@ const statsigOptions = {
initTimeoutMs: 1, initTimeoutMs: 1,
} }
export function logEvent( type FlatJSONRecord = Record<
eventName: string, string,
value?: string | number | null, string | number | boolean | null | undefined
metadata?: Record<string, string> | null, >
let getCurrentRouteName: () => string | null | undefined = () => null
export function attachRouteToLogEvents(
getRouteName: () => string | null | undefined,
) { ) {
Statsig.logEvent(eventName, value, metadata) getCurrentRouteName = getRouteName
}
export function logEvent<E extends keyof LogEvents>(
eventName: E & string,
rawMetadata: LogEvents[E] & FlatJSONRecord,
) {
const fullMetadata = {
...rawMetadata,
} as Record<string, string> // Statsig typings are unnecessarily strict here.
fullMetadata.routeName = getCurrentRouteName() ?? '(Uninitialized)'
Statsig.logEvent(eventName, null, fullMetadata)
} }
export function useGate(gateName: string) { export function useGate(gateName: string) {
@@ -48,6 +67,18 @@ export function Provider({children}: {children: React.ReactNode}) {
() => toStatsigUser(currentAccount?.did), () => toStatsigUser(currentAccount?.did),
[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 ( return (
<StatsigProvider <StatsigProvider
sdkKey="client-SXJakO39w9vIhl3D44u8UupyzFl4oZ2qPIkjwcvuPsV" sdkKey="client-SXJakO39w9vIhl3D44u8UupyzFl4oZ2qPIkjwcvuPsV"
-63
View File
@@ -1,63 +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],
)
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>
)
}
+22 -2
View File
@@ -3,6 +3,8 @@ import {BSKY_SERVICE} from 'lib/constants'
import TLDs from 'tlds' import TLDs from 'tlds'
import psl from 'psl' import psl from 'psl'
export const BSKY_APP_HOST = 'https://bsky.app'
export function isValidDomain(str: string): boolean { export function isValidDomain(str: string): boolean {
return !!TLDs.find(tld => { return !!TLDs.find(tld => {
let i = str.lastIndexOf(tld) let i = str.lastIndexOf(tld)
@@ -67,8 +69,21 @@ export function isBskyAppUrl(url: string): boolean {
return url.startsWith('https://bsky.app/') return url.startsWith('https://bsky.app/')
} }
export function isRelativeUrl(url: string): boolean {
return /^\/[^/]/.test(url)
}
export function isBskyRSSUrl(url: string): boolean {
return (
(url.startsWith('https://bsky.app/') || isRelativeUrl(url)) &&
/\/rss\/?$/.test(url)
)
}
export function isExternalUrl(url: string): boolean { export function isExternalUrl(url: string): boolean {
return !isBskyAppUrl(url) && url.startsWith('http') const external = !isBskyAppUrl(url) && url.startsWith('http')
const rss = isBskyRSSUrl(url)
return external || rss
} }
export function isBskyPostUrl(url: string): boolean { export function isBskyPostUrl(url: string): boolean {
@@ -149,7 +164,7 @@ export function linkRequiresWarning(uri: string, label: string) {
const labelDomain = labelToDomain(label) const labelDomain = labelToDomain(label)
// If the uri started with a / we know it is internal. // If the uri started with a / we know it is internal.
if (uri.startsWith('/')) { if (isRelativeUrl(uri)) {
return false return false
} }
@@ -222,3 +237,8 @@ export function splitApexDomain(hostname: string): [string, string] {
hostnamep.domain, hostnamep.domain,
] ]
} }
export function createBskyAppAbsoluteUrl(path: string): string {
const sanitizedPath = path.replace(BSKY_APP_HOST, '').replace(/^\/+/, '')
return `${BSKY_APP_HOST.replace(/\/$/, '')}/${sanitizedPath}`
}
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
+1 -1
View File
@@ -12,7 +12,7 @@ export const router = new Router({
ModerationModlists: '/moderation/modlists', ModerationModlists: '/moderation/modlists',
ModerationMutedAccounts: '/moderation/muted-accounts', ModerationMutedAccounts: '/moderation/muted-accounts',
ModerationBlockedAccounts: '/moderation/blocked-accounts', ModerationBlockedAccounts: '/moderation/blocked-accounts',
Profile: '/profile/:name', Profile: ['/profile/:name', '/profile/:name/rss'],
ProfileFollowers: '/profile/:name/followers', ProfileFollowers: '/profile/:name/followers',
ProfileFollows: '/profile/:name/follows', ProfileFollows: '/profile/:name/follows',
ProfileList: '/profile/:name/lists/:rkey', ProfileList: '/profile/:name/lists/:rkey',
@@ -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
+41 -19
View File
@@ -1,8 +1,9 @@
import React from 'react' import React from 'react'
import {SharedValue, useSharedValue} from 'react-native-reanimated'
import {DialogControlRefProps} from '#/components/Dialog' import {DialogControlRefProps} from '#/components/Dialog'
import {Provider as GlobalDialogsProvider} from '#/components/dialogs/Context' import {Provider as GlobalDialogsProvider} from '#/components/dialogs/Context'
const DialogContext = React.createContext<{ interface IDialogContext {
/** /**
* The currently active `useDialogControl` hooks. * The currently active `useDialogControl` hooks.
*/ */
@@ -13,13 +14,18 @@ const DialogContext = React.createContext<{
* The currently open dialogs, referenced by their IDs, generated from * The currently open dialogs, referenced by their IDs, generated from
* `useId`. * `useId`.
*/ */
openDialogs: string[] openDialogs: React.MutableRefObject<Set<string>>
}>({ /**
activeDialogs: { * The counterpart to `accessibilityViewIsModal` for Android. This property
current: new Map(), * applies to the parent of all non-modal views, and prevents TalkBack from
}, * navigating within content beneath an open dialog.
openDialogs: [], *
}) * @see https://reactnative.dev/docs/accessibility#importantforaccessibility-android
*/
importantForAccessibility: SharedValue<'auto' | 'no-hide-descendants'>
}
const DialogContext = React.createContext<IDialogContext>({} as IDialogContext)
const DialogControlContext = React.createContext<{ const DialogControlContext = React.createContext<{
closeAllDialogs(): boolean closeAllDialogs(): boolean
@@ -41,26 +47,42 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const activeDialogs = React.useRef< const activeDialogs = React.useRef<
Map<string, React.MutableRefObject<DialogControlRefProps>> Map<string, React.MutableRefObject<DialogControlRefProps>>
>(new Map()) >(new Map())
const [openDialogs, setOpenDialogs] = React.useState<string[]>([]) const openDialogs = React.useRef<Set<string>>(new Set())
const importantForAccessibility = useSharedValue<
'auto' | 'no-hide-descendants'
>('auto')
const closeAllDialogs = React.useCallback(() => { const closeAllDialogs = React.useCallback(() => {
activeDialogs.current.forEach(dialog => dialog.current.close()) activeDialogs.current.forEach(dialog => dialog.current.close())
return openDialogs.length > 0 return openDialogs.current.size > 0
}, [openDialogs]) }, [])
const setDialogIsOpen = React.useCallback( const setDialogIsOpen = React.useCallback(
(id: string, isOpen: boolean) => { (id: string, isOpen: boolean) => {
setOpenDialogs(prev => { if (isOpen) {
const filtered = prev.filter(dialogId => dialogId !== id) as string[] openDialogs.current.add(id)
return isOpen ? [...filtered, id] : filtered importantForAccessibility.value = 'no-hide-descendants'
}) } else {
openDialogs.current.delete(id)
if (openDialogs.current.size < 1) {
importantForAccessibility.value = 'auto'
}
}
}, },
[setOpenDialogs], [importantForAccessibility],
) )
const context = React.useMemo( const context = React.useMemo<IDialogContext>(
() => ({activeDialogs, openDialogs}), () => ({
[openDialogs], activeDialogs: {
current: new Map(),
},
openDialogs: {
current: new Set(),
},
importantForAccessibility,
}),
[importantForAccessibility],
) )
const controls = React.useMemo( const controls = React.useMemo(
() => ({closeAllDialogs, setDialogIsOpen}), () => ({closeAllDialogs, setDialogIsOpen}),
-18
View File
@@ -1,6 +1,5 @@
import React from 'react' import React from 'react'
import {AppBskyActorDefs, AppBskyGraphDefs, ModerationUI} from '@atproto/api' import {AppBskyActorDefs, AppBskyGraphDefs, ModerationUI} from '@atproto/api'
import {StyleProp, ViewStyle} from 'react-native'
import {Image as RNImage} from 'react-native-image-crop-picker' import {Image as RNImage} from 'react-native-image-crop-picker'
import {ImageModel} from '#/state/models/media/image' import {ImageModel} from '#/state/models/media/image'
@@ -9,17 +8,6 @@ import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {EmbedPlayerSource} from '#/lib/strings/embed-player' import {EmbedPlayerSource} from '#/lib/strings/embed-player'
import {ThreadgateSetting} from '../queries/threadgate' import {ThreadgateSetting} from '../queries/threadgate'
export interface ConfirmModal {
name: 'confirm'
title: string
message: string | (() => JSX.Element)
onPressConfirm: () => void | Promise<void>
onPressCancel?: () => void | Promise<void>
confirmBtnText?: string
confirmBtnStyle?: StyleProp<ViewStyle>
cancelBtnText?: string
}
export interface EditProfileModal { export interface EditProfileModal {
name: 'edit-profile' name: 'edit-profile'
profile: AppBskyActorDefs.ProfileViewDetailed profile: AppBskyActorDefs.ProfileViewDetailed
@@ -147,10 +135,6 @@ export interface PostLanguagesSettingsModal {
name: 'post-languages-settings' name: 'post-languages-settings'
} }
export interface BirthDateSettingsModal {
name: 'birth-date-settings'
}
export interface VerifyEmailModal { export interface VerifyEmailModal {
name: 'verify-email' name: 'verify-email'
showReminder?: boolean showReminder?: boolean
@@ -191,7 +175,6 @@ export type Modal =
| ChangeHandleModal | ChangeHandleModal
| DeleteAccountModal | DeleteAccountModal
| EditProfileModal | EditProfileModal
| BirthDateSettingsModal
| VerifyEmailModal | VerifyEmailModal
| ChangeEmailModal | ChangeEmailModal
| ChangePasswordModal | ChangePasswordModal
@@ -225,7 +208,6 @@ export type Modal =
| InviteCodesModal | InviteCodesModal
// Generic // Generic
| ConfirmModal
| LinkWarningModal | LinkWarningModal
| EmbedConsentModal | EmbedConsentModal
| InAppBrowserConsentModal | InAppBrowserConsentModal
+9
View File
@@ -5,6 +5,11 @@ import * as WebBrowser from 'expo-web-browser'
import {isNative} from '#/platform/detection' import {isNative} from '#/platform/detection'
import {useModalControls} from '../modals' import {useModalControls} from '../modals'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {
isBskyRSSUrl,
isRelativeUrl,
createBskyAppAbsoluteUrl,
} from 'lib/strings/url-helpers'
type StateContext = persisted.Schema['useInAppBrowser'] type StateContext = persisted.Schema['useInAppBrowser']
type SetContext = (v: persisted.Schema['useInAppBrowser']) => void type SetContext = (v: persisted.Schema['useInAppBrowser']) => void
@@ -57,6 +62,10 @@ export function useOpenLink() {
const openLink = React.useCallback( const openLink = React.useCallback(
(url: string, override?: boolean) => { (url: string, override?: boolean) => {
if (isBskyRSSUrl(url) && isRelativeUrl(url)) {
url = createBskyAppAbsoluteUrl(url)
}
if (isNative && !url.startsWith('mailto:')) { if (isNative && !url.startsWith('mailto:')) {
if (override === undefined && enabled === undefined) { if (override === undefined && enabled === undefined) {
openModal({ openModal({
+35 -12
View File
@@ -5,6 +5,7 @@ import {Shadow} from '#/state/cache/types'
import {getAgent} from '#/state/session' import {getAgent} from '#/state/session'
import {updatePostShadow} from '#/state/cache/post-shadow' import {updatePostShadow} from '#/state/cache/post-shadow'
import {track} from '#/lib/analytics/analytics' import {track} from '#/lib/analytics/analytics'
import {logEvent, LogEvents} from '#/lib/statsig/statsig'
import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue' import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue'
export const RQKEY = (postUri: string) => ['post', postUri] export const RQKEY = (postUri: string) => ['post', postUri]
@@ -58,12 +59,14 @@ export function useGetPost() {
export function usePostLikeMutationQueue( export function usePostLikeMutationQueue(
post: Shadow<AppBskyFeedDefs.PostView>, post: Shadow<AppBskyFeedDefs.PostView>,
logContext: LogEvents['post:like']['logContext'] &
LogEvents['post:unlike']['logContext'],
) { ) {
const postUri = post.uri const postUri = post.uri
const postCid = post.cid const postCid = post.cid
const initialLikeUri = post.viewer?.like const initialLikeUri = post.viewer?.like
const likeMutation = usePostLikeMutation() const likeMutation = usePostLikeMutation(logContext)
const unlikeMutation = usePostUnlikeMutation() const unlikeMutation = usePostUnlikeMutation(logContext)
const queueToggle = useToggleMutationQueue({ const queueToggle = useToggleMutationQueue({
initialState: initialLikeUri, initialState: initialLikeUri,
@@ -111,22 +114,30 @@ export function usePostLikeMutationQueue(
return [queueLike, queueUnlike] return [queueLike, queueUnlike]
} }
function usePostLikeMutation() { function usePostLikeMutation(logContext: LogEvents['post:like']['logContext']) {
return useMutation< return useMutation<
{uri: string}, // responds with the uri of the like {uri: string}, // responds with the uri of the like
Error, Error,
{uri: string; cid: string} // the post's uri and cid {uri: string; cid: string} // the post's uri and cid
>({ >({
mutationFn: post => getAgent().like(post.uri, post.cid), mutationFn: post => {
logEvent('post:like', {logContext})
return getAgent().like(post.uri, post.cid)
},
onSuccess() { onSuccess() {
track('Post:Like') track('Post:Like')
}, },
}) })
} }
function usePostUnlikeMutation() { function usePostUnlikeMutation(
logContext: LogEvents['post:unlike']['logContext'],
) {
return useMutation<void, Error, {postUri: string; likeUri: string}>({ return useMutation<void, Error, {postUri: string; likeUri: string}>({
mutationFn: ({likeUri}) => getAgent().deleteLike(likeUri), mutationFn: ({likeUri}) => {
logEvent('post:unlike', {logContext})
return getAgent().deleteLike(likeUri)
},
onSuccess() { onSuccess() {
track('Post:Unlike') track('Post:Unlike')
}, },
@@ -135,12 +146,14 @@ function usePostUnlikeMutation() {
export function usePostRepostMutationQueue( export function usePostRepostMutationQueue(
post: Shadow<AppBskyFeedDefs.PostView>, post: Shadow<AppBskyFeedDefs.PostView>,
logContext: LogEvents['post:repost']['logContext'] &
LogEvents['post:unrepost']['logContext'],
) { ) {
const postUri = post.uri const postUri = post.uri
const postCid = post.cid const postCid = post.cid
const initialRepostUri = post.viewer?.repost const initialRepostUri = post.viewer?.repost
const repostMutation = usePostRepostMutation() const repostMutation = usePostRepostMutation(logContext)
const unrepostMutation = usePostUnrepostMutation() const unrepostMutation = usePostUnrepostMutation(logContext)
const queueToggle = useToggleMutationQueue({ const queueToggle = useToggleMutationQueue({
initialState: initialRepostUri, initialState: initialRepostUri,
@@ -188,22 +201,32 @@ export function usePostRepostMutationQueue(
return [queueRepost, queueUnrepost] return [queueRepost, queueUnrepost]
} }
function usePostRepostMutation() { function usePostRepostMutation(
logContext: LogEvents['post:repost']['logContext'],
) {
return useMutation< return useMutation<
{uri: string}, // responds with the uri of the repost {uri: string}, // responds with the uri of the repost
Error, Error,
{uri: string; cid: string} // the post's uri and cid {uri: string; cid: string} // the post's uri and cid
>({ >({
mutationFn: post => getAgent().repost(post.uri, post.cid), mutationFn: post => {
logEvent('post:repost', {logContext})
return getAgent().repost(post.uri, post.cid)
},
onSuccess() { onSuccess() {
track('Post:Repost') track('Post:Repost')
}, },
}) })
} }
function usePostUnrepostMutation() { function usePostUnrepostMutation(
logContext: LogEvents['post:unrepost']['logContext'],
) {
return useMutation<void, Error, {postUri: string; repostUri: string}>({ return useMutation<void, Error, {postUri: string; repostUri: string}>({
mutationFn: ({repostUri}) => getAgent().deleteRepost(repostUri), mutationFn: ({repostUri}) => {
logEvent('post:unrepost', {logContext})
return getAgent().deleteRepost(repostUri)
},
onSuccess() { onSuccess() {
track('Post:Unrepost') track('Post:Unrepost')
}, },
+13 -4
View File
@@ -26,6 +26,7 @@ import {RQKEY as RQKEY_MY_MUTED} from './my-muted-accounts'
import {RQKEY as RQKEY_MY_BLOCKED} from './my-blocked-accounts' import {RQKEY as RQKEY_MY_BLOCKED} from './my-blocked-accounts'
import {STALE} from '#/state/queries' import {STALE} from '#/state/queries'
import {track} from '#/lib/analytics/analytics' import {track} from '#/lib/analytics/analytics'
import {logEvent, LogEvents} from '#/lib/statsig/statsig'
import {ThreadNode} from './post-thread' import {ThreadNode} from './post-thread'
export const RQKEY = (did: string) => ['profile', did] export const RQKEY = (did: string) => ['profile', did]
@@ -186,11 +187,13 @@ export function useProfileUpdateMutation() {
export function useProfileFollowMutationQueue( export function useProfileFollowMutationQueue(
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>, profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>,
logContext: LogEvents['profile:follow']['logContext'] &
LogEvents['profile:unfollow']['logContext'],
) { ) {
const did = profile.did const did = profile.did
const initialFollowingUri = profile.viewer?.following const initialFollowingUri = profile.viewer?.following
const followMutation = useProfileFollowMutation() const followMutation = useProfileFollowMutation(logContext)
const unfollowMutation = useProfileUnfollowMutation() const unfollowMutation = useProfileUnfollowMutation(logContext)
const queueToggle = useToggleMutationQueue({ const queueToggle = useToggleMutationQueue({
initialState: initialFollowingUri, initialState: initialFollowingUri,
@@ -237,9 +240,12 @@ export function useProfileFollowMutationQueue(
return [queueFollow, queueUnfollow] return [queueFollow, queueUnfollow]
} }
function useProfileFollowMutation() { function useProfileFollowMutation(
logContext: LogEvents['profile:follow']['logContext'],
) {
return useMutation<{uri: string; cid: string}, Error, {did: string}>({ return useMutation<{uri: string; cid: string}, Error, {did: string}>({
mutationFn: async ({did}) => { mutationFn: async ({did}) => {
logEvent('profile:follow', {logContext})
return await getAgent().follow(did) return await getAgent().follow(did)
}, },
onSuccess(data, variables) { onSuccess(data, variables) {
@@ -248,9 +254,12 @@ function useProfileFollowMutation() {
}) })
} }
function useProfileUnfollowMutation() { function useProfileUnfollowMutation(
logContext: LogEvents['profile:unfollow']['logContext'],
) {
return useMutation<void, Error, {did: string; followUri: string}>({ return useMutation<void, Error, {did: string; followUri: string}>({
mutationFn: async ({followUri}) => { mutationFn: async ({followUri}) => {
logEvent('profile:unfollow', {logContext})
track('Profile:Unfollow', {username: followUri}) track('Profile:Unfollow', {username: followUri})
return await getAgent().deleteFollow(followUri) return await getAgent().deleteFollow(followUri)
}, },
+1 -1
View File
@@ -157,7 +157,7 @@ export function Step1({
</View> </View>
)} )}
{uiState.inviteCode ? ( {!uiState.isInviteCodeRequired || uiState.inviteCode ? (
<> <>
<View style={s.pb20}> <View style={s.pb20}>
<Text <Text
@@ -56,7 +56,7 @@ export function RecommendedFollowsItem({
) )
} }
export function ProfileCard({ function ProfileCard({
profile, profile,
onFollowStateChange, onFollowStateChange,
moderation, moderation,
@@ -72,7 +72,10 @@ export function ProfileCard({
const pal = usePalette('default') const pal = usePalette('default')
const [addingMoreSuggestions, setAddingMoreSuggestions] = const [addingMoreSuggestions, setAddingMoreSuggestions] =
React.useState(false) React.useState(false)
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile) const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
profile,
'RecommendedFollowsItem',
)
const onToggleFollow = React.useCallback(async () => { const onToggleFollow = React.useCallback(async () => {
try { try {
+36 -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,9 @@ 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'
import {logEvent} from '#/lib/statsig/statsig'
type Props = ComposerOpts type Props = ComposerOpts
export const ComposePost = observer(function ComposePost({ export const ComposePost = observer(function ComposePost({
@@ -76,8 +79,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 +89,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 +139,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) {
@@ -257,6 +256,16 @@ export const ComposePost = observer(function ComposePost({
setIsProcessing(false) setIsProcessing(false)
return return
} finally { } finally {
if (postUri) {
logEvent('post:create', {
imageCount: gallery.size,
isReply: replyTo != null,
hasLink: extLink != null,
hasQuote: quote != null,
langs: langPrefs.postLanguage,
logContext: 'Composer',
})
}
track('Create Post', { track('Create Post', {
imageCount: gallery.size, imageCount: gallery.size,
}) })
@@ -488,6 +497,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) {
+133 -132
View File
@@ -6,14 +6,11 @@ import {RichText} from '#/components/RichText'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {s} from 'lib/styles' import {s} from 'lib/styles'
import {UserAvatar} from '../util/UserAvatar' import {UserAvatar} from '../util/UserAvatar'
import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from 'lib/routes/types'
import {pluralize} from 'lib/strings/helpers' import {pluralize} from 'lib/strings/helpers'
import {AtUri} from '@atproto/api' import {AtUri} from '@atproto/api'
import * as Toast from 'view/com/util/Toast' import * as Toast from 'view/com/util/Toast'
import {sanitizeHandle} from 'lib/strings/handles' import {sanitizeHandle} from 'lib/strings/handles'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useModalControls} from '#/state/modals'
import {Trans, msg} from '@lingui/macro' import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import { import {
@@ -26,6 +23,8 @@ import {
import {useFeedSourceInfoQuery, FeedSourceInfo} from '#/state/queries/feed' import {useFeedSourceInfoQuery, FeedSourceInfo} from '#/state/queries/feed'
import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {useTheme} from '#/alf' import {useTheme} from '#/alf'
import * as Prompt from '#/components/Prompt'
import {useNavigationDeduped} from 'lib/hooks/useNavigationDeduped'
export function FeedSourceCard({ export function FeedSourceCard({
feedUri, feedUri,
@@ -86,8 +85,8 @@ export function FeedSourceCardLoaded({
const t = useTheme() const t = useTheme()
const pal = usePalette('default') const pal = usePalette('default')
const {_} = useLingui() const {_} = useLingui()
const navigation = useNavigation<NavigationProp>() const removePromptControl = Prompt.usePromptControl()
const {openModal} = useModalControls() const navigation = useNavigationDeduped()
const {isPending: isSavePending, mutateAsync: saveFeed} = const {isPending: isSavePending, mutateAsync: saveFeed} =
useSaveFeedMutation() useSaveFeedMutation()
@@ -97,40 +96,45 @@ export function FeedSourceCardLoaded({
const isSaved = Boolean(preferences?.feeds?.saved?.includes(feed?.uri || '')) const isSaved = Boolean(preferences?.feeds?.saved?.includes(feed?.uri || ''))
const onSave = React.useCallback(async () => {
if (!feed) return
try {
if (pinOnSave) {
await pinFeed({uri: feed.uri})
} else {
await saveFeed({uri: feed.uri})
}
Toast.show(_(msg`Added to my feeds`))
} catch (e) {
Toast.show(_(msg`There was an issue contacting your server`))
logger.error('Failed to save feed', {message: e})
}
}, [_, feed, pinFeed, pinOnSave, saveFeed])
const onUnsave = React.useCallback(async () => {
if (!feed) return
try {
await removeFeed({uri: feed.uri})
// await item.unsave()
Toast.show(_(msg`Removed from my feeds`))
} catch (e) {
Toast.show(_(msg`There was an issue contacting your server`))
logger.error('Failed to unsave feed', {message: e})
}
}, [_, feed, removeFeed])
const onToggleSaved = React.useCallback(async () => { const onToggleSaved = React.useCallback(async () => {
// Only feeds can be un/saved, lists are handled elsewhere // Only feeds can be un/saved, lists are handled elsewhere
if (feed?.type !== 'feed') return if (feed?.type !== 'feed') return
if (isSaved) { if (isSaved) {
openModal({ removePromptControl.open()
name: 'confirm',
title: _(msg`Remove from my feeds`),
message: _(msg`Remove ${feed?.displayName} from my feeds?`),
onPressConfirm: async () => {
try {
await removeFeed({uri: feed.uri})
// await item.unsave()
Toast.show(_(msg`Removed from my feeds`))
} catch (e) {
Toast.show(_(msg`There was an issue contacting your server`))
logger.error('Failed to unsave feed', {message: e})
}
},
})
} else { } else {
try { await onSave()
if (pinOnSave) {
await pinFeed({uri: feed.uri})
} else {
await saveFeed({uri: feed.uri})
}
Toast.show(_(msg`Added to my feeds`))
} catch (e) {
Toast.show(_(msg`There was an issue contacting your server`))
logger.error('Failed to save feed', {message: e})
}
} }
}, [isSaved, openModal, feed, removeFeed, saveFeed, _, pinOnSave, pinFeed]) }, [feed?.type, isSaved, removePromptControl, onSave])
/* /*
* LOAD STATE * LOAD STATE
@@ -168,25 +172,7 @@ export function FeedSourceCardLoaded({
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={_(msg`Remove from my feeds`)} accessibilityLabel={_(msg`Remove from my feeds`)}
accessibilityHint="" accessibilityHint=""
onPress={() => { onPress={onToggleSaved}
openModal({
name: 'confirm',
title: _(msg`Remove from my feeds`),
message: _(msg`Remove this feed from my feeds?`),
onPressConfirm: async () => {
try {
await removeFeed({uri: feedUri})
// await item.unsave()
Toast.show(_(msg`Removed from my feeds`))
} catch (e) {
Toast.show(
_(msg`There was an issue contacting your server`),
)
logger.error('Failed to unsave feed', {message: e})
}
},
})
}}
hitSlop={15} hitSlop={15}
style={styles.btn}> style={styles.btn}>
<FontAwesomeIcon <FontAwesomeIcon
@@ -200,89 +186,104 @@ export function FeedSourceCardLoaded({
) )
return ( return (
<Pressable <>
testID={`feed-${feed.displayName}`} <Pressable
accessibilityRole="button" testID={`feed-${feed.displayName}`}
style={[styles.container, pal.border, style]} accessibilityRole="button"
onPress={() => { style={[styles.container, pal.border, style]}
if (feed.type === 'feed') { onPress={() => {
navigation.push('ProfileFeed', { if (feed.type === 'feed') {
name: feed.creatorDid, navigation.push('ProfileFeed', {
rkey: new AtUri(feed.uri).rkey, name: feed.creatorDid,
}) rkey: new AtUri(feed.uri).rkey,
} else if (feed.type === 'list') { })
navigation.push('ProfileList', { } else if (feed.type === 'list') {
name: feed.creatorDid, navigation.push('ProfileList', {
rkey: new AtUri(feed.uri).rkey, name: feed.creatorDid,
}) rkey: new AtUri(feed.uri).rkey,
} })
}} }
key={feed.uri}> }}
<View style={[styles.headerContainer]}> key={feed.uri}>
<View style={[s.mr10]}> <View style={[styles.headerContainer]}>
<UserAvatar type="algo" size={36} avatar={feed.avatar} /> <View style={[s.mr10]}>
</View> <UserAvatar type="algo" size={36} avatar={feed.avatar} />
<View style={[styles.headerTextContainer]}>
<Text style={[pal.text, s.bold]} numberOfLines={3}>
{feed.displayName}
</Text>
<Text style={[pal.textLight]} numberOfLines={3}>
{feed.type === 'feed' ? (
<Trans>Feed by {sanitizeHandle(feed.creatorHandle, '@')}</Trans>
) : (
<Trans>List by {sanitizeHandle(feed.creatorHandle, '@')}</Trans>
)}
</Text>
</View>
{showSaveBtn && feed.type === 'feed' && (
<View style={[s.justifyCenter]}>
<Pressable
testID={`feed-${feed.displayName}-toggleSave`}
disabled={isSavePending || isPinPending || isRemovePending}
accessibilityRole="button"
accessibilityLabel={
isSaved ? _(msg`Remove from my feeds`) : _(msg`Add to my feeds`)
}
accessibilityHint=""
onPress={onToggleSaved}
hitSlop={15}
style={styles.btn}>
{isSaved ? (
<FontAwesomeIcon
icon={['far', 'trash-can']}
size={19}
color={pal.colors.icon}
/>
) : (
<FontAwesomeIcon
icon="plus"
size={18}
color={pal.colors.link}
/>
)}
</Pressable>
</View> </View>
<View style={[styles.headerTextContainer]}>
<Text style={[pal.text, s.bold]} numberOfLines={3}>
{feed.displayName}
</Text>
<Text style={[pal.textLight]} numberOfLines={3}>
{feed.type === 'feed' ? (
<Trans>Feed by {sanitizeHandle(feed.creatorHandle, '@')}</Trans>
) : (
<Trans>List by {sanitizeHandle(feed.creatorHandle, '@')}</Trans>
)}
</Text>
</View>
{showSaveBtn && feed.type === 'feed' && (
<View style={[s.justifyCenter]}>
<Pressable
testID={`feed-${feed.displayName}-toggleSave`}
disabled={isSavePending || isPinPending || isRemovePending}
accessibilityRole="button"
accessibilityLabel={
isSaved
? _(msg`Remove from my feeds`)
: _(msg`Add to my feeds`)
}
accessibilityHint=""
onPress={onToggleSaved}
hitSlop={15}
style={styles.btn}>
{isSaved ? (
<FontAwesomeIcon
icon={['far', 'trash-can']}
size={19}
color={pal.colors.icon}
/>
) : (
<FontAwesomeIcon
icon="plus"
size={18}
color={pal.colors.link}
/>
)}
</Pressable>
</View>
)}
</View>
{showDescription && feed.description ? (
<RichText
style={[t.atoms.text_contrast_high, styles.description]}
value={feed.description}
numberOfLines={3}
/>
) : null}
{showLikes && feed.type === 'feed' ? (
<Text type="sm-medium" style={[pal.text, pal.textLight]}>
<Trans>
Liked by {feed.likeCount || 0}{' '}
{pluralize(feed.likeCount || 0, 'user')}
</Trans>
</Text>
) : null}
</Pressable>
<Prompt.Basic
control={removePromptControl}
title={_(msg`Remove from my feeds?`)}
description={_(
msg`Are you sure you want to remove ${feed.displayName} from your feeds?`,
)} )}
</View> onConfirm={onUnsave}
confirmButtonCta={_(msg`Remove`)}
{showDescription && feed.description ? ( confirmButtonColor="negative"
<RichText />
style={[t.atoms.text_contrast_high, styles.description]} </>
value={feed.description}
numberOfLines={3}
/>
) : null}
{showLikes && feed.type === 'feed' ? (
<Text type="sm-medium" style={[pal.text, pal.textLight]}>
<Trans>
Liked by {feed.likeCount || 0}{' '}
{pluralize(feed.likeCount || 0, 'user')}
</Trans>
</Text>
) : null}
</Pressable>
) )
} }
-151
View File
@@ -1,151 +0,0 @@
import React, {useState} from 'react'
import {
ActivityIndicator,
StyleSheet,
TouchableOpacity,
View,
} from 'react-native'
import {Text} from '../util/text/Text'
import {DateInput} from '../util/forms/DateInput'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {s, colors} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette'
import {isWeb} from 'platform/detection'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {cleanError} from 'lib/strings/errors'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useModalControls} from '#/state/modals'
import {
usePreferencesQuery,
usePreferencesSetBirthDateMutation,
UsePreferencesQueryResponse,
} from '#/state/queries/preferences'
import {logger} from '#/logger'
export const snapPoints = ['50%', '90%']
function Inner({preferences}: {preferences: UsePreferencesQueryResponse}) {
const pal = usePalette('default')
const {isMobile} = useWebMediaQueries()
const {_} = useLingui()
const {
isPending,
isError,
error,
mutateAsync: setBirthDate,
} = usePreferencesSetBirthDateMutation()
const [date, setDate] = useState(preferences.birthDate || new Date())
const {closeModal} = useModalControls()
const onSave = React.useCallback(async () => {
try {
await setBirthDate({birthDate: date})
closeModal()
} catch (e) {
logger.error(`setBirthDate failed`, {message: e})
}
}, [date, setBirthDate, closeModal])
return (
<View
testID="birthDateSettingsModal"
style={[pal.view, styles.container, isMobile && {paddingHorizontal: 18}]}>
<View style={styles.titleSection}>
<Text type="title-lg" style={[pal.text, styles.title]}>
<Trans>My Birthday</Trans>
</Text>
</View>
<Text type="lg" style={[pal.textLight, {marginBottom: 10}]}>
<Trans>This information is not shared with other users.</Trans>
</Text>
<View>
<DateInput
handleAsUTC
testID="birthdayInput"
value={date}
onChange={setDate}
buttonType="default-light"
buttonStyle={[pal.border, styles.dateInputButton]}
buttonLabelType="lg"
accessibilityLabel={_(msg`Birthday`)}
accessibilityHint={_(msg`Enter your birth date`)}
accessibilityLabelledBy="birthDate"
/>
</View>
{isError ? (
<ErrorMessage message={cleanError(error)} style={styles.error} />
) : undefined}
<View style={[styles.btnContainer, pal.borderDark]}>
{isPending ? (
<View style={styles.btn}>
<ActivityIndicator color="#fff" />
</View>
) : (
<TouchableOpacity
testID="confirmBtn"
onPress={onSave}
style={styles.btn}
accessibilityRole="button"
accessibilityLabel={_(msg`Save`)}
accessibilityHint="">
<Text style={[s.white, s.bold, s.f18]}>
<Trans>Save</Trans>
</Text>
</TouchableOpacity>
)}
</View>
</View>
)
}
export function Component({}: {}) {
const {data: preferences} = usePreferencesQuery()
return !preferences ? (
<ActivityIndicator />
) : (
<Inner preferences={preferences} />
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
paddingBottom: isWeb ? 0 : 40,
},
titleSection: {
paddingTop: isWeb ? 0 : 4,
paddingBottom: isWeb ? 14 : 10,
},
title: {
textAlign: 'center',
fontWeight: '600',
marginBottom: 5,
},
error: {
borderRadius: 6,
marginTop: 10,
},
dateInputButton: {
borderWidth: 1,
borderRadius: 6,
paddingVertical: 14,
},
btn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 32,
padding: 14,
backgroundColor: colors.blue3,
},
btnContainer: {
paddingTop: 20,
paddingHorizontal: 20,
},
})
-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,
},
})
@@ -24,6 +24,8 @@ import {
CONFIGURABLE_LABEL_GROUPS, CONFIGURABLE_LABEL_GROUPS,
UsePreferencesQueryResponse, UsePreferencesQueryResponse,
} from '#/state/queries/preferences' } from '#/state/queries/preferences'
import {useDialogControl} from '#/components/Dialog'
import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings'
export const snapPoints = ['90%'] export const snapPoints = ['90%']
@@ -107,11 +109,11 @@ function AdultContentEnabledPref() {
const {_} = useLingui() const {_} = useLingui()
const {data: preferences} = usePreferencesQuery() const {data: preferences} = usePreferencesQuery()
const {mutate, variables} = usePreferencesSetAdultContentMutation() const {mutate, variables} = usePreferencesSetAdultContentMutation()
const {openModal} = useModalControls() const bithdayDialogControl = useDialogControl()
const onSetAge = React.useCallback( const onSetAge = React.useCallback(
() => openModal({name: 'birth-date-settings'}), () => bithdayDialogControl.open(),
[openModal], [bithdayDialogControl],
) )
const onToggleAdultContent = React.useCallback(async () => { const onToggleAdultContent = React.useCallback(async () => {
@@ -135,6 +137,10 @@ function AdultContentEnabledPref() {
return ( return (
<View style={s.mb10}> <View style={s.mb10}>
<BirthDateSettingsDialog
control={bithdayDialogControl}
preferences={preferences}
/>
{isIOS ? ( {isIOS ? (
preferences?.adultContentEnabled ? null : ( preferences?.adultContentEnabled ? null : (
<Text type="md" style={pal.textLight}> <Text type="md" style={pal.textLight}>
+1 -9
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'
@@ -26,7 +25,6 @@ import * as ContentFilteringSettingsModal from './ContentFilteringSettings'
import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
import * as ModerationDetailsModal from './ModerationDetails' import * as ModerationDetailsModal from './ModerationDetails'
import * as BirthDateSettingsModal from './BirthDateSettings'
import * as VerifyEmailModal from './VerifyEmail' import * as VerifyEmailModal from './VerifyEmail'
import * as ChangeEmailModal from './ChangeEmail' import * as ChangeEmailModal from './ChangeEmail'
import * as ChangePasswordModal from './ChangePassword' import * as ChangePasswordModal from './ChangePassword'
@@ -66,10 +64,7 @@ export function ModalsContainer() {
let snapPoints: (string | number)[] = DEFAULT_SNAPPOINTS let snapPoints: (string | number)[] = DEFAULT_SNAPPOINTS
let element let element
if (activeModal?.name === 'confirm') { if (activeModal?.name === 'edit-profile') {
snapPoints = ConfirmModal.snapPoints
element = <ConfirmModal.Component {...activeModal} />
} else if (activeModal?.name === 'edit-profile') {
snapPoints = EditProfileModal.snapPoints snapPoints = EditProfileModal.snapPoints
element = <EditProfileModal.Component {...activeModal} /> element = <EditProfileModal.Component {...activeModal} />
} else if (activeModal?.name === 'report') { } else if (activeModal?.name === 'report') {
@@ -126,9 +121,6 @@ export function ModalsContainer() {
} else if (activeModal?.name === 'moderation-details') { } else if (activeModal?.name === 'moderation-details') {
snapPoints = ModerationDetailsModal.snapPoints snapPoints = ModerationDetailsModal.snapPoints
element = <ModerationDetailsModal.Component {...activeModal} /> element = <ModerationDetailsModal.Component {...activeModal} />
} else if (activeModal?.name === 'birth-date-settings') {
snapPoints = BirthDateSettingsModal.snapPoints
element = <BirthDateSettingsModal.Component />
} else if (activeModal?.name === 'verify-email') { } else if (activeModal?.name === 'verify-email') {
snapPoints = VerifyEmailModal.snapPoints snapPoints = VerifyEmailModal.snapPoints
element = <VerifyEmailModal.Component {...activeModal} /> element = <VerifyEmailModal.Component {...activeModal} />
+1 -7
View File
@@ -7,7 +7,6 @@ import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock'
import {useModals, useModalControls} from '#/state/modals' import {useModals, useModalControls} from '#/state/modals'
import type {Modal as ModalIface} from '#/state/modals' import type {Modal as ModalIface} from '#/state/modals'
import * as ConfirmModal from './Confirm'
import * as EditProfileModal from './EditProfile' import * as EditProfileModal from './EditProfile'
import * as ReportModal from './report/Modal' import * as ReportModal from './report/Modal'
import * as AppealLabelModal from './AppealLabel' import * as AppealLabelModal from './AppealLabel'
@@ -28,7 +27,6 @@ import * as ContentFilteringSettingsModal from './ContentFilteringSettings'
import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
import * as ModerationDetailsModal from './ModerationDetails' import * as ModerationDetailsModal from './ModerationDetails'
import * as BirthDateSettingsModal from './BirthDateSettings'
import * as VerifyEmailModal from './VerifyEmail' import * as VerifyEmailModal from './VerifyEmail'
import * as ChangeEmailModal from './ChangeEmail' import * as ChangeEmailModal from './ChangeEmail'
import * as ChangePasswordModal from './ChangePassword' import * as ChangePasswordModal from './ChangePassword'
@@ -78,9 +76,7 @@ function Modal({modal}: {modal: ModalIface}) {
} }
let element let element
if (modal.name === 'confirm') { if (modal.name === 'edit-profile') {
element = <ConfirmModal.Component {...modal} />
} else if (modal.name === 'edit-profile') {
element = <EditProfileModal.Component {...modal} /> element = <EditProfileModal.Component {...modal} />
} else if (modal.name === 'report') { } else if (modal.name === 'report') {
element = <ReportModal.Component {...modal} /> element = <ReportModal.Component {...modal} />
@@ -120,8 +116,6 @@ function Modal({modal}: {modal: ModalIface}) {
element = <EditImageModal.Component {...modal} /> element = <EditImageModal.Component {...modal} />
} else if (modal.name === 'moderation-details') { } else if (modal.name === 'moderation-details') {
element = <ModerationDetailsModal.Component {...modal} /> element = <ModerationDetailsModal.Component {...modal} />
} else if (modal.name === 'birth-date-settings') {
element = <BirthDateSettingsModal.Component />
} else if (modal.name === 'verify-email') { } else if (modal.name === 'verify-email') {
element = <VerifyEmailModal.Component {...modal} /> element = <VerifyEmailModal.Component {...modal} />
} else if (modal.name === 'change-email') { } else if (modal.name === 'change-email') {
-1
View File
@@ -182,7 +182,6 @@ let FeedItem = ({
testID={`feedItem-by-${item.notification.author.handle}`} testID={`feedItem-by-${item.notification.author.handle}`}
style={[ style={[
styles.outer, styles.outer,
pal.view,
pal.border, pal.border,
item.notification.isRead item.notification.isRead
? undefined ? undefined
@@ -1,42 +0,0 @@
// FixedTouchableHighlight.tsx
import React, {ComponentProps, useRef} from 'react'
import {GestureResponderEvent, TouchableHighlight} from 'react-native'
type Position = {pageX: number; pageY: number}
export default function FixedTouchableHighlight({
onPress,
onPressIn,
...props
}: ComponentProps<typeof TouchableHighlight>) {
const _touchActivatePositionRef = useRef<Position | null>(null)
function _onPressIn(e: GestureResponderEvent) {
const {pageX, pageY} = e.nativeEvent
_touchActivatePositionRef.current = {
pageX,
pageY,
}
onPressIn?.(e)
}
function _onPress(e: GestureResponderEvent) {
const {pageX, pageY} = e.nativeEvent
const absX = Math.abs(_touchActivatePositionRef.current?.pageX! - pageX)
const absY = Math.abs(_touchActivatePositionRef.current?.pageY! - pageY)
const dragged = absX > 2 || absY > 2
if (!dragged) {
onPress?.(e)
}
}
return (
<TouchableHighlight onPressIn={_onPressIn} onPress={_onPress} {...props}>
{props.children}
</TouchableHighlight>
)
}
@@ -42,7 +42,10 @@ function PostThreadFollowBtnLoaded({
const {isTabletOrDesktop} = useWebMediaQueries() const {isTabletOrDesktop} = useWebMediaQueries()
const profile: Shadow<AppBskyActorDefs.ProfileViewBasic> = const profile: Shadow<AppBskyActorDefs.ProfileViewBasic> =
useProfileShadow(profileUnshadowed) useProfileShadow(profileUnshadowed)
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile) const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
profile,
'PostThreadItem',
)
const requireAuth = useRequireAuth() const requireAuth = useRequireAuth()
const isFollowing = !!profile.viewer?.following const isFollowing = !!profile.viewer?.following
+2 -3
View File
@@ -407,6 +407,7 @@ let PostThreadItemLoaded = ({
record={record} record={record}
richText={richText} richText={richText}
onPressReply={onPressReply} onPressReply={onPressReply}
logContext="PostThreadItem"
/> />
</View> </View>
</View> </View>
@@ -431,7 +432,6 @@ let PostThreadItemLoaded = ({
<PostHider <PostHider
testID={`postThreadItem-by-${post.author.handle}`} testID={`postThreadItem-by-${post.author.handle}`}
href={postHref} href={postHref}
style={[pal.view]}
moderation={moderation.content} moderation={moderation.content}
iconSize={isThreadedChild ? 26 : 38} iconSize={isThreadedChild ? 26 : 38}
iconStyles={ iconStyles={
@@ -560,6 +560,7 @@ let PostThreadItemLoaded = ({
record={record} record={record}
richText={richText} richText={richText}
onPressReply={onPressReply} onPressReply={onPressReply}
logContext="PostThreadItem"
/> />
</View> </View>
</View> </View>
@@ -620,7 +621,6 @@ function PostOuterWrapper({
return ( return (
<View <View
style={[ style={[
pal.view,
pal.border, pal.border,
styles.cursor, styles.cursor,
{ {
@@ -648,7 +648,6 @@ function PostOuterWrapper({
<View <View
style={[ style={[
styles.outer, styles.outer,
pal.view,
pal.border, pal.border,
showParentReplyLine && hasPrecedingItem && styles.noTopBorder, showParentReplyLine && hasPrecedingItem && styles.noTopBorder,
styles.cursor, styles.cursor,
+2 -1
View File
@@ -133,7 +133,7 @@ function PostInner({
}, [setLimitLines]) }, [setLimitLines])
return ( return (
<Link href={itemHref} style={[styles.outer, pal.view, pal.border, style]}> <Link href={itemHref} style={[styles.outer, pal.border, style]}>
{showReplyLine && <View style={styles.replyLine} />} {showReplyLine && <View style={styles.replyLine} />}
<View style={styles.layout}> <View style={styles.layout}>
<View style={styles.layoutAvi}> <View style={styles.layoutAvi}>
@@ -220,6 +220,7 @@ function PostInner({
record={record} record={record}
richText={richText} richText={richText}
onPressReply={onPressReply} onPressReply={onPressReply}
logContext="Post"
/> />
</View> </View>
</View> </View>
+15 -1
View File
@@ -33,6 +33,7 @@ import {useLingui} from '@lingui/react'
import {DiscoverFallbackHeader} from './DiscoverFallbackHeader' import {DiscoverFallbackHeader} from './DiscoverFallbackHeader'
import {FALLBACK_MARKER_POST} from '#/lib/api/feed/home' import {FALLBACK_MARKER_POST} from '#/lib/api/feed/home'
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender' import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
import {logEvent} from '#/lib/statsig/statsig'
const LOADING_ITEM = {_reactKey: '__loading__'} const LOADING_ITEM = {_reactKey: '__loading__'}
const EMPTY_FEED_ITEM = {_reactKey: '__empty__'} const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
@@ -223,16 +224,29 @@ let Feed = ({
setIsPTRing(false) setIsPTRing(false)
}, [refetch, track, setIsPTRing, onHasNew]) }, [refetch, track, setIsPTRing, onHasNew])
const feedType = feed.split('|')[0]
const onEndReached = React.useCallback(async () => { const onEndReached = React.useCallback(async () => {
if (isFetching || !hasNextPage || isError) return if (isFetching || !hasNextPage || isError) return
logEvent('feed:endReached', {
feedType: feedType,
itemCount: feedItems.length,
})
track('Feed:onEndReached') track('Feed:onEndReached')
try { try {
await fetchNextPage() await fetchNextPage()
} catch (err) { } catch (err) {
logger.error('Failed to load more posts', {message: err}) logger.error('Failed to load more posts', {message: err})
} }
}, [isFetching, hasNextPage, isError, fetchNextPage, track]) }, [
isFetching,
hasNextPage,
isError,
fetchNextPage,
track,
feedType,
feedItems.length,
])
const onPressTryAgain = React.useCallback(() => { const onPressTryAgain = React.useCallback(() => {
refetch() refetch()
+47 -42
View File
@@ -9,13 +9,13 @@ import {usePalette} from 'lib/hooks/usePalette'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
import {NavigationProp} from 'lib/routes/types' import {NavigationProp} from 'lib/routes/types'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useModalControls} from '#/state/modals'
import {msg as msgLingui, Trans} from '@lingui/macro' import {msg as msgLingui, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {FeedDescriptor} from '#/state/queries/post-feed' import {FeedDescriptor} from '#/state/queries/post-feed'
import {EmptyState} from '../util/EmptyState' import {EmptyState} from '../util/EmptyState'
import {cleanError} from '#/lib/strings/errors' import {cleanError} from '#/lib/strings/errors'
import {useRemoveFeedMutation} from '#/state/queries/preferences' import {useRemoveFeedMutation} from '#/state/queries/preferences'
import * as Prompt from '#/components/Prompt'
export enum KnownError { export enum KnownError {
Block = 'Block', Block = 'Block',
@@ -118,35 +118,29 @@ function FeedgenErrorMessage({
) )
const [_, uri] = feedDesc.split('|') const [_, uri] = feedDesc.split('|')
const [ownerDid] = safeParseFeedgenUri(uri) const [ownerDid] = safeParseFeedgenUri(uri)
const {openModal, closeModal} = useModalControls() const removePromptControl = Prompt.usePromptControl()
const {mutateAsync: removeFeed} = useRemoveFeedMutation() const {mutateAsync: removeFeed} = useRemoveFeedMutation()
const onViewProfile = React.useCallback(() => { const onViewProfile = React.useCallback(() => {
navigation.navigate('Profile', {name: ownerDid}) navigation.navigate('Profile', {name: ownerDid})
}, [navigation, ownerDid]) }, [navigation, ownerDid])
const onPressRemoveFeed = React.useCallback(() => {
removePromptControl.open()
}, [removePromptControl])
const onRemoveFeed = React.useCallback(async () => { const onRemoveFeed = React.useCallback(async () => {
openModal({ try {
name: 'confirm', await removeFeed({uri})
title: _l(msgLingui`Remove feed`), } catch (err) {
message: _l(msgLingui`Remove this feed from your saved feeds?`), Toast.show(
async onPressConfirm() { _l(
try { msgLingui`There was an an issue removing this feed. Please check your internet connection and try again.`,
await removeFeed({uri}) ),
} catch (err) { )
Toast.show( logger.error('Failed to remove feed', {message: err})
_l( }
msgLingui`There was an an issue removing this feed. Please check your internet connection and try again.`, }, [uri, removeFeed, _l])
),
)
logger.error('Failed to remove feed', {message: err})
}
},
onPressCancel() {
closeModal()
},
})
}, [openModal, closeModal, uri, removeFeed, _l])
const cta = React.useMemo(() => { const cta = React.useMemo(() => {
switch (knownError) { switch (knownError) {
@@ -179,27 +173,38 @@ function FeedgenErrorMessage({
}, [knownError, onViewProfile, onRemoveFeed, _l]) }, [knownError, onViewProfile, onRemoveFeed, _l])
return ( return (
<View <>
style={[ <View
pal.border, style={[
pal.viewLight, pal.border,
{ pal.viewLight,
borderTopWidth: 1, {
paddingHorizontal: 20, borderTopWidth: 1,
paddingVertical: 18, paddingHorizontal: 20,
gap: 12, paddingVertical: 18,
}, gap: 12,
]}> },
<Text style={pal.text}>{msg}</Text> ]}>
<Text style={pal.text}>{msg}</Text>
{rawError?.message && ( {rawError?.message && (
<Text style={pal.textLight}> <Text style={pal.textLight}>
<Trans>Message from server: {rawError.message}</Trans> <Trans>Message from server: {rawError.message}</Trans>
</Text> </Text>
)} )}
{cta} {cta}
</View> </View>
<Prompt.Basic
control={removePromptControl}
title={_l(msgLingui`Remove feed?`)}
description={_l(msgLingui`Remove this feed from your saved feeds`)}
onConfirm={onPressRemoveFeed}
confirmButtonCta={_l(msgLingui`Remove`)}
confirmButtonColor="negative"
/>
</>
) )
} }
+1 -1
View File
@@ -144,7 +144,6 @@ let FeedItemInner = ({
const outerStyles = [ const outerStyles = [
styles.outer, styles.outer,
pal.view,
{ {
borderColor: pal.colors.border, borderColor: pal.colors.border,
paddingBottom: paddingBottom:
@@ -310,6 +309,7 @@ let FeedItemInner = ({
showAppealLabelItem={ showAppealLabelItem={
post.author.did === currentAccount?.did && isModeratedPost post.author.did === currentAccount?.did && isModeratedPost
} }
logContext="FeedItem"
/> />
</View> </View>
</View> </View>
+1 -5
View File
@@ -78,11 +78,7 @@ function ViewFullThread({slice}: {slice: FeedPostSlice}) {
}, [slice.rootUri]) }, [slice.rootUri])
return ( return (
<Link <Link style={[styles.viewFullThread]} href={itemHref} asAnchor noFeedback>
style={[pal.view, styles.viewFullThread]}
href={itemHref}
asAnchor
noFeedback>
<View style={styles.viewFullThreadDots}> <View style={styles.viewFullThreadDots}>
<Svg width="4" height="40"> <Svg width="4" height="40">
<Line <Line
+6 -1
View File
@@ -13,13 +13,18 @@ export function FollowButton({
followedType = 'default', followedType = 'default',
profile, profile,
labelStyle, labelStyle,
logContext,
}: { }: {
unfollowedType?: ButtonType unfollowedType?: ButtonType
followedType?: ButtonType followedType?: ButtonType
profile: Shadow<AppBskyActorDefs.ProfileViewBasic> profile: Shadow<AppBskyActorDefs.ProfileViewBasic>
labelStyle?: StyleProp<TextStyle> labelStyle?: StyleProp<TextStyle>
logContext: 'ProfileCard'
}) { }) {
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile) const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
profile,
logContext,
)
const {_} = useLingui() const {_} = useLingui()
const onPressFollow = async () => { const onPressFollow = async () => {
+3 -1
View File
@@ -230,7 +230,9 @@ export function ProfileCardWithFollowBtn({
renderButton={ renderButton={
isMe isMe
? undefined ? undefined
: profileShadow => <FollowButton profile={profileShadow} /> : profileShadow => (
<FollowButton profile={profileShadow} logContext="ProfileCard" />
)
} }
/> />
) )
+34 -221
View File
@@ -7,7 +7,6 @@ import {
} from 'react-native' } from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
import { import {
AppBskyActorDefs, AppBskyActorDefs,
ModerationOpts, ModerationOpts,
@@ -17,7 +16,7 @@ import {
import {Trans, msg} from '@lingui/macro' import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {NavigationProp} from 'lib/routes/types' import {NavigationProp} from 'lib/routes/types'
import {isNative, isWeb} from 'platform/detection' import {isNative} from 'platform/detection'
import {BlurView} from '../util/BlurView' import {BlurView} from '../util/BlurView'
import * as Toast from '../util/Toast' import * as Toast from '../util/Toast'
import {LoadingPlaceholder} from '../util/LoadingPlaceholder' import {LoadingPlaceholder} from '../util/LoadingPlaceholder'
@@ -28,14 +27,11 @@ import {UserAvatar} from '../util/UserAvatar'
import {UserBanner} from '../util/UserBanner' import {UserBanner} from '../util/UserBanner'
import {ProfileHeaderAlerts} from '../util/moderation/ProfileHeaderAlerts' import {ProfileHeaderAlerts} from '../util/moderation/ProfileHeaderAlerts'
import {formatCount} from '../util/numeric/format' import {formatCount} from '../util/numeric/format'
import {NativeDropdown, DropdownItem} from '../util/forms/NativeDropdown'
import {Link} from '../util/Link' import {Link} from '../util/Link'
import {ProfileHeaderSuggestedFollows} from './ProfileHeaderSuggestedFollows' import {ProfileHeaderSuggestedFollows} from './ProfileHeaderSuggestedFollows'
import {useModalControls} from '#/state/modals' import {useModalControls} from '#/state/modals'
import {useLightboxControls, ProfileImageLightbox} from '#/state/lightbox' import {useLightboxControls, ProfileImageLightbox} from '#/state/lightbox'
import { import {
RQKEY as profileQueryKey,
useProfileMuteMutationQueue,
useProfileBlockMutationQueue, useProfileBlockMutationQueue,
useProfileFollowMutationQueue, useProfileFollowMutationQueue,
} from '#/state/queries/profile' } from '#/state/queries/profile'
@@ -46,9 +42,7 @@ import {BACK_HITSLOP} from 'lib/constants'
import {isInvalidHandle, sanitizeHandle} from 'lib/strings/handles' import {isInvalidHandle, sanitizeHandle} from 'lib/strings/handles'
import {makeProfileLink} from 'lib/routes/links' import {makeProfileLink} from 'lib/routes/links'
import {pluralize} from 'lib/strings/helpers' import {pluralize} from 'lib/strings/helpers'
import {toShareUrl} from 'lib/strings/url-helpers'
import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeDisplayName} from 'lib/strings/display-names'
import {shareUrl} from 'lib/sharing'
import {s, colors} from 'lib/styles' import {s, colors} from 'lib/styles'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
@@ -57,6 +51,8 @@ import {useRequireAuth} from '#/state/session'
import {LabelInfo} from '../util/moderation/LabelInfo' import {LabelInfo} from '../util/moderation/LabelInfo'
import {useProfileShadow} from 'state/cache/profile-shadow' import {useProfileShadow} from 'state/cache/profile-shadow'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
import {ProfileMenu} from 'view/com/profile/ProfileMenu'
import * as Prompt from '#/components/Prompt'
let ProfileHeaderLoading = (_props: {}): React.ReactNode => { let ProfileHeaderLoading = (_props: {}): React.ReactNode => {
const pal = usePalette('default') const pal = usePalette('default')
@@ -107,21 +103,17 @@ let ProfileHeader = ({
const invalidHandle = isInvalidHandle(profile.handle) const invalidHandle = isInvalidHandle(profile.handle)
const {isDesktop} = useWebMediaQueries() const {isDesktop} = useWebMediaQueries()
const [showSuggestedFollows, setShowSuggestedFollows] = React.useState(false) const [showSuggestedFollows, setShowSuggestedFollows] = React.useState(false)
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile) const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile) profile,
const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) 'ProfileHeader',
const queryClient = useQueryClient() )
const [__, queueUnblock] = useProfileBlockMutationQueue(profile)
const unblockPromptControl = Prompt.usePromptControl()
const moderation = useMemo( const moderation = useMemo(
() => moderateProfile(profile, moderationOpts), () => moderateProfile(profile, moderationOpts),
[profile, moderationOpts], [profile, moderationOpts],
) )
const invalidateProfileQuery = React.useCallback(() => {
queryClient.invalidateQueries({
queryKey: profileQueryKey(profile.did),
})
}, [queryClient, profile.did])
const onPressBack = React.useCallback(() => { const onPressBack = React.useCallback(() => {
if (navigation.canGoBack()) { if (navigation.canGoBack()) {
navigation.goBack() navigation.goBack()
@@ -189,204 +181,23 @@ let ProfileHeader = ({
}) })
}, [track, openModal, profile]) }, [track, openModal, profile])
const onPressShare = React.useCallback(() => { const unblockAccount = React.useCallback(async () => {
track('ProfileHeader:ShareButtonClicked')
shareUrl(toShareUrl(makeProfileLink(profile)))
}, [track, profile])
const onPressAddRemoveLists = React.useCallback(() => {
track('ProfileHeader:AddToListsButtonClicked')
openModal({
name: 'user-add-remove-lists',
subject: profile.did,
handle: profile.handle,
displayName: profile.displayName || profile.handle,
onAdd: invalidateProfileQuery,
onRemove: invalidateProfileQuery,
})
}, [track, profile, openModal, invalidateProfileQuery])
const onPressMuteAccount = React.useCallback(async () => {
track('ProfileHeader:MuteAccountButtonClicked')
try {
await queueMute()
Toast.show(_(msg`Account muted`))
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('Failed to mute account', {message: e})
Toast.show(_(msg`There was an issue! ${e.toString()}`))
}
}
}, [track, queueMute, _])
const onPressUnmuteAccount = React.useCallback(async () => {
track('ProfileHeader:UnmuteAccountButtonClicked')
try {
await queueUnmute()
Toast.show(_(msg`Account unmuted`))
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('Failed to unmute account', {message: e})
Toast.show(_(msg`There was an issue! ${e.toString()}`))
}
}
}, [track, queueUnmute, _])
const onPressBlockAccount = React.useCallback(async () => {
track('ProfileHeader:BlockAccountButtonClicked')
openModal({
name: 'confirm',
title: _(msg`Block Account`),
message: _(
msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`,
),
onPressConfirm: async () => {
try {
await queueBlock()
Toast.show(_(msg`Account blocked`))
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('Failed to block account', {message: e})
Toast.show(_(msg`There was an issue! ${e.toString()}`))
}
}
},
})
}, [track, queueBlock, openModal, _])
const onPressUnblockAccount = React.useCallback(async () => {
track('ProfileHeader:UnblockAccountButtonClicked') track('ProfileHeader:UnblockAccountButtonClicked')
openModal({ try {
name: 'confirm', await queueUnblock()
title: _(msg`Unblock Account`), Toast.show(_(msg`Account unblocked`))
message: _( } catch (e: any) {
msg`The account will be able to interact with you after unblocking.`, if (e?.name !== 'AbortError') {
), logger.error('Failed to unblock account', {message: e})
onPressConfirm: async () => { Toast.show(_(msg`There was an issue! ${e.toString()}`))
try { }
await queueUnblock() }
Toast.show(_(msg`Account unblocked`)) }, [_, queueUnblock, track])
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('Failed to unblock account', {message: e})
Toast.show(_(msg`There was an issue! ${e.toString()}`))
}
}
},
})
}, [track, queueUnblock, openModal, _])
const onPressReportAccount = React.useCallback(() => {
track('ProfileHeader:ReportAccountButtonClicked')
openModal({
name: 'report',
did: profile.did,
})
}, [track, openModal, profile])
const isMe = React.useMemo( const isMe = React.useMemo(
() => currentAccount?.did === profile.did, () => currentAccount?.did === profile.did,
[currentAccount, profile], [currentAccount, profile],
) )
const dropdownItems: DropdownItem[] = React.useMemo(() => {
let items: DropdownItem[] = [
{
testID: 'profileHeaderDropdownShareBtn',
label: isWeb ? _(msg`Copy link to profile`) : _(msg`Share`),
onPress: onPressShare,
icon: {
ios: {
name: 'square.and.arrow.up',
},
android: 'ic_menu_share',
web: 'share',
},
},
]
if (hasSession) {
items.push({label: 'separator'})
items.push({
testID: 'profileHeaderDropdownListAddRemoveBtn',
label: _(msg`Add to Lists`),
onPress: onPressAddRemoveLists,
icon: {
ios: {
name: 'list.bullet',
},
android: 'ic_menu_add',
web: 'list',
},
})
if (!isMe) {
if (!profile.viewer?.blocking) {
if (!profile.viewer?.mutedByList) {
items.push({
testID: 'profileHeaderDropdownMuteBtn',
label: profile.viewer?.muted
? _(msg`Unmute Account`)
: _(msg`Mute Account`),
onPress: profile.viewer?.muted
? onPressUnmuteAccount
: onPressMuteAccount,
icon: {
ios: {
name: 'speaker.slash',
},
android: 'ic_lock_silent_mode',
web: 'comment-slash',
},
})
}
}
if (!profile.viewer?.blockingByList) {
items.push({
testID: 'profileHeaderDropdownBlockBtn',
label: profile.viewer?.blocking
? _(msg`Unblock Account`)
: _(msg`Block Account`),
onPress: profile.viewer?.blocking
? onPressUnblockAccount
: onPressBlockAccount,
icon: {
ios: {
name: 'person.fill.xmark',
},
android: 'ic_menu_close_clear_cancel',
web: 'user-slash',
},
})
}
items.push({
testID: 'profileHeaderDropdownReportBtn',
label: _(msg`Report Account`),
onPress: onPressReportAccount,
icon: {
ios: {
name: 'exclamationmark.triangle',
},
android: 'ic_menu_report_image',
web: 'circle-exclamation',
},
})
}
}
return items
}, [
isMe,
hasSession,
profile.viewer?.muted,
profile.viewer?.mutedByList,
profile.viewer?.blocking,
profile.viewer?.blockingByList,
onPressShare,
onPressUnmuteAccount,
onPressMuteAccount,
onPressUnblockAccount,
onPressBlockAccount,
onPressReportAccount,
onPressAddRemoveLists,
_,
])
const blockHide = const blockHide =
!isMe && (profile.viewer?.blocking || profile.viewer?.blockedBy) !isMe && (profile.viewer?.blocking || profile.viewer?.blockedBy)
@@ -427,7 +238,7 @@ let ProfileHeader = ({
profile.viewer?.blockingByList ? null : ( profile.viewer?.blockingByList ? null : (
<TouchableOpacity <TouchableOpacity
testID="unblockBtn" testID="unblockBtn"
onPress={onPressUnblockAccount} onPress={() => unblockPromptControl.open()}
style={[styles.btn, styles.mainBtn, pal.btn]} style={[styles.btn, styles.mainBtn, pal.btn]}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={_(msg`Unblock`)} accessibilityLabel={_(msg`Unblock`)}
@@ -516,17 +327,7 @@ let ProfileHeader = ({
)} )}
</> </>
) : null} ) : null}
{dropdownItems?.length ? ( <ProfileMenu profile={profile} />
<NativeDropdown
testID="profileHeaderDropdownBtn"
items={dropdownItems}
accessibilityLabel={_(msg`More options`)}
accessibilityHint="">
<View style={[styles.btn, styles.secondaryBtn, pal.btn]}>
<FontAwesomeIcon icon="ellipsis" size={20} style={[pal.text]} />
</View>
</NativeDropdown>
) : undefined}
</View> </View>
<View pointerEvents="none"> <View pointerEvents="none">
<Text <Text
@@ -670,6 +471,18 @@ let ProfileHeader = ({
/> />
</View> </View>
</TouchableWithoutFeedback> </TouchableWithoutFeedback>
<Prompt.Basic
control={unblockPromptControl}
title={_(msg`Unblock Account?`)}
description={_(
msg`The account will be able to interact with you after unblocking.`,
)}
onConfirm={unblockAccount}
confirmButtonCta={
profile.viewer?.blocking ? _(msg`Unblock`) : _(msg`Block`)
}
confirmButtonColor="negative"
/>
</View> </View>
) )
} }
@@ -170,7 +170,10 @@ function SuggestedFollow({
const pal = usePalette('default') const pal = usePalette('default')
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const profile = useProfileShadow(profileUnshadowed) const profile = useProfileShadow(profileUnshadowed)
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile) const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
profile,
'ProfileHeaderSuggestedFollows',
)
const onPressFollow = React.useCallback(async () => { const onPressFollow = React.useCallback(async () => {
try { try {
+339
View File
@@ -0,0 +1,339 @@
import React, {memo} from 'react'
import {TouchableOpacity} from 'react-native'
import {AppBskyActorDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {useQueryClient} from '@tanstack/react-query'
import * as Toast from 'view/com/util/Toast'
import {EventStopper} from 'view/com/util/EventStopper'
import {useSession} from 'state/session'
import * as Menu from '#/components/Menu'
import {useTheme} from '#/alf'
import {usePalette} from 'lib/hooks/usePalette'
import {HITSLOP_10} from 'lib/constants'
import {shareUrl} from 'lib/sharing'
import {toShareUrl} from 'lib/strings/url-helpers'
import {makeProfileLink} from 'lib/routes/links'
import {useAnalytics} from 'lib/analytics/analytics'
import {useModalControls} from 'state/modals'
import {
RQKEY as profileQueryKey,
useProfileBlockMutationQueue,
useProfileFollowMutationQueue,
useProfileMuteMutationQueue,
} from 'state/queries/profile'
import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox'
import {ListSparkle_Stroke2_Corner0_Rounded as List} from '#/components/icons/ListSparkle'
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag'
import {PersonCheck_Stroke2_Corner0_Rounded as PersonCheck} from '#/components/icons/PersonCheck'
import {PersonX_Stroke2_Corner0_Rounded as PersonX} from '#/components/icons/PersonX'
import {PeopleRemove2_Stroke2_Corner0_Rounded as UserMinus} from '#/components/icons/PeopleRemove2'
import {logger} from '#/logger'
import {Shadow} from 'state/cache/types'
import * as Prompt from '#/components/Prompt'
let ProfileMenu = ({
profile,
}: {
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
}): React.ReactNode => {
const {_} = useLingui()
const {currentAccount, hasSession} = useSession()
const t = useTheme()
// TODO ALF this
const pal = usePalette('default')
const {track} = useAnalytics()
const {openModal} = useModalControls()
const queryClient = useQueryClient()
const isSelf = currentAccount?.did === profile.did
const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile)
const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
const [, queueUnfollow] = useProfileFollowMutationQueue(
profile,
'ProfileMenu',
)
const blockPromptControl = Prompt.usePromptControl()
const loggedOutWarningPromptControl = Prompt.usePromptControl()
const showLoggedOutWarning = React.useMemo(() => {
return !!profile.labels?.find(label => label.val === '!no-unauthenticated')
}, [profile.labels])
const invalidateProfileQuery = React.useCallback(() => {
queryClient.invalidateQueries({
queryKey: profileQueryKey(profile.did),
})
}, [queryClient, profile.did])
const onPressShare = React.useCallback(() => {
track('ProfileHeader:ShareButtonClicked')
shareUrl(toShareUrl(makeProfileLink(profile)))
}, [track, profile])
const onPressAddRemoveLists = React.useCallback(() => {
track('ProfileHeader:AddToListsButtonClicked')
openModal({
name: 'user-add-remove-lists',
subject: profile.did,
handle: profile.handle,
displayName: profile.displayName || profile.handle,
onAdd: invalidateProfileQuery,
onRemove: invalidateProfileQuery,
})
}, [track, profile, openModal, invalidateProfileQuery])
const onPressMuteAccount = React.useCallback(async () => {
if (profile.viewer?.muted) {
track('ProfileHeader:UnmuteAccountButtonClicked')
try {
await queueUnmute()
Toast.show(_(msg`Account unmuted`))
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('Failed to unmute account', {message: e})
Toast.show(_(msg`There was an issue! ${e.toString()}`))
}
}
} else {
track('ProfileHeader:MuteAccountButtonClicked')
try {
await queueMute()
Toast.show(_(msg`Account muted`))
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('Failed to mute account', {message: e})
Toast.show(_(msg`There was an issue! ${e.toString()}`))
}
}
}
}, [profile.viewer?.muted, track, queueUnmute, _, queueMute])
const blockAccount = React.useCallback(async () => {
if (profile.viewer?.blocking) {
track('ProfileHeader:UnblockAccountButtonClicked')
try {
await queueUnblock()
Toast.show(_(msg`Account unblocked`))
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('Failed to unblock account', {message: e})
Toast.show(_(msg`There was an issue! ${e.toString()}`))
}
}
} else {
track('ProfileHeader:BlockAccountButtonClicked')
try {
await queueBlock()
Toast.show(_(msg`Account blocked`))
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('Failed to block account', {message: e})
Toast.show(_(msg`There was an issue! ${e.toString()}`))
}
}
}
}, [profile.viewer?.blocking, track, _, queueUnblock, queueBlock])
const onPressUnfollowAccount = React.useCallback(async () => {
track('ProfileHeader:UnfollowButtonClicked')
try {
await queueUnfollow()
Toast.show(_(msg`Account unfollowed`))
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('Failed to unfollow account', {message: e})
Toast.show(_(msg`There was an issue! ${e.toString()}`))
}
}
}, [_, queueUnfollow, track])
const onPressReportAccount = React.useCallback(() => {
track('ProfileHeader:ReportAccountButtonClicked')
openModal({
name: 'report',
did: profile.did,
})
}, [track, openModal, profile])
return (
<EventStopper onKeyDown={false}>
<Menu.Root>
<Menu.Trigger label={_(`More options`)}>
{({props}) => {
return (
<TouchableOpacity
{...props}
hitSlop={HITSLOP_10}
testID="profileHeaderDropdownBtn"
style={[
{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 7,
borderRadius: 50,
marginLeft: 6,
paddingHorizontal: 14,
},
pal.btn,
]}>
<FontAwesomeIcon
icon="ellipsis"
size={20}
style={t.atoms.text}
/>
</TouchableOpacity>
)
}}
</Menu.Trigger>
<Menu.Outer style={{minWidth: 170}}>
<Menu.Group>
<Menu.Item
testID="profileHeaderDropdownShareBtn"
label={_(msg`Share`)}
onPress={() => {
if (showLoggedOutWarning) {
loggedOutWarningPromptControl.open()
} else {
onPressShare()
}
}}>
<Menu.ItemText>
<Trans>Share</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Share} />
</Menu.Item>
</Menu.Group>
{hasSession && (
<>
<Menu.Divider />
<Menu.Group>
<Menu.Item
testID="profileHeaderDropdownListAddRemoveBtn"
label={_(msg`Add to Lists`)}
onPress={onPressAddRemoveLists}>
<Menu.ItemText>
<Trans>Add to Lists</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={List} />
</Menu.Item>
{!isSelf && (
<>
{profile.viewer?.following &&
(profile.viewer.blocking || profile.viewer.blockedBy) && (
<Menu.Item
testID="profileHeaderDropdownUnfollowBtn"
label={_(msg`Unfollow Account`)}
onPress={onPressUnfollowAccount}>
<Menu.ItemText>
<Trans>Unfollow Account</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={UserMinus} />
</Menu.Item>
)}
{!profile.viewer?.blocking &&
!profile.viewer?.mutedByList && (
<Menu.Item
testID="profileHeaderDropdownMuteBtn"
label={
profile.viewer?.muted
? _(msg`Unmute Account`)
: _(msg`Mute Account`)
}
onPress={onPressMuteAccount}>
<Menu.ItemText>
{profile.viewer?.muted ? (
<Trans>Unmute Account</Trans>
) : (
<Trans>Mute Account</Trans>
)}
</Menu.ItemText>
<Menu.ItemIcon
icon={profile.viewer?.muted ? Unmute : Mute}
/>
</Menu.Item>
)}
{!profile.viewer?.blockingByList && (
<Menu.Item
testID="profileHeaderDropdownBlockBtn"
label={
profile.viewer
? _(msg`Unblock Account`)
: _(msg`Block Account`)
}
onPress={() => blockPromptControl.open()}>
<Menu.ItemText>
{profile.viewer?.blocking ? (
<Trans>Unblock Account</Trans>
) : (
<Trans>Block Account</Trans>
)}
</Menu.ItemText>
<Menu.ItemIcon
icon={
profile.viewer?.blocking ? PersonCheck : PersonX
}
/>
</Menu.Item>
)}
<Menu.Item
testID="profileHeaderDropdownReportBtn"
label={_(msg`Report Account`)}
onPress={onPressReportAccount}>
<Menu.ItemText>
<Trans>Report Account</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Flag} />
</Menu.Item>
</>
)}
</Menu.Group>
</>
)}
</Menu.Outer>
</Menu.Root>
<Prompt.Basic
control={blockPromptControl}
title={
profile.viewer?.blocking
? _(msg`Unblock Account?`)
: _(msg`Block Account?`)
}
description={
profile.viewer?.blocking
? _(
msg`The account will be able to interact with you after unblocking.`,
)
: _(
msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`,
)
}
onConfirm={blockAccount}
confirmButtonCta={
profile.viewer?.blocking ? _(msg`Unblock`) : _(msg`Block`)
}
confirmButtonColor={profile.viewer?.blocking ? undefined : 'negative'}
/>
<Prompt.Basic
control={loggedOutWarningPromptControl}
title={_(msg`Note about sharing`)}
description={_(
msg`This profile is only visible to logged-in users. It won't be visible to people who aren't logged in.`,
)}
onConfirm={onPressShare}
confirmButtonCta={_(msg`Share anyway`)}
/>
</EventStopper>
)
}
ProfileMenu = memo(ProfileMenu)
export {ProfileMenu}
+9 -2
View File
@@ -8,7 +8,14 @@ import {View, ViewStyle} from 'react-native'
export function EventStopper({ export function EventStopper({
children, children,
style, style,
}: React.PropsWithChildren<{style?: ViewStyle | ViewStyle[]}>) { onKeyDown = true,
}: React.PropsWithChildren<{
style?: ViewStyle | ViewStyle[]
/**
* Default `true`. Set to `false` to allow onKeyDown to propagate
*/
onKeyDown?: boolean
}>) {
const stop = (e: any) => { const stop = (e: any) => {
e.stopPropagation() e.stopPropagation()
} }
@@ -18,7 +25,7 @@ export function EventStopper({
onTouchEnd={stop} onTouchEnd={stop}
// @ts-ignore web only -prf // @ts-ignore web only -prf
onClick={stop} onClick={stop}
onKeyDown={stop} onKeyDown={onKeyDown ? stop : undefined}
style={style}> style={style}>
{children} {children}
</View> </View>
+17 -32
View File
@@ -8,17 +8,11 @@ import {
View, View,
ViewStyle, ViewStyle,
Pressable, Pressable,
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,
@@ -28,10 +22,14 @@ import {
import {isAndroid, isWeb} from 'platform/detection' import {isAndroid, isWeb} from 'platform/detection'
import {sanitizeUrl} from '@braintree/sanitize-url' import {sanitizeUrl} from '@braintree/sanitize-url'
import {PressableWithHover} from './PressableWithHover' import {PressableWithHover} from './PressableWithHover'
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'
import {useTheme} from '#/alf'
type Event = type Event =
| React.MouseEvent<HTMLAnchorElement, MouseEvent> | React.MouseEvent<HTMLAnchorElement, MouseEvent>
@@ -64,8 +62,9 @@ export const Link = memo(function Link({
navigationAction, navigationAction,
...props ...props
}: Props) { }: Props) {
const t = useTheme()
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()
@@ -86,37 +85,23 @@ export const Link = memo(function Link({
) )
if (noFeedback) { if (noFeedback) {
if (isAndroid) {
// workaround for Android not working well with left/right swipe gestures and TouchableWithoutFeedback
// https://github.com/callstack/react-native-pager-view/issues/424
return (
<FixedTouchableHighlight
testID={testID}
onPress={onPress}
// @ts-ignore web only -prf
href={asAnchor ? sanitizeUrl(href) : undefined}
accessible={accessible}
accessibilityRole="link"
{...props}>
<View style={style}>
{children ? children : <Text>{title || 'link'}</Text>}
</View>
</FixedTouchableHighlight>
)
}
return ( return (
<WebAuxClickWrapper> <WebAuxClickWrapper>
<TouchableWithoutFeedback <Pressable
testID={testID} testID={testID}
onPress={onPress} onPress={onPress}
accessible={accessible} accessible={accessible}
accessibilityRole="link" accessibilityRole="link"
{...props}> {...props}
android_ripple={{
color: t.atoms.bg_contrast_25.backgroundColor,
}}
unstable_pressDelay={isAndroid ? 90 : undefined}>
{/* @ts-ignore web only -prf */} {/* @ts-ignore web only -prf */}
<View style={style} href={anchorHref}> <View style={style} href={anchorHref}>
{children ? children : <Text>{title || 'link'}</Text>} {children ? children : <Text>{title || 'link'}</Text>}
</View> </View>
</TouchableWithoutFeedback> </Pressable>
</WebAuxClickWrapper> </WebAuxClickWrapper>
) )
} }
@@ -176,7 +161,7 @@ export const TextLink = memo(function TextLink({
navigationAction?: 'push' | 'replace' | 'navigate' navigationAction?: 'push' | 'replace' | 'navigate'
} & TextProps) { } & TextProps) {
const {...props} = useLinkProps({to: sanitizeUrl(href)}) const {...props} = useLinkProps({to: sanitizeUrl(href)})
const navigation = useNavigation<NavigationProp>() const navigation = useNavigationDeduped()
const {openModal, closeModal} = useModalControls() const {openModal, closeModal} = useModalControls()
const openLink = useOpenLink() const openLink = useOpenLink()
@@ -335,7 +320,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,
+119 -113
View File
@@ -1,9 +1,13 @@
import React, {memo, useMemo} from 'react' import React, {memo, useMemo} from 'react'
import {Image, StyleSheet, View} from 'react-native' import {Image, StyleSheet, TouchableOpacity, View} from 'react-native'
import Svg, {Circle, Rect, Path} from 'react-native-svg' import Svg, {Circle, Rect, Path} from 'react-native-svg'
import {Image as RNImage} from 'react-native-image-crop-picker'
import {useLingui} from '@lingui/react'
import {msg, Trans} from '@lingui/macro'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {HighPriorityImage} from 'view/com/util/images/Image'
import {ModerationUI} from '@atproto/api' import {ModerationUI} from '@atproto/api'
import {HighPriorityImage} from 'view/com/util/images/Image'
import {openCamera, openCropper, openPicker} from '../../../lib/media/picker' import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
import { import {
usePhotoLibraryPermission, usePhotoLibraryPermission,
@@ -11,12 +15,16 @@ import {
} from 'lib/hooks/usePermissions' } from 'lib/hooks/usePermissions'
import {colors} from 'lib/styles' import {colors} from 'lib/styles'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {isWeb, isAndroid} from 'platform/detection' import {isWeb, isAndroid, isNative} from 'platform/detection'
import {Image as RNImage} from 'react-native-image-crop-picker'
import {UserPreviewLink} from './UserPreviewLink' import {UserPreviewLink} from './UserPreviewLink'
import {DropdownItem, NativeDropdown} from './forms/NativeDropdown' import * as Menu from '#/components/Menu'
import {useLingui} from '@lingui/react' import {
import {msg} from '@lingui/macro' Camera_Stroke2_Corner0_Rounded as Camera,
Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled,
} from '#/components/icons/Camera'
import {StreamingLive_Stroke2_Corner0_Rounded as Library} from '#/components/icons/StreamingLive'
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
import {useTheme} from '#/alf'
export type UserAvatarType = 'user' | 'algo' | 'list' export type UserAvatarType = 'user' | 'algo' | 'list'
@@ -196,6 +204,7 @@ let EditableUserAvatar = ({
avatar, avatar,
onSelectNewAvatar, onSelectNewAvatar,
}: EditableUserAvatarProps): React.ReactNode => { }: EditableUserAvatarProps): React.ReactNode => {
const t = useTheme()
const pal = usePalette('default') const pal = usePalette('default')
const {_} = useLingui() const {_} = useLingui()
const {requestCameraAccessIfNeeded} = useCameraPermission() const {requestCameraAccessIfNeeded} = useCameraPermission()
@@ -216,118 +225,115 @@ let EditableUserAvatar = ({
} }
}, [type, size]) }, [type, size])
const dropdownItems = useMemo( const onOpenCamera = React.useCallback(async () => {
() => if (!(await requestCameraAccessIfNeeded())) {
[ return
!isWeb && { }
testID: 'changeAvatarCameraBtn',
label: _(msg`Camera`),
icon: {
ios: {
name: 'camera',
},
android: 'ic_menu_camera',
web: 'camera',
},
onPress: async () => {
if (!(await requestCameraAccessIfNeeded())) {
return
}
onSelectNewAvatar( onSelectNewAvatar(
await openCamera({ await openCamera({
width: 1000, width: 1000,
height: 1000, height: 1000,
cropperCircleOverlay: true, cropperCircleOverlay: true,
}), }),
) )
}, }, [onSelectNewAvatar, requestCameraAccessIfNeeded])
},
{
testID: 'changeAvatarLibraryBtn',
label: _(msg`Library`),
icon: {
ios: {
name: 'photo.on.rectangle.angled',
},
android: 'ic_menu_gallery',
web: 'gallery',
},
onPress: async () => {
if (!(await requestPhotoAccessIfNeeded())) {
return
}
const items = await openPicker({ const onOpenLibrary = React.useCallback(async () => {
aspect: [1, 1], if (!(await requestPhotoAccessIfNeeded())) {
}) return
const item = items[0] }
if (!item) {
return
}
const croppedImage = await openCropper({ const items = await openPicker({
mediaType: 'photo', aspect: [1, 1],
cropperCircleOverlay: true, })
height: item.height, const item = items[0]
width: item.width, if (!item) {
path: item.path, return
}) }
onSelectNewAvatar(croppedImage) const croppedImage = await openCropper({
}, mediaType: 'photo',
}, cropperCircleOverlay: true,
!!avatar && { height: item.height,
label: 'separator', width: item.width,
}, path: item.path,
!!avatar && { })
testID: 'changeAvatarRemoveBtn',
label: _(msg`Remove`), onSelectNewAvatar(croppedImage)
icon: { }, [onSelectNewAvatar, requestPhotoAccessIfNeeded])
ios: {
name: 'trash', const onRemoveAvatar = React.useCallback(() => {
}, onSelectNewAvatar(null)
android: 'ic_delete', }, [onSelectNewAvatar])
web: ['far', 'trash-can'],
},
onPress: async () => {
onSelectNewAvatar(null)
},
},
].filter(Boolean) as DropdownItem[],
[
avatar,
onSelectNewAvatar,
requestCameraAccessIfNeeded,
requestPhotoAccessIfNeeded,
_,
],
)
return ( return (
<NativeDropdown <Menu.Root>
testID="changeAvatarBtn" <Menu.Trigger label={_(msg`Edit avatar`)}>
items={dropdownItems} {({props}) => (
accessibilityLabel={_(msg`Image options`)} <TouchableOpacity {...props} activeOpacity={0.8}>
accessibilityHint=""> {avatar ? (
{avatar ? ( <HighPriorityImage
<HighPriorityImage testID="userAvatarImage"
testID="userAvatarImage" style={aviStyle}
style={aviStyle} source={{uri: avatar}}
source={{uri: avatar}} accessibilityRole="image"
accessibilityRole="image" />
/> ) : (
) : ( <DefaultAvatar type={type} size={size} />
<DefaultAvatar type={type} size={size} /> )}
)} <View style={[styles.editButtonContainer, pal.btn]}>
<View style={[styles.editButtonContainer, pal.btn]}> <CameraFilled height={14} width={14} style={t.atoms.text} />
<FontAwesomeIcon </View>
icon="camera" </TouchableOpacity>
size={12} )}
color={pal.text.color as string} </Menu.Trigger>
/> <Menu.Outer showCancel>
</View> <Menu.Group>
</NativeDropdown> {isNative && (
<Menu.Item
testID="changeAvatarCameraBtn"
label={_(msg`Upload from Camera`)}
onPress={onOpenCamera}>
<Menu.ItemText>
<Trans>Upload from Camera</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Camera} />
</Menu.Item>
)}
<Menu.Item
testID="changeAvatarLibraryBtn"
label={_(msg`Upload from Library`)}
onPress={onOpenLibrary}>
<Menu.ItemText>
{isNative ? (
<Trans>Upload from Library</Trans>
) : (
<Trans>Upload from Files</Trans>
)}
</Menu.ItemText>
<Menu.ItemIcon icon={Library} />
</Menu.Item>
</Menu.Group>
{!!avatar && (
<>
<Menu.Divider />
<Menu.Group>
<Menu.Item
testID="changeAvatarRemoveBtn"
label={_(`Remove Avatar`)}
onPress={onRemoveAvatar}>
<Menu.ItemText>
<Trans>Remove Avatar</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Trash} />
</Menu.Item>
</Menu.Group>
</>
)}
</Menu.Outer>
</Menu.Root>
) )
} }
EditableUserAvatar = memo(EditableUserAvatar) EditableUserAvatar = memo(EditableUserAvatar)
+120 -110
View File
@@ -1,21 +1,29 @@
import React, {useMemo} from 'react' import React from 'react'
import {StyleSheet, View} from 'react-native' import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {ModerationUI} from '@atproto/api' import {ModerationUI} from '@atproto/api'
import {Image} from 'expo-image' import {Image} from 'expo-image'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {colors} from 'lib/styles' import {colors} from 'lib/styles'
import {useTheme} from 'lib/ThemeContext' import {useTheme} from 'lib/ThemeContext'
import {useTheme as useAlfTheme} from '#/alf'
import {openCamera, openCropper, openPicker} from '../../../lib/media/picker' import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
import { import {
usePhotoLibraryPermission, usePhotoLibraryPermission,
useCameraPermission, useCameraPermission,
} from 'lib/hooks/usePermissions' } from 'lib/hooks/usePermissions'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {isWeb, isAndroid} from 'platform/detection' import {isAndroid, isNative} from 'platform/detection'
import {Image as RNImage} from 'react-native-image-crop-picker' import {Image as RNImage} from 'react-native-image-crop-picker'
import {NativeDropdown, DropdownItem} from './forms/NativeDropdown' import {EventStopper} from 'view/com/util/EventStopper'
import * as Menu from '#/components/Menu'
import {
Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled,
Camera_Stroke2_Corner0_Rounded as Camera,
} from '#/components/icons/Camera'
import {StreamingLive_Stroke2_Corner0_Rounded as Library} from '#/components/icons/StreamingLive'
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
export function UserBanner({ export function UserBanner({
banner, banner,
@@ -28,118 +36,120 @@ export function UserBanner({
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
const theme = useTheme() const theme = useTheme()
const t = useAlfTheme()
const {_} = useLingui() const {_} = useLingui()
const {requestCameraAccessIfNeeded} = useCameraPermission() const {requestCameraAccessIfNeeded} = useCameraPermission()
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
const dropdownItems: DropdownItem[] = useMemo( const onOpenCamera = React.useCallback(async () => {
() => if (!(await requestCameraAccessIfNeeded())) {
[ return
!isWeb && { }
testID: 'changeBannerCameraBtn', onSelectNewBanner?.(
label: _(msg`Camera`), await openCamera({
icon: { width: 3000,
ios: { height: 1000,
name: 'camera', }),
}, )
android: 'ic_menu_camera', }, [onSelectNewBanner, requestCameraAccessIfNeeded])
web: 'camera',
},
onPress: async () => {
if (!(await requestCameraAccessIfNeeded())) {
return
}
onSelectNewBanner?.(
await openCamera({
width: 3000,
height: 1000,
}),
)
},
},
{
testID: 'changeBannerLibraryBtn',
label: _(msg`Library`),
icon: {
ios: {
name: 'photo.on.rectangle.angled',
},
android: 'ic_menu_gallery',
web: 'gallery',
},
onPress: async () => {
if (!(await requestPhotoAccessIfNeeded())) {
return
}
const items = await openPicker()
if (!items[0]) {
return
}
onSelectNewBanner?.( const onOpenLibrary = React.useCallback(async () => {
await openCropper({ if (!(await requestPhotoAccessIfNeeded())) {
mediaType: 'photo', return
path: items[0].path, }
width: 3000, const items = await openPicker()
height: 1000, if (!items[0]) {
}), return
) }
},
}, onSelectNewBanner?.(
!!banner && { await openCropper({
testID: 'changeBannerRemoveBtn', mediaType: 'photo',
label: _(msg`Remove`), path: items[0].path,
icon: { width: 3000,
ios: { height: 1000,
name: 'trash', }),
}, )
android: 'ic_delete', }, [onSelectNewBanner, requestPhotoAccessIfNeeded])
web: ['far', 'trash-can'],
}, const onRemoveBanner = React.useCallback(() => {
onPress: () => { onSelectNewBanner?.(null)
onSelectNewBanner?.(null) }, [onSelectNewBanner])
},
},
].filter(Boolean) as DropdownItem[],
[
banner,
onSelectNewBanner,
requestCameraAccessIfNeeded,
requestPhotoAccessIfNeeded,
_,
],
)
// setUserBanner is only passed as prop on the EditProfile component // setUserBanner is only passed as prop on the EditProfile component
return onSelectNewBanner ? ( return onSelectNewBanner ? (
<NativeDropdown <EventStopper onKeyDown={false}>
testID="changeBannerBtn" <Menu.Root>
items={dropdownItems} <Menu.Trigger label={_(msg`Edit avatar`)}>
accessibilityLabel={_(msg`Image options`)} {({props}) => (
accessibilityHint=""> <TouchableOpacity {...props} activeOpacity={0.8}>
{banner ? ( {banner ? (
<Image <Image
testID="userBannerImage" testID="userBannerImage"
style={styles.bannerImage} style={styles.bannerImage}
source={{uri: banner}} source={{uri: banner}}
accessible={true} accessible={true}
accessibilityIgnoresInvertColors accessibilityIgnoresInvertColors
/> />
) : ( ) : (
<View <View
testID="userBannerFallback" testID="userBannerFallback"
style={[styles.bannerImage, styles.defaultBanner]} style={[styles.bannerImage, styles.defaultBanner]}
/> />
)} )}
<View style={[styles.editButtonContainer, pal.btn]}> <View style={[styles.editButtonContainer, pal.btn]}>
<FontAwesomeIcon <CameraFilled height={14} width={14} style={t.atoms.text} />
icon="camera" </View>
size={12} </TouchableOpacity>
style={{color: colors.white}} )}
color={pal.text.color as string} </Menu.Trigger>
/> <Menu.Outer showCancel>
</View> <Menu.Group>
</NativeDropdown> {isNative && (
<Menu.Item
testID="changeBannerCameraBtn"
label={_(msg`Upload from Camera`)}
onPress={onOpenCamera}>
<Menu.ItemText>
<Trans>Upload from Camera</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Camera} />
</Menu.Item>
)}
<Menu.Item
testID="changeBannerLibraryBtn"
label={_(msg`Upload from Library`)}
onPress={onOpenLibrary}>
<Menu.ItemText>
{isNative ? (
<Trans>Upload from Library</Trans>
) : (
<Trans>Upload from Files</Trans>
)}
</Menu.ItemText>
<Menu.ItemIcon icon={Library} />
</Menu.Item>
</Menu.Group>
{!!banner && (
<>
<Menu.Divider />
<Menu.Group>
<Menu.Item
testID="changeBannerRemoveBtn"
label={_(`Remove Banner`)}
onPress={onRemoveBanner}>
<Menu.ItemText>
<Trans>Remove Banner</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={Trash} />
</Menu.Item>
</Menu.Group>
</>
)}
</Menu.Outer>
</Menu.Root>
</EventStopper>
) : banner && ) : banner &&
!((moderation?.blur && isAndroid) /* android crashes with blur */) ? ( !((moderation?.blur && isAndroid) /* android crashes with blur */) ? (
<Image <Image
+220 -170
View File
@@ -1,5 +1,5 @@
import React, {memo} from 'react' import React, {memo} from 'react'
import {StyleProp, View, ViewStyle} from 'react-native' import {StyleProp, ViewStyle, Pressable, 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'
@@ -12,12 +12,10 @@ import {
import {toShareUrl} from 'lib/strings/url-helpers' import {toShareUrl} from 'lib/strings/url-helpers'
import {useTheme} from 'lib/ThemeContext' import {useTheme} from 'lib/ThemeContext'
import {shareUrl} from 'lib/sharing' import {shareUrl} from 'lib/sharing'
import {
NativeDropdown,
DropdownItem as NativeDropdownItem,
} from './NativeDropdown'
import * as Toast from '../Toast' import * as Toast from '../Toast'
import {EventStopper} from '../EventStopper' import {EventStopper} from '../EventStopper'
import {useDialogControl} from '#/components/Dialog'
import * as Prompt from '#/components/Prompt'
import {useModalControls} from '#/state/modals' import {useModalControls} from '#/state/modals'
import {makeProfileLink} from '#/lib/routes/links' import {makeProfileLink} from '#/lib/routes/links'
import {CommonNavigatorParams} from '#/lib/routes/types' import {CommonNavigatorParams} from '#/lib/routes/types'
@@ -36,6 +34,19 @@ import {isWeb} from '#/platform/detection'
import {richTextToString} from '#/lib/strings/rich-text-helpers' import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
import {atoms as a, useTheme as useAlf} from '#/alf'
import * as Menu from '#/components/Menu'
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter'
import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox'
import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble'
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
let PostDropdownBtn = ({ let PostDropdownBtn = ({
testID, testID,
postAuthor, postAuthor,
@@ -45,6 +56,7 @@ let PostDropdownBtn = ({
richText, richText,
style, style,
showAppealLabelItem, showAppealLabelItem,
hitSlop,
}: { }: {
testID: string testID: string
postAuthor: AppBskyActorDefs.ProfileViewBasic postAuthor: AppBskyActorDefs.ProfileViewBasic
@@ -54,9 +66,11 @@ let PostDropdownBtn = ({
richText: RichTextAPI richText: RichTextAPI
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
showAppealLabelItem?: boolean showAppealLabelItem?: boolean
hitSlop?: PressableProps['hitSlop']
}): React.ReactNode => { }): React.ReactNode => {
const {hasSession, currentAccount} = useSession() const {hasSession, currentAccount} = useSession()
const theme = useTheme() const theme = useTheme()
const alf = useAlf()
const {_} = useLingui() const {_} = useLingui()
const defaultCtrlColor = theme.palette.default.postCtrl const defaultCtrlColor = theme.palette.default.postCtrl
const {openModal} = useModalControls() const {openModal} = useModalControls()
@@ -69,11 +83,15 @@ let PostDropdownBtn = ({
const openLink = useOpenLink() const openLink = useOpenLink()
const navigation = useNavigation() const navigation = useNavigation()
const {mutedWordsDialogControl} = useGlobalDialogsControlContext() const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
const deletePromptControl = useDialogControl()
const hidePromptControl = useDialogControl()
const loggedOutWarningPromptControl = 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)
const isPostHidden = hiddenPosts && hiddenPosts.includes(postUri) const isPostHidden = hiddenPosts && hiddenPosts.includes(postUri)
const isAuthor = postAuthor.did === currentAccount?.did const isAuthor = postAuthor.did === currentAccount?.did
const href = React.useMemo(() => { const href = React.useMemo(() => {
const urip = new AtUri(postUri) const urip = new AtUri(postUri)
return makeProfileLink(postAuthor, 'post', urip.rkey) return makeProfileLink(postAuthor, 'post', urip.rkey)
@@ -151,173 +169,205 @@ let PostDropdownBtn = ({
hidePost({uri: postUri}) hidePost({uri: postUri})
}, [postUri, hidePost]) }, [postUri, hidePost])
const dropdownItems: NativeDropdownItem[] = [ const shouldShowLoggedOutWarning = React.useMemo(() => {
{ return !!postAuthor.labels?.find(
label: _(msg`Translate`), label => label.val === '!no-unauthenticated',
onPress() { )
onOpenTranslate() }, [postAuthor])
},
testID: 'postDropdownTranslateBtn', const onSharePost = React.useCallback(() => {
icon: { const url = toShareUrl(href)
ios: { shareUrl(url)
name: 'character.book.closed', }, [href])
},
android: 'ic_menu_sort_alphabetically',
web: 'language',
},
},
{
label: _(msg`Copy post text`),
onPress() {
onCopyPostText()
},
testID: 'postDropdownCopyTextBtn',
icon: {
ios: {
name: 'doc.on.doc',
},
android: 'ic_menu_edit',
web: ['far', 'paste'],
},
},
{
label: isWeb ? _(msg`Copy link to post`) : _(msg`Share`),
onPress() {
const url = toShareUrl(href)
shareUrl(url)
},
testID: 'postDropdownShareBtn',
icon: {
ios: {
name: 'square.and.arrow.up',
},
android: 'ic_menu_share',
web: 'share',
},
},
hasSession && {
label: 'separator',
},
hasSession && {
label: isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`),
onPress() {
onToggleThreadMute()
},
testID: 'postDropdownMuteThreadBtn',
icon: {
ios: {
name: 'speaker.slash',
},
android: 'ic_lock_silent_mode',
web: 'comment-slash',
},
},
hasSession && {
label: _(msg`Mute words & tags`),
onPress() {
mutedWordsDialogControl.open()
},
testID: 'postDropdownMuteWordsBtn',
icon: {
ios: {
name: 'speaker.slash',
},
android: 'ic_lock_silent_mode',
web: 'filter',
},
},
hasSession &&
!isAuthor &&
!isPostHidden && {
label: _(msg`Hide post`),
onPress() {
openModal({
name: 'confirm',
title: _(msg`Hide this post?`),
message: _(msg`This will hide this post from your feeds.`),
onPressConfirm: onHidePost,
})
},
testID: 'postDropdownHideBtn',
icon: {
ios: {
name: 'eye.slash',
},
android: 'ic_menu_delete',
web: ['far', 'eye-slash'],
},
},
{
label: 'separator',
},
!isAuthor &&
hasSession && {
label: _(msg`Report post`),
onPress() {
openModal({
name: 'report',
uri: postUri,
cid: postCid,
})
},
testID: 'postDropdownReportBtn',
icon: {
ios: {
name: 'exclamationmark.triangle',
},
android: 'ic_menu_report_image',
web: 'circle-exclamation',
},
},
isAuthor && {
label: _(msg`Delete post`),
onPress() {
openModal({
name: 'confirm',
title: _(msg`Delete this post?`),
message: _(msg`Are you sure? This cannot be undone.`),
onPressConfirm: onDeletePost,
})
},
testID: 'postDropdownDeleteBtn',
icon: {
ios: {
name: 'trash',
},
android: 'ic_menu_delete',
web: ['far', 'trash-can'],
},
},
showAppealLabelItem && {
label: 'separator',
},
showAppealLabelItem && {
label: _(msg`Appeal content warning`),
onPress() {
openModal({name: 'appeal-label', uri: postUri, cid: postCid})
},
testID: 'postDropdownAppealBtn',
icon: {
ios: {
name: 'exclamationmark.triangle',
},
android: 'ic_menu_report_image',
web: 'circle-exclamation',
},
},
].filter(Boolean) as NativeDropdownItem[]
return ( return (
<EventStopper> <EventStopper onKeyDown={false}>
<NativeDropdown <Menu.Root>
testID={testID} <Menu.Trigger label={_(msg`Open post options menu`)}>
items={dropdownItems} {({props, state}) => {
accessibilityLabel={_(msg`More post options`)} return (
accessibilityHint=""> <Pressable
<View style={style}> {...props}
<FontAwesomeIcon icon="ellipsis" size={20} color={defaultCtrlColor} /> hitSlop={hitSlop}
</View> testID={testID}
</NativeDropdown> style={[
style,
a.rounded_full,
(state.hovered || state.pressed) && [
alf.atoms.bg_contrast_50,
],
]}>
<FontAwesomeIcon
icon="ellipsis"
size={20}
color={defaultCtrlColor}
style={{pointerEvents: 'none'}}
/>
</Pressable>
)
}}
</Menu.Trigger>
<Menu.Outer>
<Menu.Group>
<Menu.Item
testID="postDropdownTranslateBtn"
label={_(msg`Translate`)}
onPress={onOpenTranslate}>
<Menu.ItemText>{_(msg`Translate`)}</Menu.ItemText>
<Menu.ItemIcon icon={Translate} position="right" />
</Menu.Item>
<Menu.Item
testID="postDropdownCopyTextBtn"
label={_(msg`Copy post text`)}
onPress={onCopyPostText}>
<Menu.ItemText>{_(msg`Copy post text`)}</Menu.ItemText>
<Menu.ItemIcon icon={ClipboardIcon} position="right" />
</Menu.Item>
<Menu.Item
testID="postDropdownShareBtn"
label={isWeb ? _(msg`Copy link to post`) : _(msg`Share`)}
onPress={() => {
if (shouldShowLoggedOutWarning) {
loggedOutWarningPromptControl.open()
} else {
onSharePost()
}
}}>
<Menu.ItemText>
{isWeb ? _(msg`Copy link to post`) : _(msg`Share`)}
</Menu.ItemText>
<Menu.ItemIcon icon={Share} position="right" />
</Menu.Item>
</Menu.Group>
{hasSession && (
<>
<Menu.Divider />
<Menu.Group>
<Menu.Item
testID="postDropdownMuteThreadBtn"
label={
isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`)
}
onPress={onToggleThreadMute}>
<Menu.ItemText>
{isThreadMuted
? _(msg`Unmute thread`)
: _(msg`Mute thread`)}
</Menu.ItemText>
<Menu.ItemIcon
icon={isThreadMuted ? Unmute : Mute}
position="right"
/>
</Menu.Item>
<Menu.Item
testID="postDropdownMuteWordsBtn"
label={_(msg`Mute words & tags`)}
onPress={() => mutedWordsDialogControl.open()}>
<Menu.ItemText>{_(msg`Mute words & tags`)}</Menu.ItemText>
<Menu.ItemIcon icon={Filter} position="right" />
</Menu.Item>
{!isAuthor && !isPostHidden && (
<Menu.Item
testID="postDropdownHideBtn"
label={_(msg`Hide post`)}
onPress={hidePromptControl.open}>
<Menu.ItemText>{_(msg`Hide post`)}</Menu.ItemText>
<Menu.ItemIcon icon={EyeSlash} position="right" />
</Menu.Item>
)}
</Menu.Group>
</>
)}
<Menu.Divider />
<Menu.Group>
{!isAuthor && (
<Menu.Item
testID="postDropdownReportBtn"
label={_(msg`Report post`)}
onPress={() => {
openModal({
name: 'report',
uri: postUri,
cid: postCid,
})
}}>
<Menu.ItemText>{_(msg`Report post`)}</Menu.ItemText>
<Menu.ItemIcon icon={Warning} position="right" />
</Menu.Item>
)}
{isAuthor && (
<Menu.Item
testID="postDropdownDeleteBtn"
label={_(msg`Delete post`)}
onPress={deletePromptControl.open}>
<Menu.ItemText>{_(msg`Delete post`)}</Menu.ItemText>
<Menu.ItemIcon icon={Trash} position="right" />
</Menu.Item>
)}
{showAppealLabelItem && (
<>
<Menu.Divider />
<Menu.Item
testID="postDropdownAppealBtn"
label={_(msg`Appeal content warning`)}
onPress={() => {
openModal({
name: 'appeal-label',
uri: postUri,
cid: postCid,
})
}}>
<Menu.ItemText>
{_(msg`Appeal content warning`)}
</Menu.ItemText>
<Menu.ItemIcon icon={CircleInfo} position="right" />
</Menu.Item>
</>
)}
</Menu.Group>
</Menu.Outer>
</Menu.Root>
<Prompt.Basic
control={deletePromptControl}
title={_(msg`Delete this post?`)}
description={_(
msg`If you remove this post, you won't be able to recover it.`,
)}
onConfirm={onDeletePost}
confirmButtonCta={_(msg`Delete`)}
confirmButtonColor="negative"
/>
<Prompt.Basic
control={hidePromptControl}
title={_(msg`Hide this post?`)}
description={_(msg`This post will be hidden from feeds.`)}
onConfirm={onHidePost}
confirmButtonCta={_(msg`Hide`)}
/>
<Prompt.Basic
control={loggedOutWarningPromptControl}
title={_(msg`Note about sharing`)}
description={_(
msg`This post is only visible to logged-in users. It won't be visible to people who aren't logged in.`,
)}
onConfirm={onSharePost}
confirmButtonCta={_(msg`Share anyway`)}
/>
</EventStopper> </EventStopper>
) )
} }

Some files were not shown because too many files have changed in this diff Show More