Merge branch 'main' into hailey/onboarding-profile
@@ -40,6 +40,8 @@ module.exports = function (config) {
|
||||
? process.env.BSKY_ANDROID_VERSION_CODE
|
||||
: process.env.BSKY_IOS_BUILD_NUMBER
|
||||
|
||||
const IS_DEV = process.env.EXPO_PUBLIC_ENV === 'development'
|
||||
|
||||
return {
|
||||
expo: {
|
||||
version: VERSION,
|
||||
@@ -103,7 +105,7 @@ module.exports = function (config) {
|
||||
scheme: 'https',
|
||||
host: 'bsky.app',
|
||||
},
|
||||
{
|
||||
IS_DEV && {
|
||||
scheme: 'http',
|
||||
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 |
@@ -43,6 +43,9 @@
|
||||
height: calc(100% + env(safe-area-inset-top));
|
||||
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 */
|
||||
button, input, textarea {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bsky.app",
|
||||
"version": "1.72.0",
|
||||
"version": "1.73.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -180,7 +180,6 @@
|
||||
"react-responsive": "^9.0.2",
|
||||
"rn-fetch-blob": "^0.12.0",
|
||||
"sentry-expo": "~7.0.1",
|
||||
"statsig-react": "^1.36.0",
|
||||
"statsig-react-native-expo": "^4.6.1",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tlds": "^1.234.0",
|
||||
|
||||
@@ -78,7 +78,7 @@ import {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStack
|
||||
import {msg} from '@lingui/macro'
|
||||
import {i18n, MessageDescriptor} from '@lingui/core'
|
||||
import HashtagScreen from '#/screens/Hashtag'
|
||||
import {logEvent} from './lib/statsig/statsig'
|
||||
import {logEvent, attachRouteToLogEvents} from './lib/statsig/statsig'
|
||||
|
||||
const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
|
||||
|
||||
@@ -543,6 +543,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
||||
linking={LINKING}
|
||||
theme={theme}
|
||||
onReady={() => {
|
||||
attachRouteToLogEvents(getCurrentRouteName)
|
||||
logModuleInitTime()
|
||||
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
|
||||
* (eg in the state models).
|
||||
@@ -656,7 +661,9 @@ function logModuleInitTime() {
|
||||
performance.now() - global.__BUNDLE_START_TIME__,
|
||||
)
|
||||
console.log(`Time to first paint: ${initMs} ms`)
|
||||
logEvent('init', initMs)
|
||||
logEvent('init', {
|
||||
initMs,
|
||||
})
|
||||
|
||||
if (__DEV__) {
|
||||
// This log is noisy, so keep false committed
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {Platform} from 'react-native'
|
||||
import {web, native} from '#/alf/util/platform'
|
||||
import * as tokens from '#/alf/tokens'
|
||||
|
||||
@@ -6,7 +7,7 @@ export const atoms = {
|
||||
* Positioning
|
||||
*/
|
||||
fixed: {
|
||||
position: 'fixed',
|
||||
position: Platform.select({web: 'fixed', native: 'absolute'}) as 'absolute',
|
||||
},
|
||||
absolute: {
|
||||
position: 'absolute',
|
||||
@@ -111,6 +112,12 @@ export const atoms = {
|
||||
flex_row: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
flex_col_reverse: {
|
||||
flexDirection: 'column-reverse',
|
||||
},
|
||||
flex_row_reverse: {
|
||||
flexDirection: 'row-reverse',
|
||||
},
|
||||
flex_wrap: {
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
|
||||
@@ -27,7 +27,7 @@ export type ButtonColor =
|
||||
| 'gradient_sunset'
|
||||
| 'gradient_nordic'
|
||||
| 'gradient_bonfire'
|
||||
export type ButtonSize = 'tiny' | 'small' | 'large'
|
||||
export type ButtonSize = 'tiny' | 'small' | 'medium' | 'large'
|
||||
export type ButtonShape = 'round' | 'square' | 'default'
|
||||
export type VariantProps = {
|
||||
/**
|
||||
@@ -274,6 +274,8 @@ export function Button({
|
||||
if (shape === 'default') {
|
||||
if (size === 'large') {
|
||||
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') {
|
||||
baseStyles.push({paddingVertical: 9}, a.px_lg, a.rounded_sm, a.gap_sm)
|
||||
} else if (size === 'tiny') {
|
||||
|
||||
@@ -21,8 +21,7 @@ export function useDialogControl(): DialogOuterProps['control'] {
|
||||
open: () => {},
|
||||
close: () => {},
|
||||
})
|
||||
const {activeDialogs, openDialogs} = useDialogStateContext()
|
||||
const isOpen = openDialogs.includes(id)
|
||||
const {activeDialogs} = useDialogStateContext()
|
||||
|
||||
React.useEffect(() => {
|
||||
activeDialogs.current.set(id, control)
|
||||
@@ -36,7 +35,6 @@ export function useDialogControl(): DialogOuterProps['control'] {
|
||||
() => ({
|
||||
id,
|
||||
ref: control,
|
||||
isOpen,
|
||||
open: () => {
|
||||
control.current.open()
|
||||
},
|
||||
@@ -44,6 +42,6 @@ export function useDialogControl(): DialogOuterProps['control'] {
|
||||
control.current.close(cb)
|
||||
},
|
||||
}),
|
||||
[id, control, isOpen],
|
||||
[id, control],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
DialogInnerProps,
|
||||
} from '#/components/Dialog/types'
|
||||
import {Context} from '#/components/Dialog/context'
|
||||
import {isNative} from 'platform/detection'
|
||||
|
||||
export {useDialogControl, useDialogContext} from '#/components/Dialog/context'
|
||||
export * from '#/components/Dialog/types'
|
||||
@@ -75,6 +76,7 @@ export function Outer({
|
||||
control,
|
||||
onClose,
|
||||
nativeOptions,
|
||||
testID,
|
||||
}: React.PropsWithChildren<DialogOuterProps>) {
|
||||
const t = useTheme()
|
||||
const sheet = React.useRef<BottomSheet>(null)
|
||||
@@ -145,7 +147,8 @@ export function Outer({
|
||||
accessibilityViewIsModal
|
||||
// Android
|
||||
importantForAccessibility="yes"
|
||||
style={[a.absolute, a.inset_0]}>
|
||||
style={[a.absolute, a.inset_0]}
|
||||
testID={testID}>
|
||||
<BottomSheet
|
||||
enableDynamicSizing={!hasSnapPoints}
|
||||
enablePanDownToClose
|
||||
@@ -224,7 +227,8 @@ export function ScrollableInner({children, style}: DialogInnerProps) {
|
||||
borderTopRightRadius: 40,
|
||||
},
|
||||
flatten(style),
|
||||
]}>
|
||||
]}
|
||||
contentContainerStyle={isNative ? a.pb_4xl : undefined}>
|
||||
{children}
|
||||
<View style={{height: insets.bottom + a.pt_5xl.paddingTop}} />
|
||||
</BottomSheetScrollView>
|
||||
|
||||
@@ -22,7 +22,7 @@ export type DialogControlRefProps = {
|
||||
export type DialogControlProps = DialogControlRefProps & {
|
||||
id: string
|
||||
ref: React.RefObject<DialogControlRefProps>
|
||||
isOpen: boolean
|
||||
isOpen?: boolean
|
||||
}
|
||||
|
||||
export type DialogContextProps = {
|
||||
@@ -46,6 +46,7 @@ export type DialogOuterProps = {
|
||||
sheet?: Omit<BottomSheetProps, 'children'>
|
||||
}
|
||||
webOptions?: {}
|
||||
testID?: string
|
||||
}
|
||||
|
||||
type DialogInnerPropsBase<T> = React.PropsWithChildren<ViewStyleProp> & T
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
import React from 'react'
|
||||
import {GestureResponderEvent} from 'react-native'
|
||||
import {
|
||||
useLinkProps,
|
||||
useNavigation,
|
||||
StackActions,
|
||||
} from '@react-navigation/native'
|
||||
import {useLinkProps, StackActions} from '@react-navigation/native'
|
||||
import {sanitizeUrl} from '@braintree/sanitize-url'
|
||||
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {useTheme, web, flatten, TextStyleProp, atoms as a} from '#/alf'
|
||||
import {Button, ButtonProps} from '#/components/Button'
|
||||
import {AllNavigatorParams, NavigationProp} from '#/lib/routes/types'
|
||||
import {AllNavigatorParams} from '#/lib/routes/types'
|
||||
import {
|
||||
convertBskyAppUrlIfNeeded,
|
||||
isExternalUrl,
|
||||
@@ -21,6 +17,7 @@ import {useModalControls} from '#/state/modals'
|
||||
import {router} from '#/routes'
|
||||
import {Text, TextProps} from '#/components/Typography'
|
||||
import {useOpenLink} from 'state/preferences/in-app-browser'
|
||||
import {useNavigationDeduped} from 'lib/hooks/useNavigationDeduped'
|
||||
|
||||
/**
|
||||
* Only available within a `Link`, since that inherits from `Button`.
|
||||
@@ -74,7 +71,7 @@ export function useLink({
|
||||
}: BaseLinkProps & {
|
||||
displayText: string
|
||||
}) {
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const navigation = useNavigationDeduped()
|
||||
const {href} = useLinkProps<AllNavigatorParams>({
|
||||
to:
|
||||
typeof to === 'string' ? convertBskyAppUrlIfNeeded(sanitizeUrl(to)) : to,
|
||||
@@ -231,6 +228,7 @@ export function InlineLink({
|
||||
onPress: outerOnPress,
|
||||
download,
|
||||
selectable,
|
||||
label,
|
||||
...rest
|
||||
}: InlineLinkProps) {
|
||||
const t = useTheme()
|
||||
@@ -258,7 +256,8 @@ export function InlineLink({
|
||||
return (
|
||||
<Text
|
||||
selectable={selectable}
|
||||
label={href}
|
||||
accessibilityHint=""
|
||||
accessibilityLabel={label || href}
|
||||
{...rest}
|
||||
style={[
|
||||
{color: t.palette.primary_500},
|
||||
|
||||
@@ -8,9 +8,8 @@ import {cleanError} from 'lib/strings/errors'
|
||||
import {Button} from '#/components/Button'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {StackActions} from '@react-navigation/native'
|
||||
import {useNavigation} from '@react-navigation/core'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {router} from '#/routes'
|
||||
import {useNavigationDeduped} from 'lib/hooks/useNavigationDeduped'
|
||||
|
||||
export function ListFooter({
|
||||
isFetching,
|
||||
@@ -142,7 +141,7 @@ export function ListMaybePlaceholder({
|
||||
notFoundType?: 'page' | 'results'
|
||||
onRetry?: () => Promise<unknown>
|
||||
}) {
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const navigation = useNavigationDeduped()
|
||||
const t = useTheme()
|
||||
const {gtMobile, gtTablet} = useBreakpoints()
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import {View, Pressable} from 'react-native'
|
||||
import {View, Pressable, ViewStyle, StyleProp} from 'react-native'
|
||||
import flattenReactChildren from 'react-keyed-flatten-children'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
@@ -16,6 +16,10 @@ import {
|
||||
ItemTextProps,
|
||||
ItemIconProps,
|
||||
} from '#/components/Menu/types'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {isNative} from 'platform/detection'
|
||||
|
||||
export {useDialogControl as useMenuControl} from '#/components/Dialog'
|
||||
|
||||
@@ -68,7 +72,13 @@ export function Trigger({children, label}: TriggerProps) {
|
||||
})
|
||||
}
|
||||
|
||||
export function Outer({children}: React.PropsWithChildren<{}>) {
|
||||
export function Outer({
|
||||
children,
|
||||
showCancel,
|
||||
}: React.PropsWithChildren<{
|
||||
showCancel?: boolean
|
||||
style?: StyleProp<ViewStyle>
|
||||
}>) {
|
||||
const context = React.useContext(Context)
|
||||
|
||||
return (
|
||||
@@ -78,7 +88,10 @@ export function Outer({children}: React.PropsWithChildren<{}>) {
|
||||
{/* Re-wrap with context since Dialogs are portal-ed to root */}
|
||||
<Context.Provider value={context}>
|
||||
<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}} />
|
||||
</Dialog.ScrollableInner>
|
||||
</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() {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
|
||||
import React from 'react'
|
||||
import {View, Pressable} from 'react-native'
|
||||
import {View, Pressable, ViewStyle, StyleProp} from 'react-native'
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
@@ -14,8 +18,10 @@ import {
|
||||
GroupProps,
|
||||
ItemTextProps,
|
||||
ItemIconProps,
|
||||
RadixPassThroughTriggerProps,
|
||||
} from '#/components/Menu/types'
|
||||
import {Context} from '#/components/Menu/context'
|
||||
import {Portal} from '#/components/Portal'
|
||||
|
||||
export function useMenuControl(): Dialog.DialogControlProps {
|
||||
const id = React.useId()
|
||||
@@ -47,6 +53,7 @@ export function Root({
|
||||
}: React.PropsWithChildren<{
|
||||
control?: Dialog.DialogOuterProps['control']
|
||||
}>) {
|
||||
const {_} = useLingui()
|
||||
const defaultControl = useMenuControl()
|
||||
const context = React.useMemo<ContextType>(
|
||||
() => ({
|
||||
@@ -67,6 +74,18 @@ export function Root({
|
||||
|
||||
return (
|
||||
<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
|
||||
open={context.control.isOpen}
|
||||
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 {
|
||||
state: hovered,
|
||||
@@ -87,35 +123,42 @@ export function Trigger({children, label, style}: TriggerProps) {
|
||||
|
||||
return (
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<Pressable
|
||||
accessibilityHint=""
|
||||
accessibilityLabel={label}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
style={flatten([style, web({outline: 0})])}
|
||||
onPointerDown={() => {
|
||||
control.open()
|
||||
}}
|
||||
{...web({
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
})}>
|
||||
{children({
|
||||
isNative: false,
|
||||
control,
|
||||
state: {
|
||||
hovered,
|
||||
focused,
|
||||
pressed: false,
|
||||
},
|
||||
props: {},
|
||||
})}
|
||||
</Pressable>
|
||||
<RadixTriggerPassThrough>
|
||||
{props =>
|
||||
children({
|
||||
isNative: false,
|
||||
control,
|
||||
state: {
|
||||
hovered,
|
||||
focused,
|
||||
pressed: false,
|
||||
},
|
||||
props: {
|
||||
...props,
|
||||
// disable on web, use `onPress`
|
||||
onPointerDown: () => false,
|
||||
onPress: () =>
|
||||
control.isOpen ? control.close() : control.open(),
|
||||
onFocus: onFocus,
|
||||
onBlur: onBlur,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
accessibilityLabel: label,
|
||||
},
|
||||
})
|
||||
}
|
||||
</RadixTriggerPassThrough>
|
||||
</DropdownMenu.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
export function Outer({children}: React.PropsWithChildren<{}>) {
|
||||
export function Outer({
|
||||
children,
|
||||
style,
|
||||
}: React.PropsWithChildren<{
|
||||
showCancel?: boolean
|
||||
style?: StyleProp<ViewStyle>
|
||||
}>) {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
@@ -127,10 +170,12 @@ export function Outer({children}: React.PropsWithChildren<{}>) {
|
||||
a.p_xs,
|
||||
t.name === 'light' ? t.atoms.bg : t.atoms.bg_contrast_25,
|
||||
t.atoms.shadow_md,
|
||||
style,
|
||||
]}>
|
||||
{children}
|
||||
</View>
|
||||
|
||||
{/* Disabled until we can fix positioning
|
||||
<DropdownMenu.Arrow
|
||||
className="DropdownMenuArrow"
|
||||
fill={
|
||||
@@ -138,6 +183,7 @@ export function Outer({children}: React.PropsWithChildren<{}>) {
|
||||
.backgroundColor
|
||||
}
|
||||
/>
|
||||
*/}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import React from 'react'
|
||||
import {GestureResponderEvent, PressableProps} from 'react-native'
|
||||
import {
|
||||
GestureResponderEvent,
|
||||
PressableProps,
|
||||
AccessibilityProps,
|
||||
} from 'react-native'
|
||||
|
||||
import {Props as SVGIconProps} from '#/components/icons/common'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
@@ -9,7 +13,23 @@ export type ContextType = {
|
||||
control: Dialog.DialogOuterProps['control']
|
||||
}
|
||||
|
||||
export type TriggerProps = ViewStyleProp & {
|
||||
export type RadixPassThroughTriggerProps = {
|
||||
id: string
|
||||
type: 'button'
|
||||
disabled: boolean
|
||||
['data-disabled']: boolean
|
||||
['data-state']: string
|
||||
['aria-controls']?: string
|
||||
['aria-haspopup']?: boolean
|
||||
['aria-expanded']?: AccessibilityProps['aria-expanded']
|
||||
onKeyDown: (e: React.KeyboardEvent) => void
|
||||
/**
|
||||
* Radix provides this, but we override on web to use `onPress` instead,
|
||||
* which is less sensitive while scrolling.
|
||||
*/
|
||||
onPointerDown: PressableProps['onPointerDown']
|
||||
}
|
||||
export type TriggerProps = {
|
||||
children(props: TriggerChildProps): React.ReactNode
|
||||
label: string
|
||||
}
|
||||
@@ -52,7 +72,14 @@ export type TriggerChildProps =
|
||||
*/
|
||||
pressed: false
|
||||
}
|
||||
props: {}
|
||||
props: RadixPassThroughTriggerProps & {
|
||||
onPress: () => void
|
||||
onFocus: () => void
|
||||
onBlur: () => void
|
||||
onMouseEnter: () => void
|
||||
onMouseLeave: () => void
|
||||
accessibilityLabel: string
|
||||
}
|
||||
}
|
||||
|
||||
export type ItemProps = React.PropsWithChildren<
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React from 'react'
|
||||
import {View, PressableProps} from 'react-native'
|
||||
import {View} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useTheme, atoms as a} from '#/alf'
|
||||
import {useTheme, atoms as a, useBreakpoints} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {Button} from '#/components/Button'
|
||||
import {Button, ButtonColor, ButtonText} from '#/components/Button'
|
||||
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
|
||||
@@ -22,9 +22,12 @@ const Context = React.createContext<{
|
||||
export function Outer({
|
||||
children,
|
||||
control,
|
||||
testID,
|
||||
}: React.PropsWithChildren<{
|
||||
control: Dialog.DialogOuterProps['control']
|
||||
testID?: string
|
||||
}>) {
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const titleId = React.useId()
|
||||
const descriptionId = React.useId()
|
||||
|
||||
@@ -34,16 +37,16 @@ export function Outer({
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog.Outer control={control}>
|
||||
<Dialog.Outer control={control} testID={testID}>
|
||||
<Context.Provider value={context}>
|
||||
<Dialog.Handle />
|
||||
|
||||
<Dialog.Inner
|
||||
<Dialog.ScrollableInner
|
||||
accessibilityLabelledBy={titleId}
|
||||
accessibilityDescribedBy={descriptionId}
|
||||
style={[{width: 'auto', maxWidth: 400}]}>
|
||||
style={[gtMobile ? {width: 'auto', maxWidth: 400} : a.w_full]}>
|
||||
{children}
|
||||
</Dialog.Inner>
|
||||
</Dialog.ScrollableInner>
|
||||
</Context.Provider>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
@@ -71,8 +74,18 @@ export function Description({children}: React.PropsWithChildren<{}>) {
|
||||
}
|
||||
|
||||
export function Actions({children}: React.PropsWithChildren<{}>) {
|
||||
const {gtMobile} = useBreakpoints()
|
||||
|
||||
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}
|
||||
</View>
|
||||
)
|
||||
@@ -80,17 +93,29 @@ export function Actions({children}: React.PropsWithChildren<{}>) {
|
||||
|
||||
export function Cancel({
|
||||
children,
|
||||
}: React.PropsWithChildren<{onPress?: PressableProps['onPress']}>) {
|
||||
cta,
|
||||
}: React.PropsWithChildren<{
|
||||
/**
|
||||
* Optional i18n string, used in lieu of `children` for simple buttons. If
|
||||
* undefined (and `children` is undefined), it will default to "Cancel".
|
||||
*/
|
||||
cta?: string
|
||||
}>) {
|
||||
const {_} = useLingui()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {close} = Dialog.useDialogContext()
|
||||
const onPress = React.useCallback(() => {
|
||||
close()
|
||||
}, [close])
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="small"
|
||||
label={_(msg`Cancel`)}
|
||||
onPress={() => close()}>
|
||||
{children}
|
||||
size={gtMobile ? 'small' : 'medium'}
|
||||
label={cta || _(msg`Cancel`)}
|
||||
onPress={onPress}>
|
||||
{children ? children : <ButtonText>{cta || _(msg`Cancel`)}</ButtonText>}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -98,21 +123,70 @@ export function Cancel({
|
||||
export function Action({
|
||||
children,
|
||||
onPress,
|
||||
}: React.PropsWithChildren<{onPress?: () => void}>) {
|
||||
color = 'primary',
|
||||
cta,
|
||||
testID,
|
||||
}: React.PropsWithChildren<{
|
||||
onPress: () => void
|
||||
color?: ButtonColor
|
||||
/**
|
||||
* Optional i18n string, used in lieu of `children` for simple buttons. If
|
||||
* undefined (and `children` is undefined), it will default to "Confirm".
|
||||
*/
|
||||
cta?: string
|
||||
testID?: string
|
||||
}>) {
|
||||
const {_} = useLingui()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {close} = Dialog.useDialogContext()
|
||||
const handleOnPress = React.useCallback(() => {
|
||||
close()
|
||||
onPress?.()
|
||||
onPress()
|
||||
}, [close, onPress])
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="small"
|
||||
label={_(msg`Confirm`)}
|
||||
onPress={handleOnPress}>
|
||||
{children}
|
||||
color={color}
|
||||
size={gtMobile ? 'small' : 'medium'}
|
||||
label={cta || _(msg`Confirm`)}
|
||||
onPress={handleOnPress}
|
||||
testID={testID}>
|
||||
{children ? children : <ButtonText>{cta || _(msg`Confirm`)}</ButtonText>}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import {Text as RNText, TextStyle, TextProps as RNTextProps} from 'react-native'
|
||||
import {UITextView} from 'react-native-ui-text-view'
|
||||
|
||||
import {useTheme, atoms, web, flatten} from '#/alf'
|
||||
import {isIOS} from '#/platform/detection'
|
||||
import {isIOS, isNative} from '#/platform/detection'
|
||||
|
||||
export type TextProps = RNTextProps & {
|
||||
/**
|
||||
@@ -40,11 +40,11 @@ function normalizeTextStyles(styles: TextStyle[]) {
|
||||
const fontSize = s.fontSize || atoms.text_md.fontSize
|
||||
|
||||
if (s?.lineHeight) {
|
||||
if (s.lineHeight <= 2) {
|
||||
if (s.lineHeight !== 0 && s.lineHeight <= 2) {
|
||||
s.lineHeight = Math.round(fontSize * s.lineHeight)
|
||||
}
|
||||
} else {
|
||||
s.lineHeight = fontSize
|
||||
} else if (!isNative) {
|
||||
s.lineHeight = s.fontSize
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -254,9 +254,9 @@ function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) {
|
||||
</View>
|
||||
|
||||
{isNative && <View style={{height: 20}} />}
|
||||
|
||||
<Dialog.Close />
|
||||
</View>
|
||||
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
)
|
||||
}
|
||||
@@ -277,29 +277,16 @@ function MutedWordRow({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Prompt.Outer control={control}>
|
||||
<Prompt.Title>
|
||||
<Trans>Are you sure?</Trans>
|
||||
</Prompt.Title>
|
||||
<Prompt.Description>
|
||||
<Trans>
|
||||
This will delete {word.value} from your muted words. You can always
|
||||
add it back later.
|
||||
</Trans>
|
||||
</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>
|
||||
<Prompt.Basic
|
||||
control={control}
|
||||
title={_(msg`Are you sure?`)}
|
||||
description={_(
|
||||
msg`This will delete ${word.value} from your muted words. You can always add it back later.`,
|
||||
)}
|
||||
onConfirm={remove}
|
||||
confirmButtonCta={_(msg`Remove`)}
|
||||
confirmButtonColor="negative"
|
||||
/>
|
||||
|
||||
<View
|
||||
style={[
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
@@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE'
|
||||
export const Camera_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M8.371 3.89A2 2 0 0 1 10.035 3h3.93a2 2 0 0 1 1.664.89L17.035 6H20a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h2.965L8.37 3.89ZM13.965 5h-3.93L8.63 7.11A2 2 0 0 1 6.965 8H4v11h16V8h-2.965a2 2 0 0 1-1.664-.89L13.965 5ZM12 11a2 2 0 1 0 0 4 2 2 0 0 0 0-4Zm-4 2a4 4 0 1 1 8 0 4 4 0 0 1-8 0Z',
|
||||
})
|
||||
|
||||
export const Camera_Filled_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M8.371 3.89A2 2 0 0 1 10.035 3h3.93a2 2 0 0 1 1.664.89L17.035 6H20a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h2.965L8.37 3.89ZM12 9a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Z',
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
@@ -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',
|
||||
})
|
||||
@@ -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',
|
||||
})
|
||||
@@ -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',
|
||||
})
|
||||
@@ -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',
|
||||
})
|
||||
@@ -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',
|
||||
})
|
||||
@@ -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',
|
||||
})
|
||||
@@ -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',
|
||||
})
|
||||
@@ -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',
|
||||
})
|
||||
@@ -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',
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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],
|
||||
)
|
||||
}
|
||||
@@ -2,25 +2,9 @@ import * as Updates from 'expo-updates'
|
||||
import {useCallback, useEffect} from 'react'
|
||||
import {AppState} from 'react-native'
|
||||
import {logger} from '#/logger'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {t} from '@lingui/macro'
|
||||
|
||||
export function useOTAUpdate() {
|
||||
const {openModal} = useModalControls()
|
||||
|
||||
// HELPER FUNCTIONS
|
||||
const showUpdatePopup = useCallback(() => {
|
||||
openModal({
|
||||
name: 'confirm',
|
||||
title: t`Update Available`,
|
||||
message: t`A new version of the app is available. Please update to continue using the app.`,
|
||||
onPressConfirm: async () => {
|
||||
Updates.reloadAsync().catch(err => {
|
||||
throw err
|
||||
})
|
||||
},
|
||||
})
|
||||
}, [openModal])
|
||||
const checkForUpdate = useCallback(async () => {
|
||||
logger.debug('useOTAUpdate: Checking for update...')
|
||||
try {
|
||||
@@ -32,32 +16,26 @@ export function useOTAUpdate() {
|
||||
}
|
||||
// Otherwise fetch the update in the background, so even if the user rejects switching to latest version it will be done automatically on next relaunch.
|
||||
await Updates.fetchUpdateAsync()
|
||||
// show a popup modal
|
||||
showUpdatePopup()
|
||||
} catch (e) {
|
||||
logger.error('useOTAUpdate: Error while checking for update', {
|
||||
message: e,
|
||||
})
|
||||
}
|
||||
}, [showUpdatePopup])
|
||||
const updateEventListener = useCallback(
|
||||
(event: Updates.UpdateEvent) => {
|
||||
logger.debug('useOTAUpdate: Listening for update...')
|
||||
if (event.type === Updates.UpdateEventType.ERROR) {
|
||||
logger.error('useOTAUpdate: Error while listening for update', {
|
||||
message: event.message,
|
||||
})
|
||||
} else if (event.type === Updates.UpdateEventType.NO_UPDATE_AVAILABLE) {
|
||||
// Handle no update available
|
||||
// do nothing
|
||||
} else if (event.type === Updates.UpdateEventType.UPDATE_AVAILABLE) {
|
||||
// Handle update available
|
||||
// open modal, ask for user confirmation, and reload the app
|
||||
showUpdatePopup()
|
||||
}
|
||||
},
|
||||
[showUpdatePopup],
|
||||
)
|
||||
}, [])
|
||||
const updateEventListener = useCallback((event: Updates.UpdateEvent) => {
|
||||
logger.debug('useOTAUpdate: Listening for update...')
|
||||
if (event.type === Updates.UpdateEventType.ERROR) {
|
||||
logger.error('useOTAUpdate: Error while listening for update', {
|
||||
message: event.message,
|
||||
})
|
||||
} else if (event.type === Updates.UpdateEventType.NO_UPDATE_AVAILABLE) {
|
||||
// Handle no update available
|
||||
// do nothing
|
||||
} else if (event.type === Updates.UpdateEventType.UPDATE_AVAILABLE) {
|
||||
// Handle update available
|
||||
// open modal, ask for user confirmation, and reload the app
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// ADD EVENT LISTENERS
|
||||
|
||||
@@ -18,11 +18,18 @@ export async function openPicker(opts?: ImagePickerOptions) {
|
||||
Toast.show('You may only select up to 4 images')
|
||||
}
|
||||
|
||||
return (response.assets ?? []).slice(0, 4).map(image => ({
|
||||
mime: 'image/jpeg',
|
||||
height: image.height,
|
||||
width: image.width,
|
||||
path: image.uri,
|
||||
size: getDataUriSize(image.uri),
|
||||
}))
|
||||
return (response.assets ?? [])
|
||||
.slice(0, 4)
|
||||
.filter(asset => {
|
||||
if (asset.mimeType?.startsWith('image/')) return true
|
||||
Toast.show('Only image files are supported')
|
||||
return false
|
||||
})
|
||||
.map(image => ({
|
||||
mime: 'image/jpeg',
|
||||
height: image.height,
|
||||
width: image.width,
|
||||
path: image.uri,
|
||||
size: getDataUriSize(image.uri),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -2,9 +2,15 @@ import {RouteParams, Route} from './types'
|
||||
|
||||
export class Router {
|
||||
routes: [string, Route][] = []
|
||||
constructor(description: Record<string, string>) {
|
||||
constructor(description: Record<string, string | string[]>) {
|
||||
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)])
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,9 +12,9 @@ import {Share} from 'react-native'
|
||||
*/
|
||||
export async function shareUrl(url: string) {
|
||||
if (isAndroid) {
|
||||
Share.share({message: url})
|
||||
await Share.share({message: url})
|
||||
} else if (isIOS) {
|
||||
Share.share({url})
|
||||
await Share.share({url})
|
||||
} else {
|
||||
// React Native Share is not supported by web. Web Share API
|
||||
// has increasing but not full support, so default to clipboard
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
} from 'statsig-react-native-expo'
|
||||
import {useSession} from '../../state/session'
|
||||
import {sha256} from 'js-sha256'
|
||||
import {LogEvents} from './events'
|
||||
|
||||
export type {LogEvents}
|
||||
|
||||
const statsigOptions = {
|
||||
environment: {
|
||||
@@ -17,12 +20,28 @@ const statsigOptions = {
|
||||
initTimeoutMs: 1,
|
||||
}
|
||||
|
||||
export function logEvent(
|
||||
eventName: string,
|
||||
value?: string | number | null,
|
||||
metadata?: Record<string, string> | null,
|
||||
type FlatJSONRecord = Record<
|
||||
string,
|
||||
string | number | boolean | null | undefined
|
||||
>
|
||||
|
||||
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) {
|
||||
@@ -48,6 +67,18 @@ export function Provider({children}: {children: React.ReactNode}) {
|
||||
() => toStatsigUser(currentAccount?.did),
|
||||
[currentAccount?.did],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
function refresh() {
|
||||
// Intentionally refetching the config using the JS SDK rather than React SDK
|
||||
// so that the new config is stored in cache but isn't used during this session.
|
||||
// It will kick in for the next reload.
|
||||
Statsig.updateUser(currentStatsigUser)
|
||||
}
|
||||
const id = setInterval(refresh, 3 * 60e3 /* 3 min */)
|
||||
return () => clearInterval(id)
|
||||
}, [currentStatsigUser])
|
||||
|
||||
return (
|
||||
<StatsigProvider
|
||||
sdkKey="client-SXJakO39w9vIhl3D44u8UupyzFl4oZ2qPIkjwcvuPsV"
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import {BSKY_SERVICE} from 'lib/constants'
|
||||
import TLDs from 'tlds'
|
||||
import psl from 'psl'
|
||||
|
||||
export const BSKY_APP_HOST = 'https://bsky.app'
|
||||
|
||||
export function isValidDomain(str: string): boolean {
|
||||
return !!TLDs.find(tld => {
|
||||
let i = str.lastIndexOf(tld)
|
||||
@@ -67,8 +69,21 @@ export function isBskyAppUrl(url: string): boolean {
|
||||
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 {
|
||||
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 {
|
||||
@@ -149,7 +164,7 @@ export function linkRequiresWarning(uri: string, label: string) {
|
||||
const labelDomain = labelToDomain(label)
|
||||
|
||||
// If the uri started with a / we know it is internal.
|
||||
if (uri.startsWith('/')) {
|
||||
if (isRelativeUrl(uri)) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -222,3 +237,8 @@ export function splitApexDomain(hostname: string): [string, string] {
|
||||
hostnamep.domain,
|
||||
]
|
||||
}
|
||||
|
||||
export function createBskyAppAbsoluteUrl(path: string): string {
|
||||
const sanitizedPath = path.replace(BSKY_APP_HOST, '').replace(/^\/+/, '')
|
||||
return `${BSKY_APP_HOST.replace(/\/$/, '')}/${sanitizedPath}`
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export const router = new Router({
|
||||
ModerationModlists: '/moderation/modlists',
|
||||
ModerationMutedAccounts: '/moderation/muted-accounts',
|
||||
ModerationBlockedAccounts: '/moderation/blocked-accounts',
|
||||
Profile: '/profile/:name',
|
||||
Profile: ['/profile/:name', '/profile/:name/rss'],
|
||||
ProfileFollowers: '/profile/:name/followers',
|
||||
ProfileFollows: '/profile/:name/follows',
|
||||
ProfileList: '/profile/:name/lists/:rkey',
|
||||
|
||||
@@ -238,13 +238,14 @@ function FeedCardInner({feed}: {feed: FeedSourceInfo; config: FeedConfig}) {
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={[a.pt_2xs, a.flex_grow]}>
|
||||
<View style={[a.pt_2xs, a.flex_1, a.flex_grow]}>
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.font_bold,
|
||||
ctx.selected && styles.textSelected,
|
||||
]}>
|
||||
]}
|
||||
numberOfLines={1}>
|
||||
{feed.displayName}
|
||||
</Text>
|
||||
<Text
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React from 'react'
|
||||
import {SharedValue, useSharedValue} from 'react-native-reanimated'
|
||||
import {DialogControlRefProps} from '#/components/Dialog'
|
||||
import {Provider as GlobalDialogsProvider} from '#/components/dialogs/Context'
|
||||
|
||||
const DialogContext = React.createContext<{
|
||||
interface IDialogContext {
|
||||
/**
|
||||
* The currently active `useDialogControl` hooks.
|
||||
*/
|
||||
@@ -13,13 +14,18 @@ const DialogContext = React.createContext<{
|
||||
* The currently open dialogs, referenced by their IDs, generated from
|
||||
* `useId`.
|
||||
*/
|
||||
openDialogs: string[]
|
||||
}>({
|
||||
activeDialogs: {
|
||||
current: new Map(),
|
||||
},
|
||||
openDialogs: [],
|
||||
})
|
||||
openDialogs: React.MutableRefObject<Set<string>>
|
||||
/**
|
||||
* The counterpart to `accessibilityViewIsModal` for Android. This property
|
||||
* applies to the parent of all non-modal views, and prevents TalkBack from
|
||||
* navigating within content beneath an open dialog.
|
||||
*
|
||||
* @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<{
|
||||
closeAllDialogs(): boolean
|
||||
@@ -41,26 +47,42 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const activeDialogs = React.useRef<
|
||||
Map<string, React.MutableRefObject<DialogControlRefProps>>
|
||||
>(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(() => {
|
||||
activeDialogs.current.forEach(dialog => dialog.current.close())
|
||||
return openDialogs.length > 0
|
||||
}, [openDialogs])
|
||||
return openDialogs.current.size > 0
|
||||
}, [])
|
||||
|
||||
const setDialogIsOpen = React.useCallback(
|
||||
(id: string, isOpen: boolean) => {
|
||||
setOpenDialogs(prev => {
|
||||
const filtered = prev.filter(dialogId => dialogId !== id) as string[]
|
||||
return isOpen ? [...filtered, id] : filtered
|
||||
})
|
||||
if (isOpen) {
|
||||
openDialogs.current.add(id)
|
||||
importantForAccessibility.value = 'no-hide-descendants'
|
||||
} else {
|
||||
openDialogs.current.delete(id)
|
||||
if (openDialogs.current.size < 1) {
|
||||
importantForAccessibility.value = 'auto'
|
||||
}
|
||||
}
|
||||
},
|
||||
[setOpenDialogs],
|
||||
[importantForAccessibility],
|
||||
)
|
||||
|
||||
const context = React.useMemo(
|
||||
() => ({activeDialogs, openDialogs}),
|
||||
[openDialogs],
|
||||
const context = React.useMemo<IDialogContext>(
|
||||
() => ({
|
||||
activeDialogs: {
|
||||
current: new Map(),
|
||||
},
|
||||
openDialogs: {
|
||||
current: new Set(),
|
||||
},
|
||||
importantForAccessibility,
|
||||
}),
|
||||
[importantForAccessibility],
|
||||
)
|
||||
const controls = React.useMemo(
|
||||
() => ({closeAllDialogs, setDialogIsOpen}),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react'
|
||||
import {AppBskyActorDefs, AppBskyGraphDefs, ModerationUI} from '@atproto/api'
|
||||
import {StyleProp, ViewStyle} from 'react-native'
|
||||
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||
|
||||
import {ImageModel} from '#/state/models/media/image'
|
||||
@@ -9,17 +8,6 @@ import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
import {EmbedPlayerSource} from '#/lib/strings/embed-player'
|
||||
import {ThreadgateSetting} from '../queries/threadgate'
|
||||
|
||||
export interface ConfirmModal {
|
||||
name: 'confirm'
|
||||
title: string
|
||||
message: string | (() => JSX.Element)
|
||||
onPressConfirm: () => void | Promise<void>
|
||||
onPressCancel?: () => void | Promise<void>
|
||||
confirmBtnText?: string
|
||||
confirmBtnStyle?: StyleProp<ViewStyle>
|
||||
cancelBtnText?: string
|
||||
}
|
||||
|
||||
export interface EditProfileModal {
|
||||
name: 'edit-profile'
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
@@ -147,10 +135,6 @@ export interface PostLanguagesSettingsModal {
|
||||
name: 'post-languages-settings'
|
||||
}
|
||||
|
||||
export interface BirthDateSettingsModal {
|
||||
name: 'birth-date-settings'
|
||||
}
|
||||
|
||||
export interface VerifyEmailModal {
|
||||
name: 'verify-email'
|
||||
showReminder?: boolean
|
||||
@@ -191,7 +175,6 @@ export type Modal =
|
||||
| ChangeHandleModal
|
||||
| DeleteAccountModal
|
||||
| EditProfileModal
|
||||
| BirthDateSettingsModal
|
||||
| VerifyEmailModal
|
||||
| ChangeEmailModal
|
||||
| ChangePasswordModal
|
||||
@@ -225,7 +208,6 @@ export type Modal =
|
||||
| InviteCodesModal
|
||||
|
||||
// Generic
|
||||
| ConfirmModal
|
||||
| LinkWarningModal
|
||||
| EmbedConsentModal
|
||||
| InAppBrowserConsentModal
|
||||
|
||||
@@ -5,6 +5,11 @@ import * as WebBrowser from 'expo-web-browser'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {useModalControls} from '../modals'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {
|
||||
isBskyRSSUrl,
|
||||
isRelativeUrl,
|
||||
createBskyAppAbsoluteUrl,
|
||||
} from 'lib/strings/url-helpers'
|
||||
|
||||
type StateContext = persisted.Schema['useInAppBrowser']
|
||||
type SetContext = (v: persisted.Schema['useInAppBrowser']) => void
|
||||
@@ -57,6 +62,10 @@ export function useOpenLink() {
|
||||
|
||||
const openLink = React.useCallback(
|
||||
(url: string, override?: boolean) => {
|
||||
if (isBskyRSSUrl(url) && isRelativeUrl(url)) {
|
||||
url = createBskyAppAbsoluteUrl(url)
|
||||
}
|
||||
|
||||
if (isNative && !url.startsWith('mailto:')) {
|
||||
if (override === undefined && enabled === undefined) {
|
||||
openModal({
|
||||
|
||||
@@ -5,6 +5,7 @@ import {Shadow} from '#/state/cache/types'
|
||||
import {getAgent} from '#/state/session'
|
||||
import {updatePostShadow} from '#/state/cache/post-shadow'
|
||||
import {track} from '#/lib/analytics/analytics'
|
||||
import {logEvent, LogEvents} from '#/lib/statsig/statsig'
|
||||
import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue'
|
||||
|
||||
export const RQKEY = (postUri: string) => ['post', postUri]
|
||||
@@ -58,12 +59,14 @@ export function useGetPost() {
|
||||
|
||||
export function usePostLikeMutationQueue(
|
||||
post: Shadow<AppBskyFeedDefs.PostView>,
|
||||
logContext: LogEvents['post:like']['logContext'] &
|
||||
LogEvents['post:unlike']['logContext'],
|
||||
) {
|
||||
const postUri = post.uri
|
||||
const postCid = post.cid
|
||||
const initialLikeUri = post.viewer?.like
|
||||
const likeMutation = usePostLikeMutation()
|
||||
const unlikeMutation = usePostUnlikeMutation()
|
||||
const likeMutation = usePostLikeMutation(logContext)
|
||||
const unlikeMutation = usePostUnlikeMutation(logContext)
|
||||
|
||||
const queueToggle = useToggleMutationQueue({
|
||||
initialState: initialLikeUri,
|
||||
@@ -111,22 +114,30 @@ export function usePostLikeMutationQueue(
|
||||
return [queueLike, queueUnlike]
|
||||
}
|
||||
|
||||
function usePostLikeMutation() {
|
||||
function usePostLikeMutation(logContext: LogEvents['post:like']['logContext']) {
|
||||
return useMutation<
|
||||
{uri: string}, // responds with the uri of the like
|
||||
Error,
|
||||
{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() {
|
||||
track('Post:Like')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function usePostUnlikeMutation() {
|
||||
function usePostUnlikeMutation(
|
||||
logContext: LogEvents['post:unlike']['logContext'],
|
||||
) {
|
||||
return useMutation<void, Error, {postUri: string; likeUri: string}>({
|
||||
mutationFn: ({likeUri}) => getAgent().deleteLike(likeUri),
|
||||
mutationFn: ({likeUri}) => {
|
||||
logEvent('post:unlike', {logContext})
|
||||
return getAgent().deleteLike(likeUri)
|
||||
},
|
||||
onSuccess() {
|
||||
track('Post:Unlike')
|
||||
},
|
||||
@@ -135,12 +146,14 @@ function usePostUnlikeMutation() {
|
||||
|
||||
export function usePostRepostMutationQueue(
|
||||
post: Shadow<AppBskyFeedDefs.PostView>,
|
||||
logContext: LogEvents['post:repost']['logContext'] &
|
||||
LogEvents['post:unrepost']['logContext'],
|
||||
) {
|
||||
const postUri = post.uri
|
||||
const postCid = post.cid
|
||||
const initialRepostUri = post.viewer?.repost
|
||||
const repostMutation = usePostRepostMutation()
|
||||
const unrepostMutation = usePostUnrepostMutation()
|
||||
const repostMutation = usePostRepostMutation(logContext)
|
||||
const unrepostMutation = usePostUnrepostMutation(logContext)
|
||||
|
||||
const queueToggle = useToggleMutationQueue({
|
||||
initialState: initialRepostUri,
|
||||
@@ -188,22 +201,32 @@ export function usePostRepostMutationQueue(
|
||||
return [queueRepost, queueUnrepost]
|
||||
}
|
||||
|
||||
function usePostRepostMutation() {
|
||||
function usePostRepostMutation(
|
||||
logContext: LogEvents['post:repost']['logContext'],
|
||||
) {
|
||||
return useMutation<
|
||||
{uri: string}, // responds with the uri of the repost
|
||||
Error,
|
||||
{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() {
|
||||
track('Post:Repost')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function usePostUnrepostMutation() {
|
||||
function usePostUnrepostMutation(
|
||||
logContext: LogEvents['post:unrepost']['logContext'],
|
||||
) {
|
||||
return useMutation<void, Error, {postUri: string; repostUri: string}>({
|
||||
mutationFn: ({repostUri}) => getAgent().deleteRepost(repostUri),
|
||||
mutationFn: ({repostUri}) => {
|
||||
logEvent('post:unrepost', {logContext})
|
||||
return getAgent().deleteRepost(repostUri)
|
||||
},
|
||||
onSuccess() {
|
||||
track('Post:Unrepost')
|
||||
},
|
||||
|
||||
@@ -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 {STALE} from '#/state/queries'
|
||||
import {track} from '#/lib/analytics/analytics'
|
||||
import {logEvent, LogEvents} from '#/lib/statsig/statsig'
|
||||
import {ThreadNode} from './post-thread'
|
||||
|
||||
export const RQKEY = (did: string) => ['profile', did]
|
||||
@@ -186,11 +187,13 @@ export function useProfileUpdateMutation() {
|
||||
|
||||
export function useProfileFollowMutationQueue(
|
||||
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>,
|
||||
logContext: LogEvents['profile:follow']['logContext'] &
|
||||
LogEvents['profile:unfollow']['logContext'],
|
||||
) {
|
||||
const did = profile.did
|
||||
const initialFollowingUri = profile.viewer?.following
|
||||
const followMutation = useProfileFollowMutation()
|
||||
const unfollowMutation = useProfileUnfollowMutation()
|
||||
const followMutation = useProfileFollowMutation(logContext)
|
||||
const unfollowMutation = useProfileUnfollowMutation(logContext)
|
||||
|
||||
const queueToggle = useToggleMutationQueue({
|
||||
initialState: initialFollowingUri,
|
||||
@@ -237,9 +240,12 @@ export function useProfileFollowMutationQueue(
|
||||
return [queueFollow, queueUnfollow]
|
||||
}
|
||||
|
||||
function useProfileFollowMutation() {
|
||||
function useProfileFollowMutation(
|
||||
logContext: LogEvents['profile:follow']['logContext'],
|
||||
) {
|
||||
return useMutation<{uri: string; cid: string}, Error, {did: string}>({
|
||||
mutationFn: async ({did}) => {
|
||||
logEvent('profile:follow', {logContext})
|
||||
return await getAgent().follow(did)
|
||||
},
|
||||
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}>({
|
||||
mutationFn: async ({followUri}) => {
|
||||
logEvent('profile:unfollow', {logContext})
|
||||
track('Profile:Unfollow', {username: followUri})
|
||||
return await getAgent().deleteFollow(followUri)
|
||||
},
|
||||
|
||||
@@ -157,7 +157,7 @@ export function Step1({
|
||||
</View>
|
||||
)}
|
||||
|
||||
{uiState.inviteCode ? (
|
||||
{!uiState.isInviteCodeRequired || uiState.inviteCode ? (
|
||||
<>
|
||||
<View style={s.pb20}>
|
||||
<Text
|
||||
|
||||
@@ -56,7 +56,7 @@ export function RecommendedFollowsItem({
|
||||
)
|
||||
}
|
||||
|
||||
export function ProfileCard({
|
||||
function ProfileCard({
|
||||
profile,
|
||||
onFollowStateChange,
|
||||
moderation,
|
||||
@@ -72,7 +72,10 @@ export function ProfileCard({
|
||||
const pal = usePalette('default')
|
||||
const [addingMoreSuggestions, setAddingMoreSuggestions] =
|
||||
React.useState(false)
|
||||
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile)
|
||||
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
|
||||
profile,
|
||||
'RecommendedFollowsItem',
|
||||
)
|
||||
|
||||
const onToggleFollow = React.useCallback(async () => {
|
||||
try {
|
||||
|
||||
@@ -49,7 +49,7 @@ import {SuggestedLanguage} from './select-language/SuggestedLanguage'
|
||||
import {insertMentionAt} from 'lib/strings/mention-manip'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useModals, useModalControls} from '#/state/modals'
|
||||
import {useModals} from '#/state/modals'
|
||||
import {useRequireAltTextEnabled} from '#/state/preferences'
|
||||
import {
|
||||
useLanguagePrefs,
|
||||
@@ -63,6 +63,9 @@ import {emitPostCreated} from '#/state/events'
|
||||
import {ThreadgateSetting} from '#/state/queries/threadgate'
|
||||
import {logger} from '#/logger'
|
||||
import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {useDialogStateControlContext} from 'state/dialogs'
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
|
||||
type Props = ComposerOpts
|
||||
export const ComposePost = observer(function ComposePost({
|
||||
@@ -76,8 +79,7 @@ export const ComposePost = observer(function ComposePost({
|
||||
}: Props) {
|
||||
const {currentAccount} = useSession()
|
||||
const {data: currentProfile} = useProfileQuery({did: currentAccount!.did})
|
||||
const {isModalActive, activeModals} = useModals()
|
||||
const {openModal, closeModal} = useModalControls()
|
||||
const {isModalActive} = useModals()
|
||||
const {closeComposer} = useComposerControls()
|
||||
const {track} = useAnalytics()
|
||||
const pal = usePalette('default')
|
||||
@@ -87,6 +89,9 @@ export const ComposePost = observer(function ComposePost({
|
||||
const langPrefs = useLanguagePrefs()
|
||||
const setLangPrefs = useLanguagePrefsApi()
|
||||
const textInput = useRef<TextInputRef>(null)
|
||||
const discardPromptControl = Prompt.usePromptControl()
|
||||
const {closeAllDialogs} = useDialogStateControlContext()
|
||||
|
||||
const [isKeyboardVisible] = useIsKeyboardVisible({iosUseWillEvents: true})
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [processingState, setProcessingState] = useState('')
|
||||
@@ -134,27 +139,21 @@ export const ComposePost = observer(function ComposePost({
|
||||
|
||||
const onPressCancel = useCallback(() => {
|
||||
if (graphemeLength > 0 || !gallery.isEmpty) {
|
||||
if (activeModals.some(modal => modal.name === 'confirm')) {
|
||||
closeModal()
|
||||
}
|
||||
closeAllDialogs()
|
||||
if (Keyboard) {
|
||||
Keyboard.dismiss()
|
||||
}
|
||||
openModal({
|
||||
name: 'confirm',
|
||||
title: _(msg`Discard draft`),
|
||||
onPressConfirm: onClose,
|
||||
onPressCancel: () => {
|
||||
closeModal()
|
||||
},
|
||||
message: _(msg`Are you sure you'd like to discard this draft?`),
|
||||
confirmBtnText: _(msg`Discard`),
|
||||
confirmBtnStyle: {backgroundColor: colors.red4},
|
||||
})
|
||||
discardPromptControl.open()
|
||||
} else {
|
||||
onClose()
|
||||
}
|
||||
}, [openModal, closeModal, activeModals, onClose, graphemeLength, gallery, _])
|
||||
}, [
|
||||
graphemeLength,
|
||||
gallery.isEmpty,
|
||||
closeAllDialogs,
|
||||
discardPromptControl,
|
||||
onClose,
|
||||
])
|
||||
// android back button
|
||||
useEffect(() => {
|
||||
if (!isAndroid) {
|
||||
@@ -257,6 +256,16 @@ export const ComposePost = observer(function ComposePost({
|
||||
setIsProcessing(false)
|
||||
return
|
||||
} 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', {
|
||||
imageCount: gallery.size,
|
||||
})
|
||||
@@ -488,6 +497,15 @@ export const ComposePost = observer(function ComposePost({
|
||||
<CharProgress count={graphemeLength} />
|
||||
</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>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, {useCallback} from 'react'
|
||||
import {TouchableOpacity, StyleSheet} from 'react-native'
|
||||
import * as MediaLibrary from 'expo-media-library'
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
@@ -24,6 +25,8 @@ export function OpenCameraBtn({gallery}: Props) {
|
||||
const {track} = useAnalytics()
|
||||
const {_} = useLingui()
|
||||
const {requestCameraAccessIfNeeded} = useCameraPermission()
|
||||
const [mediaPermissionRes, requestMediaPermission] =
|
||||
MediaLibrary.usePermissions()
|
||||
|
||||
const onPressTakePicture = useCallback(async () => {
|
||||
track('Composer:CameraOpened')
|
||||
@@ -31,6 +34,9 @@ export function OpenCameraBtn({gallery}: Props) {
|
||||
if (!(await requestCameraAccessIfNeeded())) {
|
||||
return
|
||||
}
|
||||
if (!mediaPermissionRes?.granted && mediaPermissionRes?.canAskAgain) {
|
||||
await requestMediaPermission()
|
||||
}
|
||||
|
||||
const img = await openCamera({
|
||||
width: POST_IMG_MAX.width,
|
||||
@@ -38,12 +44,23 @@ export function OpenCameraBtn({gallery}: Props) {
|
||||
freeStyleCropEnabled: true,
|
||||
})
|
||||
|
||||
// If we don't have permissions it's fine, we just wont save it. The post itself will still have access to
|
||||
// the image even without these permissions
|
||||
if (mediaPermissionRes) {
|
||||
await MediaLibrary.createAssetAsync(img.path)
|
||||
}
|
||||
gallery.add(img)
|
||||
} catch (err: any) {
|
||||
// ignore
|
||||
logger.warn('Error using camera', {error: err})
|
||||
}
|
||||
}, [gallery, track, requestCameraAccessIfNeeded])
|
||||
}, [
|
||||
gallery,
|
||||
track,
|
||||
requestCameraAccessIfNeeded,
|
||||
mediaPermissionRes,
|
||||
requestMediaPermission,
|
||||
])
|
||||
|
||||
const shouldShowCameraButton = isNative || isMobileWeb
|
||||
if (!shouldShowCameraButton) {
|
||||
|
||||
@@ -6,14 +6,11 @@ import {RichText} from '#/components/RichText'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {s} from 'lib/styles'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {pluralize} from 'lib/strings/helpers'
|
||||
import {AtUri} from '@atproto/api'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {logger} from '#/logger'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {
|
||||
@@ -26,6 +23,8 @@ import {
|
||||
import {useFeedSourceInfoQuery, FeedSourceInfo} from '#/state/queries/feed'
|
||||
import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
|
||||
import {useTheme} from '#/alf'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {useNavigationDeduped} from 'lib/hooks/useNavigationDeduped'
|
||||
|
||||
export function FeedSourceCard({
|
||||
feedUri,
|
||||
@@ -86,8 +85,8 @@ export function FeedSourceCardLoaded({
|
||||
const t = useTheme()
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const {openModal} = useModalControls()
|
||||
const removePromptControl = Prompt.usePromptControl()
|
||||
const navigation = useNavigationDeduped()
|
||||
|
||||
const {isPending: isSavePending, mutateAsync: saveFeed} =
|
||||
useSaveFeedMutation()
|
||||
@@ -97,40 +96,45 @@ export function FeedSourceCardLoaded({
|
||||
|
||||
const isSaved = Boolean(preferences?.feeds?.saved?.includes(feed?.uri || ''))
|
||||
|
||||
const onSave = React.useCallback(async () => {
|
||||
if (!feed) return
|
||||
|
||||
try {
|
||||
if (pinOnSave) {
|
||||
await pinFeed({uri: feed.uri})
|
||||
} else {
|
||||
await saveFeed({uri: feed.uri})
|
||||
}
|
||||
Toast.show(_(msg`Added to my feeds`))
|
||||
} catch (e) {
|
||||
Toast.show(_(msg`There was an issue contacting your server`))
|
||||
logger.error('Failed to save feed', {message: e})
|
||||
}
|
||||
}, [_, feed, pinFeed, pinOnSave, saveFeed])
|
||||
|
||||
const onUnsave = React.useCallback(async () => {
|
||||
if (!feed) return
|
||||
|
||||
try {
|
||||
await removeFeed({uri: feed.uri})
|
||||
// await item.unsave()
|
||||
Toast.show(_(msg`Removed from my feeds`))
|
||||
} catch (e) {
|
||||
Toast.show(_(msg`There was an issue contacting your server`))
|
||||
logger.error('Failed to unsave feed', {message: e})
|
||||
}
|
||||
}, [_, feed, removeFeed])
|
||||
|
||||
const onToggleSaved = React.useCallback(async () => {
|
||||
// Only feeds can be un/saved, lists are handled elsewhere
|
||||
if (feed?.type !== 'feed') return
|
||||
|
||||
if (isSaved) {
|
||||
openModal({
|
||||
name: 'confirm',
|
||||
title: _(msg`Remove from my feeds`),
|
||||
message: _(msg`Remove ${feed?.displayName} from my feeds?`),
|
||||
onPressConfirm: async () => {
|
||||
try {
|
||||
await removeFeed({uri: feed.uri})
|
||||
// await item.unsave()
|
||||
Toast.show(_(msg`Removed from my feeds`))
|
||||
} catch (e) {
|
||||
Toast.show(_(msg`There was an issue contacting your server`))
|
||||
logger.error('Failed to unsave feed', {message: e})
|
||||
}
|
||||
},
|
||||
})
|
||||
removePromptControl.open()
|
||||
} else {
|
||||
try {
|
||||
if (pinOnSave) {
|
||||
await pinFeed({uri: feed.uri})
|
||||
} else {
|
||||
await saveFeed({uri: feed.uri})
|
||||
}
|
||||
Toast.show(_(msg`Added to my feeds`))
|
||||
} catch (e) {
|
||||
Toast.show(_(msg`There was an issue contacting your server`))
|
||||
logger.error('Failed to save feed', {message: e})
|
||||
}
|
||||
await onSave()
|
||||
}
|
||||
}, [isSaved, openModal, feed, removeFeed, saveFeed, _, pinOnSave, pinFeed])
|
||||
}, [feed?.type, isSaved, removePromptControl, onSave])
|
||||
|
||||
/*
|
||||
* LOAD STATE
|
||||
@@ -168,25 +172,7 @@ export function FeedSourceCardLoaded({
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Remove from my feeds`)}
|
||||
accessibilityHint=""
|
||||
onPress={() => {
|
||||
openModal({
|
||||
name: 'confirm',
|
||||
title: _(msg`Remove from my feeds`),
|
||||
message: _(msg`Remove this feed from my feeds?`),
|
||||
onPressConfirm: async () => {
|
||||
try {
|
||||
await removeFeed({uri: feedUri})
|
||||
// await item.unsave()
|
||||
Toast.show(_(msg`Removed from my feeds`))
|
||||
} catch (e) {
|
||||
Toast.show(
|
||||
_(msg`There was an issue contacting your server`),
|
||||
)
|
||||
logger.error('Failed to unsave feed', {message: e})
|
||||
}
|
||||
},
|
||||
})
|
||||
}}
|
||||
onPress={onToggleSaved}
|
||||
hitSlop={15}
|
||||
style={styles.btn}>
|
||||
<FontAwesomeIcon
|
||||
@@ -200,89 +186,104 @@ export function FeedSourceCardLoaded({
|
||||
)
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
testID={`feed-${feed.displayName}`}
|
||||
accessibilityRole="button"
|
||||
style={[styles.container, pal.border, style]}
|
||||
onPress={() => {
|
||||
if (feed.type === 'feed') {
|
||||
navigation.push('ProfileFeed', {
|
||||
name: feed.creatorDid,
|
||||
rkey: new AtUri(feed.uri).rkey,
|
||||
})
|
||||
} else if (feed.type === 'list') {
|
||||
navigation.push('ProfileList', {
|
||||
name: feed.creatorDid,
|
||||
rkey: new AtUri(feed.uri).rkey,
|
||||
})
|
||||
}
|
||||
}}
|
||||
key={feed.uri}>
|
||||
<View style={[styles.headerContainer]}>
|
||||
<View style={[s.mr10]}>
|
||||
<UserAvatar type="algo" size={36} avatar={feed.avatar} />
|
||||
</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>
|
||||
<>
|
||||
<Pressable
|
||||
testID={`feed-${feed.displayName}`}
|
||||
accessibilityRole="button"
|
||||
style={[styles.container, pal.border, style]}
|
||||
onPress={() => {
|
||||
if (feed.type === 'feed') {
|
||||
navigation.push('ProfileFeed', {
|
||||
name: feed.creatorDid,
|
||||
rkey: new AtUri(feed.uri).rkey,
|
||||
})
|
||||
} else if (feed.type === 'list') {
|
||||
navigation.push('ProfileList', {
|
||||
name: feed.creatorDid,
|
||||
rkey: new AtUri(feed.uri).rkey,
|
||||
})
|
||||
}
|
||||
}}
|
||||
key={feed.uri}>
|
||||
<View style={[styles.headerContainer]}>
|
||||
<View style={[s.mr10]}>
|
||||
<UserAvatar type="algo" size={36} avatar={feed.avatar} />
|
||||
</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>
|
||||
|
||||
{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>
|
||||
onConfirm={onUnsave}
|
||||
confirmButtonCta={_(msg`Remove`)}
|
||||
confirmButtonColor="negative"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
UsePreferencesQueryResponse,
|
||||
} from '#/state/queries/preferences'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings'
|
||||
|
||||
export const snapPoints = ['90%']
|
||||
|
||||
@@ -107,11 +109,11 @@ function AdultContentEnabledPref() {
|
||||
const {_} = useLingui()
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const {mutate, variables} = usePreferencesSetAdultContentMutation()
|
||||
const {openModal} = useModalControls()
|
||||
const bithdayDialogControl = useDialogControl()
|
||||
|
||||
const onSetAge = React.useCallback(
|
||||
() => openModal({name: 'birth-date-settings'}),
|
||||
[openModal],
|
||||
() => bithdayDialogControl.open(),
|
||||
[bithdayDialogControl],
|
||||
)
|
||||
|
||||
const onToggleAdultContent = React.useCallback(async () => {
|
||||
@@ -135,6 +137,10 @@ function AdultContentEnabledPref() {
|
||||
|
||||
return (
|
||||
<View style={s.mb10}>
|
||||
<BirthDateSettingsDialog
|
||||
control={bithdayDialogControl}
|
||||
preferences={preferences}
|
||||
/>
|
||||
{isIOS ? (
|
||||
preferences?.adultContentEnabled ? null : (
|
||||
<Text type="md" style={pal.textLight}>
|
||||
|
||||
@@ -6,7 +6,6 @@ import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
|
||||
import {useModals, useModalControls} from '#/state/modals'
|
||||
import * as ConfirmModal from './Confirm'
|
||||
import * as EditProfileModal from './EditProfile'
|
||||
import * as RepostModal from './Repost'
|
||||
import * as SelfLabelModal from './SelfLabel'
|
||||
@@ -26,7 +25,6 @@ import * as ContentFilteringSettingsModal from './ContentFilteringSettings'
|
||||
import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
|
||||
import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
|
||||
import * as ModerationDetailsModal from './ModerationDetails'
|
||||
import * as BirthDateSettingsModal from './BirthDateSettings'
|
||||
import * as VerifyEmailModal from './VerifyEmail'
|
||||
import * as ChangeEmailModal from './ChangeEmail'
|
||||
import * as ChangePasswordModal from './ChangePassword'
|
||||
@@ -66,10 +64,7 @@ export function ModalsContainer() {
|
||||
|
||||
let snapPoints: (string | number)[] = DEFAULT_SNAPPOINTS
|
||||
let element
|
||||
if (activeModal?.name === 'confirm') {
|
||||
snapPoints = ConfirmModal.snapPoints
|
||||
element = <ConfirmModal.Component {...activeModal} />
|
||||
} else if (activeModal?.name === 'edit-profile') {
|
||||
if (activeModal?.name === 'edit-profile') {
|
||||
snapPoints = EditProfileModal.snapPoints
|
||||
element = <EditProfileModal.Component {...activeModal} />
|
||||
} else if (activeModal?.name === 'report') {
|
||||
@@ -126,9 +121,6 @@ export function ModalsContainer() {
|
||||
} else if (activeModal?.name === 'moderation-details') {
|
||||
snapPoints = ModerationDetailsModal.snapPoints
|
||||
element = <ModerationDetailsModal.Component {...activeModal} />
|
||||
} else if (activeModal?.name === 'birth-date-settings') {
|
||||
snapPoints = BirthDateSettingsModal.snapPoints
|
||||
element = <BirthDateSettingsModal.Component />
|
||||
} else if (activeModal?.name === 'verify-email') {
|
||||
snapPoints = VerifyEmailModal.snapPoints
|
||||
element = <VerifyEmailModal.Component {...activeModal} />
|
||||
|
||||
@@ -7,7 +7,6 @@ import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock'
|
||||
|
||||
import {useModals, useModalControls} from '#/state/modals'
|
||||
import type {Modal as ModalIface} from '#/state/modals'
|
||||
import * as ConfirmModal from './Confirm'
|
||||
import * as EditProfileModal from './EditProfile'
|
||||
import * as ReportModal from './report/Modal'
|
||||
import * as AppealLabelModal from './AppealLabel'
|
||||
@@ -28,7 +27,6 @@ import * as ContentFilteringSettingsModal from './ContentFilteringSettings'
|
||||
import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
|
||||
import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
|
||||
import * as ModerationDetailsModal from './ModerationDetails'
|
||||
import * as BirthDateSettingsModal from './BirthDateSettings'
|
||||
import * as VerifyEmailModal from './VerifyEmail'
|
||||
import * as ChangeEmailModal from './ChangeEmail'
|
||||
import * as ChangePasswordModal from './ChangePassword'
|
||||
@@ -78,9 +76,7 @@ function Modal({modal}: {modal: ModalIface}) {
|
||||
}
|
||||
|
||||
let element
|
||||
if (modal.name === 'confirm') {
|
||||
element = <ConfirmModal.Component {...modal} />
|
||||
} else if (modal.name === 'edit-profile') {
|
||||
if (modal.name === 'edit-profile') {
|
||||
element = <EditProfileModal.Component {...modal} />
|
||||
} else if (modal.name === 'report') {
|
||||
element = <ReportModal.Component {...modal} />
|
||||
@@ -120,8 +116,6 @@ function Modal({modal}: {modal: ModalIface}) {
|
||||
element = <EditImageModal.Component {...modal} />
|
||||
} else if (modal.name === 'moderation-details') {
|
||||
element = <ModerationDetailsModal.Component {...modal} />
|
||||
} else if (modal.name === 'birth-date-settings') {
|
||||
element = <BirthDateSettingsModal.Component />
|
||||
} else if (modal.name === 'verify-email') {
|
||||
element = <VerifyEmailModal.Component {...modal} />
|
||||
} else if (modal.name === 'change-email') {
|
||||
|
||||
@@ -182,7 +182,6 @@ let FeedItem = ({
|
||||
testID={`feedItem-by-${item.notification.author.handle}`}
|
||||
style={[
|
||||
styles.outer,
|
||||
pal.view,
|
||||
pal.border,
|
||||
item.notification.isRead
|
||||
? 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 profile: Shadow<AppBskyActorDefs.ProfileViewBasic> =
|
||||
useProfileShadow(profileUnshadowed)
|
||||
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile)
|
||||
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
|
||||
profile,
|
||||
'PostThreadItem',
|
||||
)
|
||||
const requireAuth = useRequireAuth()
|
||||
|
||||
const isFollowing = !!profile.viewer?.following
|
||||
|
||||
@@ -407,6 +407,7 @@ let PostThreadItemLoaded = ({
|
||||
record={record}
|
||||
richText={richText}
|
||||
onPressReply={onPressReply}
|
||||
logContext="PostThreadItem"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
@@ -431,7 +432,6 @@ let PostThreadItemLoaded = ({
|
||||
<PostHider
|
||||
testID={`postThreadItem-by-${post.author.handle}`}
|
||||
href={postHref}
|
||||
style={[pal.view]}
|
||||
moderation={moderation.content}
|
||||
iconSize={isThreadedChild ? 26 : 38}
|
||||
iconStyles={
|
||||
@@ -560,6 +560,7 @@ let PostThreadItemLoaded = ({
|
||||
record={record}
|
||||
richText={richText}
|
||||
onPressReply={onPressReply}
|
||||
logContext="PostThreadItem"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
@@ -620,7 +621,6 @@ function PostOuterWrapper({
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
pal.view,
|
||||
pal.border,
|
||||
styles.cursor,
|
||||
{
|
||||
@@ -648,7 +648,6 @@ function PostOuterWrapper({
|
||||
<View
|
||||
style={[
|
||||
styles.outer,
|
||||
pal.view,
|
||||
pal.border,
|
||||
showParentReplyLine && hasPrecedingItem && styles.noTopBorder,
|
||||
styles.cursor,
|
||||
|
||||
@@ -133,7 +133,7 @@ function PostInner({
|
||||
}, [setLimitLines])
|
||||
|
||||
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} />}
|
||||
<View style={styles.layout}>
|
||||
<View style={styles.layoutAvi}>
|
||||
@@ -220,6 +220,7 @@ function PostInner({
|
||||
record={record}
|
||||
richText={richText}
|
||||
onPressReply={onPressReply}
|
||||
logContext="Post"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -33,6 +33,7 @@ import {useLingui} from '@lingui/react'
|
||||
import {DiscoverFallbackHeader} from './DiscoverFallbackHeader'
|
||||
import {FALLBACK_MARKER_POST} from '#/lib/api/feed/home'
|
||||
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
|
||||
const LOADING_ITEM = {_reactKey: '__loading__'}
|
||||
const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
|
||||
@@ -223,16 +224,29 @@ let Feed = ({
|
||||
setIsPTRing(false)
|
||||
}, [refetch, track, setIsPTRing, onHasNew])
|
||||
|
||||
const feedType = feed.split('|')[0]
|
||||
const onEndReached = React.useCallback(async () => {
|
||||
if (isFetching || !hasNextPage || isError) return
|
||||
|
||||
logEvent('feed:endReached', {
|
||||
feedType: feedType,
|
||||
itemCount: feedItems.length,
|
||||
})
|
||||
track('Feed:onEndReached')
|
||||
try {
|
||||
await fetchNextPage()
|
||||
} catch (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(() => {
|
||||
refetch()
|
||||
|
||||
@@ -9,13 +9,13 @@ import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {logger} from '#/logger'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {msg as msgLingui, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {FeedDescriptor} from '#/state/queries/post-feed'
|
||||
import {EmptyState} from '../util/EmptyState'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {useRemoveFeedMutation} from '#/state/queries/preferences'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
|
||||
export enum KnownError {
|
||||
Block = 'Block',
|
||||
@@ -118,35 +118,29 @@ function FeedgenErrorMessage({
|
||||
)
|
||||
const [_, uri] = feedDesc.split('|')
|
||||
const [ownerDid] = safeParseFeedgenUri(uri)
|
||||
const {openModal, closeModal} = useModalControls()
|
||||
const removePromptControl = Prompt.usePromptControl()
|
||||
const {mutateAsync: removeFeed} = useRemoveFeedMutation()
|
||||
|
||||
const onViewProfile = React.useCallback(() => {
|
||||
navigation.navigate('Profile', {name: ownerDid})
|
||||
}, [navigation, ownerDid])
|
||||
|
||||
const onPressRemoveFeed = React.useCallback(() => {
|
||||
removePromptControl.open()
|
||||
}, [removePromptControl])
|
||||
|
||||
const onRemoveFeed = React.useCallback(async () => {
|
||||
openModal({
|
||||
name: 'confirm',
|
||||
title: _l(msgLingui`Remove feed`),
|
||||
message: _l(msgLingui`Remove this feed from your saved feeds?`),
|
||||
async onPressConfirm() {
|
||||
try {
|
||||
await removeFeed({uri})
|
||||
} catch (err) {
|
||||
Toast.show(
|
||||
_l(
|
||||
msgLingui`There was an an issue removing this feed. Please check your internet connection and try again.`,
|
||||
),
|
||||
)
|
||||
logger.error('Failed to remove feed', {message: err})
|
||||
}
|
||||
},
|
||||
onPressCancel() {
|
||||
closeModal()
|
||||
},
|
||||
})
|
||||
}, [openModal, closeModal, uri, removeFeed, _l])
|
||||
try {
|
||||
await removeFeed({uri})
|
||||
} catch (err) {
|
||||
Toast.show(
|
||||
_l(
|
||||
msgLingui`There was an an issue removing this feed. Please check your internet connection and try again.`,
|
||||
),
|
||||
)
|
||||
logger.error('Failed to remove feed', {message: err})
|
||||
}
|
||||
}, [uri, removeFeed, _l])
|
||||
|
||||
const cta = React.useMemo(() => {
|
||||
switch (knownError) {
|
||||
@@ -179,27 +173,38 @@ function FeedgenErrorMessage({
|
||||
}, [knownError, onViewProfile, onRemoveFeed, _l])
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
pal.border,
|
||||
pal.viewLight,
|
||||
{
|
||||
borderTopWidth: 1,
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 18,
|
||||
gap: 12,
|
||||
},
|
||||
]}>
|
||||
<Text style={pal.text}>{msg}</Text>
|
||||
<>
|
||||
<View
|
||||
style={[
|
||||
pal.border,
|
||||
pal.viewLight,
|
||||
{
|
||||
borderTopWidth: 1,
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 18,
|
||||
gap: 12,
|
||||
},
|
||||
]}>
|
||||
<Text style={pal.text}>{msg}</Text>
|
||||
|
||||
{rawError?.message && (
|
||||
<Text style={pal.textLight}>
|
||||
<Trans>Message from server: {rawError.message}</Trans>
|
||||
</Text>
|
||||
)}
|
||||
{rawError?.message && (
|
||||
<Text style={pal.textLight}>
|
||||
<Trans>Message from server: {rawError.message}</Trans>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{cta}
|
||||
</View>
|
||||
{cta}
|
||||
</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"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -144,7 +144,6 @@ let FeedItemInner = ({
|
||||
|
||||
const outerStyles = [
|
||||
styles.outer,
|
||||
pal.view,
|
||||
{
|
||||
borderColor: pal.colors.border,
|
||||
paddingBottom:
|
||||
@@ -310,6 +309,7 @@ let FeedItemInner = ({
|
||||
showAppealLabelItem={
|
||||
post.author.did === currentAccount?.did && isModeratedPost
|
||||
}
|
||||
logContext="FeedItem"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -78,11 +78,7 @@ function ViewFullThread({slice}: {slice: FeedPostSlice}) {
|
||||
}, [slice.rootUri])
|
||||
|
||||
return (
|
||||
<Link
|
||||
style={[pal.view, styles.viewFullThread]}
|
||||
href={itemHref}
|
||||
asAnchor
|
||||
noFeedback>
|
||||
<Link style={[styles.viewFullThread]} href={itemHref} asAnchor noFeedback>
|
||||
<View style={styles.viewFullThreadDots}>
|
||||
<Svg width="4" height="40">
|
||||
<Line
|
||||
|
||||
@@ -13,13 +13,18 @@ export function FollowButton({
|
||||
followedType = 'default',
|
||||
profile,
|
||||
labelStyle,
|
||||
logContext,
|
||||
}: {
|
||||
unfollowedType?: ButtonType
|
||||
followedType?: ButtonType
|
||||
profile: Shadow<AppBskyActorDefs.ProfileViewBasic>
|
||||
labelStyle?: StyleProp<TextStyle>
|
||||
logContext: 'ProfileCard'
|
||||
}) {
|
||||
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile)
|
||||
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
|
||||
profile,
|
||||
logContext,
|
||||
)
|
||||
const {_} = useLingui()
|
||||
|
||||
const onPressFollow = async () => {
|
||||
|
||||
@@ -230,7 +230,9 @@ export function ProfileCardWithFollowBtn({
|
||||
renderButton={
|
||||
isMe
|
||||
? undefined
|
||||
: profileShadow => <FollowButton profile={profileShadow} />
|
||||
: profileShadow => (
|
||||
<FollowButton profile={profileShadow} logContext="ProfileCard" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
} from 'react-native'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
import {
|
||||
AppBskyActorDefs,
|
||||
ModerationOpts,
|
||||
@@ -17,7 +16,7 @@ import {
|
||||
import {Trans, msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {isNative, isWeb} from 'platform/detection'
|
||||
import {isNative} from 'platform/detection'
|
||||
import {BlurView} from '../util/BlurView'
|
||||
import * as Toast from '../util/Toast'
|
||||
import {LoadingPlaceholder} from '../util/LoadingPlaceholder'
|
||||
@@ -28,14 +27,11 @@ import {UserAvatar} from '../util/UserAvatar'
|
||||
import {UserBanner} from '../util/UserBanner'
|
||||
import {ProfileHeaderAlerts} from '../util/moderation/ProfileHeaderAlerts'
|
||||
import {formatCount} from '../util/numeric/format'
|
||||
import {NativeDropdown, DropdownItem} from '../util/forms/NativeDropdown'
|
||||
import {Link} from '../util/Link'
|
||||
import {ProfileHeaderSuggestedFollows} from './ProfileHeaderSuggestedFollows'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {useLightboxControls, ProfileImageLightbox} from '#/state/lightbox'
|
||||
import {
|
||||
RQKEY as profileQueryKey,
|
||||
useProfileMuteMutationQueue,
|
||||
useProfileBlockMutationQueue,
|
||||
useProfileFollowMutationQueue,
|
||||
} from '#/state/queries/profile'
|
||||
@@ -46,9 +42,7 @@ import {BACK_HITSLOP} from 'lib/constants'
|
||||
import {isInvalidHandle, sanitizeHandle} from 'lib/strings/handles'
|
||||
import {makeProfileLink} from 'lib/routes/links'
|
||||
import {pluralize} from 'lib/strings/helpers'
|
||||
import {toShareUrl} from 'lib/strings/url-helpers'
|
||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
import {shareUrl} from 'lib/sharing'
|
||||
import {s, colors} from 'lib/styles'
|
||||
import {logger} from '#/logger'
|
||||
import {useSession} from '#/state/session'
|
||||
@@ -57,6 +51,8 @@ import {useRequireAuth} from '#/state/session'
|
||||
import {LabelInfo} from '../util/moderation/LabelInfo'
|
||||
import {useProfileShadow} from 'state/cache/profile-shadow'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {ProfileMenu} from 'view/com/profile/ProfileMenu'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
|
||||
let ProfileHeaderLoading = (_props: {}): React.ReactNode => {
|
||||
const pal = usePalette('default')
|
||||
@@ -107,21 +103,17 @@ let ProfileHeader = ({
|
||||
const invalidHandle = isInvalidHandle(profile.handle)
|
||||
const {isDesktop} = useWebMediaQueries()
|
||||
const [showSuggestedFollows, setShowSuggestedFollows] = React.useState(false)
|
||||
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile)
|
||||
const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile)
|
||||
const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
|
||||
const queryClient = useQueryClient()
|
||||
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
|
||||
profile,
|
||||
'ProfileHeader',
|
||||
)
|
||||
const [__, queueUnblock] = useProfileBlockMutationQueue(profile)
|
||||
const unblockPromptControl = Prompt.usePromptControl()
|
||||
const moderation = useMemo(
|
||||
() => moderateProfile(profile, moderationOpts),
|
||||
[profile, moderationOpts],
|
||||
)
|
||||
|
||||
const invalidateProfileQuery = React.useCallback(() => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: profileQueryKey(profile.did),
|
||||
})
|
||||
}, [queryClient, profile.did])
|
||||
|
||||
const onPressBack = React.useCallback(() => {
|
||||
if (navigation.canGoBack()) {
|
||||
navigation.goBack()
|
||||
@@ -189,204 +181,23 @@ let ProfileHeader = ({
|
||||
})
|
||||
}, [track, openModal, profile])
|
||||
|
||||
const onPressShare = React.useCallback(() => {
|
||||
track('ProfileHeader:ShareButtonClicked')
|
||||
shareUrl(toShareUrl(makeProfileLink(profile)))
|
||||
}, [track, profile])
|
||||
|
||||
const onPressAddRemoveLists = React.useCallback(() => {
|
||||
track('ProfileHeader:AddToListsButtonClicked')
|
||||
openModal({
|
||||
name: 'user-add-remove-lists',
|
||||
subject: profile.did,
|
||||
handle: profile.handle,
|
||||
displayName: profile.displayName || profile.handle,
|
||||
onAdd: invalidateProfileQuery,
|
||||
onRemove: invalidateProfileQuery,
|
||||
})
|
||||
}, [track, profile, openModal, invalidateProfileQuery])
|
||||
|
||||
const onPressMuteAccount = React.useCallback(async () => {
|
||||
track('ProfileHeader:MuteAccountButtonClicked')
|
||||
try {
|
||||
await queueMute()
|
||||
Toast.show(_(msg`Account muted`))
|
||||
} catch (e: any) {
|
||||
if (e?.name !== 'AbortError') {
|
||||
logger.error('Failed to mute account', {message: e})
|
||||
Toast.show(_(msg`There was an issue! ${e.toString()}`))
|
||||
}
|
||||
}
|
||||
}, [track, queueMute, _])
|
||||
|
||||
const onPressUnmuteAccount = React.useCallback(async () => {
|
||||
track('ProfileHeader:UnmuteAccountButtonClicked')
|
||||
try {
|
||||
await queueUnmute()
|
||||
Toast.show(_(msg`Account unmuted`))
|
||||
} catch (e: any) {
|
||||
if (e?.name !== 'AbortError') {
|
||||
logger.error('Failed to unmute account', {message: e})
|
||||
Toast.show(_(msg`There was an issue! ${e.toString()}`))
|
||||
}
|
||||
}
|
||||
}, [track, queueUnmute, _])
|
||||
|
||||
const onPressBlockAccount = React.useCallback(async () => {
|
||||
track('ProfileHeader:BlockAccountButtonClicked')
|
||||
openModal({
|
||||
name: 'confirm',
|
||||
title: _(msg`Block Account`),
|
||||
message: _(
|
||||
msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`,
|
||||
),
|
||||
onPressConfirm: async () => {
|
||||
try {
|
||||
await queueBlock()
|
||||
Toast.show(_(msg`Account blocked`))
|
||||
} catch (e: any) {
|
||||
if (e?.name !== 'AbortError') {
|
||||
logger.error('Failed to block account', {message: e})
|
||||
Toast.show(_(msg`There was an issue! ${e.toString()}`))
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}, [track, queueBlock, openModal, _])
|
||||
|
||||
const onPressUnblockAccount = React.useCallback(async () => {
|
||||
const unblockAccount = React.useCallback(async () => {
|
||||
track('ProfileHeader:UnblockAccountButtonClicked')
|
||||
openModal({
|
||||
name: 'confirm',
|
||||
title: _(msg`Unblock Account`),
|
||||
message: _(
|
||||
msg`The account will be able to interact with you after unblocking.`,
|
||||
),
|
||||
onPressConfirm: async () => {
|
||||
try {
|
||||
await queueUnblock()
|
||||
Toast.show(_(msg`Account unblocked`))
|
||||
} catch (e: any) {
|
||||
if (e?.name !== 'AbortError') {
|
||||
logger.error('Failed to unblock account', {message: e})
|
||||
Toast.show(_(msg`There was an issue! ${e.toString()}`))
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}, [track, queueUnblock, openModal, _])
|
||||
|
||||
const onPressReportAccount = React.useCallback(() => {
|
||||
track('ProfileHeader:ReportAccountButtonClicked')
|
||||
openModal({
|
||||
name: 'report',
|
||||
did: profile.did,
|
||||
})
|
||||
}, [track, openModal, profile])
|
||||
try {
|
||||
await queueUnblock()
|
||||
Toast.show(_(msg`Account unblocked`))
|
||||
} catch (e: any) {
|
||||
if (e?.name !== 'AbortError') {
|
||||
logger.error('Failed to unblock account', {message: e})
|
||||
Toast.show(_(msg`There was an issue! ${e.toString()}`))
|
||||
}
|
||||
}
|
||||
}, [_, queueUnblock, track])
|
||||
|
||||
const isMe = React.useMemo(
|
||||
() => currentAccount?.did === profile.did,
|
||||
[currentAccount, profile],
|
||||
)
|
||||
const dropdownItems: DropdownItem[] = React.useMemo(() => {
|
||||
let items: DropdownItem[] = [
|
||||
{
|
||||
testID: 'profileHeaderDropdownShareBtn',
|
||||
label: isWeb ? _(msg`Copy link to profile`) : _(msg`Share`),
|
||||
onPress: onPressShare,
|
||||
icon: {
|
||||
ios: {
|
||||
name: 'square.and.arrow.up',
|
||||
},
|
||||
android: 'ic_menu_share',
|
||||
web: 'share',
|
||||
},
|
||||
},
|
||||
]
|
||||
if (hasSession) {
|
||||
items.push({label: 'separator'})
|
||||
items.push({
|
||||
testID: 'profileHeaderDropdownListAddRemoveBtn',
|
||||
label: _(msg`Add to Lists`),
|
||||
onPress: onPressAddRemoveLists,
|
||||
icon: {
|
||||
ios: {
|
||||
name: 'list.bullet',
|
||||
},
|
||||
android: 'ic_menu_add',
|
||||
web: 'list',
|
||||
},
|
||||
})
|
||||
if (!isMe) {
|
||||
if (!profile.viewer?.blocking) {
|
||||
if (!profile.viewer?.mutedByList) {
|
||||
items.push({
|
||||
testID: 'profileHeaderDropdownMuteBtn',
|
||||
label: profile.viewer?.muted
|
||||
? _(msg`Unmute Account`)
|
||||
: _(msg`Mute Account`),
|
||||
onPress: profile.viewer?.muted
|
||||
? onPressUnmuteAccount
|
||||
: onPressMuteAccount,
|
||||
icon: {
|
||||
ios: {
|
||||
name: 'speaker.slash',
|
||||
},
|
||||
android: 'ic_lock_silent_mode',
|
||||
web: 'comment-slash',
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
if (!profile.viewer?.blockingByList) {
|
||||
items.push({
|
||||
testID: 'profileHeaderDropdownBlockBtn',
|
||||
label: profile.viewer?.blocking
|
||||
? _(msg`Unblock Account`)
|
||||
: _(msg`Block Account`),
|
||||
onPress: profile.viewer?.blocking
|
||||
? onPressUnblockAccount
|
||||
: onPressBlockAccount,
|
||||
icon: {
|
||||
ios: {
|
||||
name: 'person.fill.xmark',
|
||||
},
|
||||
android: 'ic_menu_close_clear_cancel',
|
||||
web: 'user-slash',
|
||||
},
|
||||
})
|
||||
}
|
||||
items.push({
|
||||
testID: 'profileHeaderDropdownReportBtn',
|
||||
label: _(msg`Report Account`),
|
||||
onPress: onPressReportAccount,
|
||||
icon: {
|
||||
ios: {
|
||||
name: 'exclamationmark.triangle',
|
||||
},
|
||||
android: 'ic_menu_report_image',
|
||||
web: 'circle-exclamation',
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
return items
|
||||
}, [
|
||||
isMe,
|
||||
hasSession,
|
||||
profile.viewer?.muted,
|
||||
profile.viewer?.mutedByList,
|
||||
profile.viewer?.blocking,
|
||||
profile.viewer?.blockingByList,
|
||||
onPressShare,
|
||||
onPressUnmuteAccount,
|
||||
onPressMuteAccount,
|
||||
onPressUnblockAccount,
|
||||
onPressBlockAccount,
|
||||
onPressReportAccount,
|
||||
onPressAddRemoveLists,
|
||||
_,
|
||||
])
|
||||
|
||||
const blockHide =
|
||||
!isMe && (profile.viewer?.blocking || profile.viewer?.blockedBy)
|
||||
@@ -427,7 +238,7 @@ let ProfileHeader = ({
|
||||
profile.viewer?.blockingByList ? null : (
|
||||
<TouchableOpacity
|
||||
testID="unblockBtn"
|
||||
onPress={onPressUnblockAccount}
|
||||
onPress={() => unblockPromptControl.open()}
|
||||
style={[styles.btn, styles.mainBtn, pal.btn]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Unblock`)}
|
||||
@@ -516,17 +327,7 @@ let ProfileHeader = ({
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
{dropdownItems?.length ? (
|
||||
<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}
|
||||
<ProfileMenu profile={profile} />
|
||||
</View>
|
||||
<View pointerEvents="none">
|
||||
<Text
|
||||
@@ -670,6 +471,18 @@ let ProfileHeader = ({
|
||||
/>
|
||||
</View>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -170,7 +170,10 @@ function SuggestedFollow({
|
||||
const pal = usePalette('default')
|
||||
const moderationOpts = useModerationOpts()
|
||||
const profile = useProfileShadow(profileUnshadowed)
|
||||
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile)
|
||||
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
|
||||
profile,
|
||||
'ProfileHeaderSuggestedFollows',
|
||||
)
|
||||
|
||||
const onPressFollow = React.useCallback(async () => {
|
||||
try {
|
||||
|
||||
@@ -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}
|
||||
@@ -8,7 +8,14 @@ import {View, ViewStyle} from 'react-native'
|
||||
export function EventStopper({
|
||||
children,
|
||||
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) => {
|
||||
e.stopPropagation()
|
||||
}
|
||||
@@ -18,7 +25,7 @@ export function EventStopper({
|
||||
onTouchEnd={stop}
|
||||
// @ts-ignore web only -prf
|
||||
onClick={stop}
|
||||
onKeyDown={stop}
|
||||
onKeyDown={onKeyDown ? stop : undefined}
|
||||
style={style}>
|
||||
{children}
|
||||
</View>
|
||||
|
||||
@@ -8,17 +8,11 @@ import {
|
||||
View,
|
||||
ViewStyle,
|
||||
Pressable,
|
||||
TouchableWithoutFeedback,
|
||||
TouchableOpacity,
|
||||
} from 'react-native'
|
||||
import {
|
||||
useLinkProps,
|
||||
useNavigation,
|
||||
StackActions,
|
||||
} from '@react-navigation/native'
|
||||
import {useLinkProps, StackActions} from '@react-navigation/native'
|
||||
import {Text} from './text/Text'
|
||||
import {TypographyVariant} from 'lib/ThemeContext'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {router} from '../../../routes'
|
||||
import {
|
||||
convertBskyAppUrlIfNeeded,
|
||||
@@ -28,10 +22,14 @@ import {
|
||||
import {isAndroid, isWeb} from 'platform/detection'
|
||||
import {sanitizeUrl} from '@braintree/sanitize-url'
|
||||
import {PressableWithHover} from './PressableWithHover'
|
||||
import FixedTouchableHighlight from '../pager/FixedTouchableHighlight'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {useOpenLink} from '#/state/preferences/in-app-browser'
|
||||
import {WebAuxClickWrapper} from 'view/com/util/WebAuxClickWrapper'
|
||||
import {
|
||||
DebouncedNavigationProp,
|
||||
useNavigationDeduped,
|
||||
} from 'lib/hooks/useNavigationDeduped'
|
||||
import {useTheme} from '#/alf'
|
||||
|
||||
type Event =
|
||||
| React.MouseEvent<HTMLAnchorElement, MouseEvent>
|
||||
@@ -64,8 +62,9 @@ export const Link = memo(function Link({
|
||||
navigationAction,
|
||||
...props
|
||||
}: Props) {
|
||||
const t = useTheme()
|
||||
const {closeModal} = useModalControls()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const navigation = useNavigationDeduped()
|
||||
const anchorHref = asAnchor ? sanitizeUrl(href) : undefined
|
||||
const openLink = useOpenLink()
|
||||
|
||||
@@ -86,37 +85,23 @@ export const Link = memo(function Link({
|
||||
)
|
||||
|
||||
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 (
|
||||
<WebAuxClickWrapper>
|
||||
<TouchableWithoutFeedback
|
||||
<Pressable
|
||||
testID={testID}
|
||||
onPress={onPress}
|
||||
accessible={accessible}
|
||||
accessibilityRole="link"
|
||||
{...props}>
|
||||
{...props}
|
||||
android_ripple={{
|
||||
color: t.atoms.bg_contrast_25.backgroundColor,
|
||||
}}
|
||||
unstable_pressDelay={isAndroid ? 90 : undefined}>
|
||||
{/* @ts-ignore web only -prf */}
|
||||
<View style={style} href={anchorHref}>
|
||||
{children ? children : <Text>{title || 'link'}</Text>}
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
</Pressable>
|
||||
</WebAuxClickWrapper>
|
||||
)
|
||||
}
|
||||
@@ -176,7 +161,7 @@ export const TextLink = memo(function TextLink({
|
||||
navigationAction?: 'push' | 'replace' | 'navigate'
|
||||
} & TextProps) {
|
||||
const {...props} = useLinkProps({to: sanitizeUrl(href)})
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const navigation = useNavigationDeduped()
|
||||
const {openModal, closeModal} = useModalControls()
|
||||
const openLink = useOpenLink()
|
||||
|
||||
@@ -335,7 +320,7 @@ const EXEMPT_PATHS = ['/robots.txt', '/security.txt', '/.well-known/']
|
||||
// -prf
|
||||
function onPressInner(
|
||||
closeModal = () => {},
|
||||
navigation: NavigationProp,
|
||||
navigation: DebouncedNavigationProp,
|
||||
href: string,
|
||||
navigationAction: 'push' | 'replace' | 'navigate' = 'push',
|
||||
openLink: (href: string) => void,
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import React, {memo, useMemo} from 'react'
|
||||
import {Image, StyleSheet, View} from 'react-native'
|
||||
import {Image, StyleSheet, TouchableOpacity, View} from 'react-native'
|
||||
import Svg, {Circle, Rect, Path} from 'react-native-svg'
|
||||
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {HighPriorityImage} from 'view/com/util/images/Image'
|
||||
import {ModerationUI} from '@atproto/api'
|
||||
|
||||
import {HighPriorityImage} from 'view/com/util/images/Image'
|
||||
import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
|
||||
import {
|
||||
usePhotoLibraryPermission,
|
||||
@@ -11,12 +15,16 @@ import {
|
||||
} from 'lib/hooks/usePermissions'
|
||||
import {colors} from 'lib/styles'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {isWeb, isAndroid} from 'platform/detection'
|
||||
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||
import {isWeb, isAndroid, isNative} from 'platform/detection'
|
||||
import {UserPreviewLink} from './UserPreviewLink'
|
||||
import {DropdownItem, NativeDropdown} from './forms/NativeDropdown'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import * as Menu from '#/components/Menu'
|
||||
import {
|
||||
Camera_Stroke2_Corner0_Rounded as Camera,
|
||||
Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled,
|
||||
} from '#/components/icons/Camera'
|
||||
import {StreamingLive_Stroke2_Corner0_Rounded as Library} from '#/components/icons/StreamingLive'
|
||||
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
|
||||
import {useTheme} from '#/alf'
|
||||
|
||||
export type UserAvatarType = 'user' | 'algo' | 'list'
|
||||
|
||||
@@ -196,6 +204,7 @@ let EditableUserAvatar = ({
|
||||
avatar,
|
||||
onSelectNewAvatar,
|
||||
}: EditableUserAvatarProps): React.ReactNode => {
|
||||
const t = useTheme()
|
||||
const pal = usePalette('default')
|
||||
const {_} = useLingui()
|
||||
const {requestCameraAccessIfNeeded} = useCameraPermission()
|
||||
@@ -216,118 +225,115 @@ let EditableUserAvatar = ({
|
||||
}
|
||||
}, [type, size])
|
||||
|
||||
const dropdownItems = useMemo(
|
||||
() =>
|
||||
[
|
||||
!isWeb && {
|
||||
testID: 'changeAvatarCameraBtn',
|
||||
label: _(msg`Camera`),
|
||||
icon: {
|
||||
ios: {
|
||||
name: 'camera',
|
||||
},
|
||||
android: 'ic_menu_camera',
|
||||
web: 'camera',
|
||||
},
|
||||
onPress: async () => {
|
||||
if (!(await requestCameraAccessIfNeeded())) {
|
||||
return
|
||||
}
|
||||
const onOpenCamera = React.useCallback(async () => {
|
||||
if (!(await requestCameraAccessIfNeeded())) {
|
||||
return
|
||||
}
|
||||
|
||||
onSelectNewAvatar(
|
||||
await openCamera({
|
||||
width: 1000,
|
||||
height: 1000,
|
||||
cropperCircleOverlay: true,
|
||||
}),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
testID: 'changeAvatarLibraryBtn',
|
||||
label: _(msg`Library`),
|
||||
icon: {
|
||||
ios: {
|
||||
name: 'photo.on.rectangle.angled',
|
||||
},
|
||||
android: 'ic_menu_gallery',
|
||||
web: 'gallery',
|
||||
},
|
||||
onPress: async () => {
|
||||
if (!(await requestPhotoAccessIfNeeded())) {
|
||||
return
|
||||
}
|
||||
onSelectNewAvatar(
|
||||
await openCamera({
|
||||
width: 1000,
|
||||
height: 1000,
|
||||
cropperCircleOverlay: true,
|
||||
}),
|
||||
)
|
||||
}, [onSelectNewAvatar, requestCameraAccessIfNeeded])
|
||||
|
||||
const items = await openPicker({
|
||||
aspect: [1, 1],
|
||||
})
|
||||
const item = items[0]
|
||||
if (!item) {
|
||||
return
|
||||
}
|
||||
const onOpenLibrary = React.useCallback(async () => {
|
||||
if (!(await requestPhotoAccessIfNeeded())) {
|
||||
return
|
||||
}
|
||||
|
||||
const croppedImage = await openCropper({
|
||||
mediaType: 'photo',
|
||||
cropperCircleOverlay: true,
|
||||
height: item.height,
|
||||
width: item.width,
|
||||
path: item.path,
|
||||
})
|
||||
const items = await openPicker({
|
||||
aspect: [1, 1],
|
||||
})
|
||||
const item = items[0]
|
||||
if (!item) {
|
||||
return
|
||||
}
|
||||
|
||||
onSelectNewAvatar(croppedImage)
|
||||
},
|
||||
},
|
||||
!!avatar && {
|
||||
label: 'separator',
|
||||
},
|
||||
!!avatar && {
|
||||
testID: 'changeAvatarRemoveBtn',
|
||||
label: _(msg`Remove`),
|
||||
icon: {
|
||||
ios: {
|
||||
name: 'trash',
|
||||
},
|
||||
android: 'ic_delete',
|
||||
web: ['far', 'trash-can'],
|
||||
},
|
||||
onPress: async () => {
|
||||
onSelectNewAvatar(null)
|
||||
},
|
||||
},
|
||||
].filter(Boolean) as DropdownItem[],
|
||||
[
|
||||
avatar,
|
||||
onSelectNewAvatar,
|
||||
requestCameraAccessIfNeeded,
|
||||
requestPhotoAccessIfNeeded,
|
||||
_,
|
||||
],
|
||||
)
|
||||
const croppedImage = await openCropper({
|
||||
mediaType: 'photo',
|
||||
cropperCircleOverlay: true,
|
||||
height: item.height,
|
||||
width: item.width,
|
||||
path: item.path,
|
||||
})
|
||||
|
||||
onSelectNewAvatar(croppedImage)
|
||||
}, [onSelectNewAvatar, requestPhotoAccessIfNeeded])
|
||||
|
||||
const onRemoveAvatar = React.useCallback(() => {
|
||||
onSelectNewAvatar(null)
|
||||
}, [onSelectNewAvatar])
|
||||
|
||||
return (
|
||||
<NativeDropdown
|
||||
testID="changeAvatarBtn"
|
||||
items={dropdownItems}
|
||||
accessibilityLabel={_(msg`Image options`)}
|
||||
accessibilityHint="">
|
||||
{avatar ? (
|
||||
<HighPriorityImage
|
||||
testID="userAvatarImage"
|
||||
style={aviStyle}
|
||||
source={{uri: avatar}}
|
||||
accessibilityRole="image"
|
||||
/>
|
||||
) : (
|
||||
<DefaultAvatar type={type} size={size} />
|
||||
)}
|
||||
<View style={[styles.editButtonContainer, pal.btn]}>
|
||||
<FontAwesomeIcon
|
||||
icon="camera"
|
||||
size={12}
|
||||
color={pal.text.color as string}
|
||||
/>
|
||||
</View>
|
||||
</NativeDropdown>
|
||||
<Menu.Root>
|
||||
<Menu.Trigger label={_(msg`Edit avatar`)}>
|
||||
{({props}) => (
|
||||
<TouchableOpacity {...props} activeOpacity={0.8}>
|
||||
{avatar ? (
|
||||
<HighPriorityImage
|
||||
testID="userAvatarImage"
|
||||
style={aviStyle}
|
||||
source={{uri: avatar}}
|
||||
accessibilityRole="image"
|
||||
/>
|
||||
) : (
|
||||
<DefaultAvatar type={type} size={size} />
|
||||
)}
|
||||
<View style={[styles.editButtonContainer, pal.btn]}>
|
||||
<CameraFilled height={14} width={14} style={t.atoms.text} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</Menu.Trigger>
|
||||
<Menu.Outer showCancel>
|
||||
<Menu.Group>
|
||||
{isNative && (
|
||||
<Menu.Item
|
||||
testID="changeAvatarCameraBtn"
|
||||
label={_(msg`Upload from Camera`)}
|
||||
onPress={onOpenCamera}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Upload from Camera</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Camera} />
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
<Menu.Item
|
||||
testID="changeAvatarLibraryBtn"
|
||||
label={_(msg`Upload from Library`)}
|
||||
onPress={onOpenLibrary}>
|
||||
<Menu.ItemText>
|
||||
{isNative ? (
|
||||
<Trans>Upload from Library</Trans>
|
||||
) : (
|
||||
<Trans>Upload from Files</Trans>
|
||||
)}
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Library} />
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
{!!avatar && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Group>
|
||||
<Menu.Item
|
||||
testID="changeAvatarRemoveBtn"
|
||||
label={_(`Remove Avatar`)}
|
||||
onPress={onRemoveAvatar}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Remove Avatar</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Trash} />
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
</>
|
||||
)}
|
||||
</Menu.Outer>
|
||||
</Menu.Root>
|
||||
)
|
||||
}
|
||||
EditableUserAvatar = memo(EditableUserAvatar)
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
import React, {useMemo} from 'react'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import React from 'react'
|
||||
import {StyleSheet, TouchableOpacity, View} from 'react-native'
|
||||
import {ModerationUI} from '@atproto/api'
|
||||
import {Image} from 'expo-image'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
|
||||
import {colors} from 'lib/styles'
|
||||
import {useTheme} from 'lib/ThemeContext'
|
||||
import {useTheme as useAlfTheme} from '#/alf'
|
||||
import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
|
||||
import {
|
||||
usePhotoLibraryPermission,
|
||||
useCameraPermission,
|
||||
} from 'lib/hooks/usePermissions'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {isWeb, isAndroid} from 'platform/detection'
|
||||
import {isAndroid, isNative} from 'platform/detection'
|
||||
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||
import {NativeDropdown, DropdownItem} from './forms/NativeDropdown'
|
||||
import {EventStopper} from 'view/com/util/EventStopper'
|
||||
import * as Menu from '#/components/Menu'
|
||||
import {
|
||||
Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled,
|
||||
Camera_Stroke2_Corner0_Rounded as Camera,
|
||||
} from '#/components/icons/Camera'
|
||||
import {StreamingLive_Stroke2_Corner0_Rounded as Library} from '#/components/icons/StreamingLive'
|
||||
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
|
||||
|
||||
export function UserBanner({
|
||||
banner,
|
||||
@@ -28,118 +36,120 @@ export function UserBanner({
|
||||
}) {
|
||||
const pal = usePalette('default')
|
||||
const theme = useTheme()
|
||||
const t = useAlfTheme()
|
||||
const {_} = useLingui()
|
||||
const {requestCameraAccessIfNeeded} = useCameraPermission()
|
||||
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
|
||||
|
||||
const dropdownItems: DropdownItem[] = useMemo(
|
||||
() =>
|
||||
[
|
||||
!isWeb && {
|
||||
testID: 'changeBannerCameraBtn',
|
||||
label: _(msg`Camera`),
|
||||
icon: {
|
||||
ios: {
|
||||
name: 'camera',
|
||||
},
|
||||
android: 'ic_menu_camera',
|
||||
web: 'camera',
|
||||
},
|
||||
onPress: async () => {
|
||||
if (!(await requestCameraAccessIfNeeded())) {
|
||||
return
|
||||
}
|
||||
onSelectNewBanner?.(
|
||||
await openCamera({
|
||||
width: 3000,
|
||||
height: 1000,
|
||||
}),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
testID: 'changeBannerLibraryBtn',
|
||||
label: _(msg`Library`),
|
||||
icon: {
|
||||
ios: {
|
||||
name: 'photo.on.rectangle.angled',
|
||||
},
|
||||
android: 'ic_menu_gallery',
|
||||
web: 'gallery',
|
||||
},
|
||||
onPress: async () => {
|
||||
if (!(await requestPhotoAccessIfNeeded())) {
|
||||
return
|
||||
}
|
||||
const items = await openPicker()
|
||||
if (!items[0]) {
|
||||
return
|
||||
}
|
||||
const onOpenCamera = React.useCallback(async () => {
|
||||
if (!(await requestCameraAccessIfNeeded())) {
|
||||
return
|
||||
}
|
||||
onSelectNewBanner?.(
|
||||
await openCamera({
|
||||
width: 3000,
|
||||
height: 1000,
|
||||
}),
|
||||
)
|
||||
}, [onSelectNewBanner, requestCameraAccessIfNeeded])
|
||||
|
||||
onSelectNewBanner?.(
|
||||
await openCropper({
|
||||
mediaType: 'photo',
|
||||
path: items[0].path,
|
||||
width: 3000,
|
||||
height: 1000,
|
||||
}),
|
||||
)
|
||||
},
|
||||
},
|
||||
!!banner && {
|
||||
testID: 'changeBannerRemoveBtn',
|
||||
label: _(msg`Remove`),
|
||||
icon: {
|
||||
ios: {
|
||||
name: 'trash',
|
||||
},
|
||||
android: 'ic_delete',
|
||||
web: ['far', 'trash-can'],
|
||||
},
|
||||
onPress: () => {
|
||||
onSelectNewBanner?.(null)
|
||||
},
|
||||
},
|
||||
].filter(Boolean) as DropdownItem[],
|
||||
[
|
||||
banner,
|
||||
onSelectNewBanner,
|
||||
requestCameraAccessIfNeeded,
|
||||
requestPhotoAccessIfNeeded,
|
||||
_,
|
||||
],
|
||||
)
|
||||
const onOpenLibrary = React.useCallback(async () => {
|
||||
if (!(await requestPhotoAccessIfNeeded())) {
|
||||
return
|
||||
}
|
||||
const items = await openPicker()
|
||||
if (!items[0]) {
|
||||
return
|
||||
}
|
||||
|
||||
onSelectNewBanner?.(
|
||||
await openCropper({
|
||||
mediaType: 'photo',
|
||||
path: items[0].path,
|
||||
width: 3000,
|
||||
height: 1000,
|
||||
}),
|
||||
)
|
||||
}, [onSelectNewBanner, requestPhotoAccessIfNeeded])
|
||||
|
||||
const onRemoveBanner = React.useCallback(() => {
|
||||
onSelectNewBanner?.(null)
|
||||
}, [onSelectNewBanner])
|
||||
|
||||
// setUserBanner is only passed as prop on the EditProfile component
|
||||
return onSelectNewBanner ? (
|
||||
<NativeDropdown
|
||||
testID="changeBannerBtn"
|
||||
items={dropdownItems}
|
||||
accessibilityLabel={_(msg`Image options`)}
|
||||
accessibilityHint="">
|
||||
{banner ? (
|
||||
<Image
|
||||
testID="userBannerImage"
|
||||
style={styles.bannerImage}
|
||||
source={{uri: banner}}
|
||||
accessible={true}
|
||||
accessibilityIgnoresInvertColors
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
testID="userBannerFallback"
|
||||
style={[styles.bannerImage, styles.defaultBanner]}
|
||||
/>
|
||||
)}
|
||||
<View style={[styles.editButtonContainer, pal.btn]}>
|
||||
<FontAwesomeIcon
|
||||
icon="camera"
|
||||
size={12}
|
||||
style={{color: colors.white}}
|
||||
color={pal.text.color as string}
|
||||
/>
|
||||
</View>
|
||||
</NativeDropdown>
|
||||
<EventStopper onKeyDown={false}>
|
||||
<Menu.Root>
|
||||
<Menu.Trigger label={_(msg`Edit avatar`)}>
|
||||
{({props}) => (
|
||||
<TouchableOpacity {...props} activeOpacity={0.8}>
|
||||
{banner ? (
|
||||
<Image
|
||||
testID="userBannerImage"
|
||||
style={styles.bannerImage}
|
||||
source={{uri: banner}}
|
||||
accessible={true}
|
||||
accessibilityIgnoresInvertColors
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
testID="userBannerFallback"
|
||||
style={[styles.bannerImage, styles.defaultBanner]}
|
||||
/>
|
||||
)}
|
||||
<View style={[styles.editButtonContainer, pal.btn]}>
|
||||
<CameraFilled height={14} width={14} style={t.atoms.text} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</Menu.Trigger>
|
||||
<Menu.Outer showCancel>
|
||||
<Menu.Group>
|
||||
{isNative && (
|
||||
<Menu.Item
|
||||
testID="changeBannerCameraBtn"
|
||||
label={_(msg`Upload from Camera`)}
|
||||
onPress={onOpenCamera}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Upload from Camera</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Camera} />
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
<Menu.Item
|
||||
testID="changeBannerLibraryBtn"
|
||||
label={_(msg`Upload from Library`)}
|
||||
onPress={onOpenLibrary}>
|
||||
<Menu.ItemText>
|
||||
{isNative ? (
|
||||
<Trans>Upload from Library</Trans>
|
||||
) : (
|
||||
<Trans>Upload from Files</Trans>
|
||||
)}
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Library} />
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
{!!banner && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Group>
|
||||
<Menu.Item
|
||||
testID="changeBannerRemoveBtn"
|
||||
label={_(`Remove Banner`)}
|
||||
onPress={onRemoveBanner}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Remove Banner</Trans>
|
||||
</Menu.ItemText>
|
||||
<Menu.ItemIcon icon={Trash} />
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
</>
|
||||
)}
|
||||
</Menu.Outer>
|
||||
</Menu.Root>
|
||||
</EventStopper>
|
||||
) : banner &&
|
||||
!((moderation?.blur && isAndroid) /* android crashes with blur */) ? (
|
||||
<Image
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
@@ -12,12 +12,10 @@ import {
|
||||
import {toShareUrl} from 'lib/strings/url-helpers'
|
||||
import {useTheme} from 'lib/ThemeContext'
|
||||
import {shareUrl} from 'lib/sharing'
|
||||
import {
|
||||
NativeDropdown,
|
||||
DropdownItem as NativeDropdownItem,
|
||||
} from './NativeDropdown'
|
||||
import * as Toast from '../Toast'
|
||||
import {EventStopper} from '../EventStopper'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {CommonNavigatorParams} from '#/lib/routes/types'
|
||||
@@ -36,6 +34,19 @@ import {isWeb} from '#/platform/detection'
|
||||
import {richTextToString} from '#/lib/strings/rich-text-helpers'
|
||||
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
|
||||
|
||||
import {atoms as a, useTheme as useAlf} 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 = ({
|
||||
testID,
|
||||
postAuthor,
|
||||
@@ -45,6 +56,7 @@ let PostDropdownBtn = ({
|
||||
richText,
|
||||
style,
|
||||
showAppealLabelItem,
|
||||
hitSlop,
|
||||
}: {
|
||||
testID: string
|
||||
postAuthor: AppBskyActorDefs.ProfileViewBasic
|
||||
@@ -54,9 +66,11 @@ let PostDropdownBtn = ({
|
||||
richText: RichTextAPI
|
||||
style?: StyleProp<ViewStyle>
|
||||
showAppealLabelItem?: boolean
|
||||
hitSlop?: PressableProps['hitSlop']
|
||||
}): React.ReactNode => {
|
||||
const {hasSession, currentAccount} = useSession()
|
||||
const theme = useTheme()
|
||||
const alf = useAlf()
|
||||
const {_} = useLingui()
|
||||
const defaultCtrlColor = theme.palette.default.postCtrl
|
||||
const {openModal} = useModalControls()
|
||||
@@ -69,11 +83,15 @@ let PostDropdownBtn = ({
|
||||
const openLink = useOpenLink()
|
||||
const navigation = useNavigation()
|
||||
const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
|
||||
const deletePromptControl = useDialogControl()
|
||||
const hidePromptControl = useDialogControl()
|
||||
const loggedOutWarningPromptControl = useDialogControl()
|
||||
|
||||
const rootUri = record.reply?.root?.uri || postUri
|
||||
const isThreadMuted = mutedThreads.includes(rootUri)
|
||||
const isPostHidden = hiddenPosts && hiddenPosts.includes(postUri)
|
||||
const isAuthor = postAuthor.did === currentAccount?.did
|
||||
|
||||
const href = React.useMemo(() => {
|
||||
const urip = new AtUri(postUri)
|
||||
return makeProfileLink(postAuthor, 'post', urip.rkey)
|
||||
@@ -151,173 +169,205 @@ let PostDropdownBtn = ({
|
||||
hidePost({uri: postUri})
|
||||
}, [postUri, hidePost])
|
||||
|
||||
const dropdownItems: NativeDropdownItem[] = [
|
||||
{
|
||||
label: _(msg`Translate`),
|
||||
onPress() {
|
||||
onOpenTranslate()
|
||||
},
|
||||
testID: 'postDropdownTranslateBtn',
|
||||
icon: {
|
||||
ios: {
|
||||
name: 'character.book.closed',
|
||||
},
|
||||
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[]
|
||||
const shouldShowLoggedOutWarning = React.useMemo(() => {
|
||||
return !!postAuthor.labels?.find(
|
||||
label => label.val === '!no-unauthenticated',
|
||||
)
|
||||
}, [postAuthor])
|
||||
|
||||
const onSharePost = React.useCallback(() => {
|
||||
const url = toShareUrl(href)
|
||||
shareUrl(url)
|
||||
}, [href])
|
||||
|
||||
return (
|
||||
<EventStopper>
|
||||
<NativeDropdown
|
||||
testID={testID}
|
||||
items={dropdownItems}
|
||||
accessibilityLabel={_(msg`More post options`)}
|
||||
accessibilityHint="">
|
||||
<View style={style}>
|
||||
<FontAwesomeIcon icon="ellipsis" size={20} color={defaultCtrlColor} />
|
||||
</View>
|
||||
</NativeDropdown>
|
||||
<EventStopper onKeyDown={false}>
|
||||
<Menu.Root>
|
||||
<Menu.Trigger label={_(msg`Open post options menu`)}>
|
||||
{({props, state}) => {
|
||||
return (
|
||||
<Pressable
|
||||
{...props}
|
||||
hitSlop={hitSlop}
|
||||
testID={testID}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||