Edit profile dialog ALF refresh (#5633)
This commit is contained in:
+15
-6
@@ -1,6 +1,7 @@
|
|||||||
package expo.modules.bottomsheet
|
package expo.modules.bottomsheet
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.util.DisplayMetrics
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.view.ViewStructure
|
import android.view.ViewStructure
|
||||||
@@ -17,7 +18,7 @@ import com.google.android.material.bottomsheet.BottomSheetDialog
|
|||||||
import expo.modules.kotlin.AppContext
|
import expo.modules.kotlin.AppContext
|
||||||
import expo.modules.kotlin.viewevent.EventDispatcher
|
import expo.modules.kotlin.viewevent.EventDispatcher
|
||||||
import expo.modules.kotlin.views.ExpoView
|
import expo.modules.kotlin.views.ExpoView
|
||||||
import java.util.ArrayList
|
|
||||||
|
|
||||||
class BottomSheetView(
|
class BottomSheetView(
|
||||||
context: Context,
|
context: Context,
|
||||||
@@ -58,17 +59,18 @@ class BottomSheetView(
|
|||||||
if (value < 0) {
|
if (value < 0) {
|
||||||
0f
|
0f
|
||||||
} else {
|
} else {
|
||||||
value
|
dpToPx(value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var maxHeight = this.screenHeight
|
var maxHeight = this.screenHeight
|
||||||
set(value) {
|
set(value) {
|
||||||
|
val px = dpToPx(value)
|
||||||
field =
|
field =
|
||||||
if (value > this.screenHeight) {
|
if (px > this.screenHeight) {
|
||||||
this.screenHeight.toFloat()
|
this.screenHeight
|
||||||
} else {
|
} else {
|
||||||
value
|
px
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,7 +177,7 @@ class BottomSheetView(
|
|||||||
behavior.isDraggable = true
|
behavior.isDraggable = true
|
||||||
behavior.isHideable = true
|
behavior.isHideable = true
|
||||||
|
|
||||||
if (contentHeight > this.screenHeight) {
|
if (contentHeight >= this.screenHeight || this.minHeight >= this.screenHeight) {
|
||||||
behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||||
this.selectedSnapPoint = 2
|
this.selectedSnapPoint = 2
|
||||||
} else {
|
} else {
|
||||||
@@ -332,4 +334,11 @@ class BottomSheetView(
|
|||||||
override fun addChildrenForAccessibility(outChildren: ArrayList<View>?) { }
|
override fun addChildrenForAccessibility(outChildren: ArrayList<View>?) { }
|
||||||
|
|
||||||
override fun dispatchPopulateAccessibilityEvent(event: AccessibilityEvent?): Boolean = false
|
override fun dispatchPopulateAccessibilityEvent(event: AccessibilityEvent?): Boolean = false
|
||||||
|
|
||||||
|
// https://stackoverflow.com/questions/11862391/getheight-px-or-dpi
|
||||||
|
fun dpToPx(dp: Float): Float {
|
||||||
|
val displayMetrics = context.resources.displayMetrics
|
||||||
|
val px = dp * (displayMetrics.xdpi / DisplayMetrics.DENSITY_DEFAULT)
|
||||||
|
return px
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -280,7 +280,8 @@
|
|||||||
"**/zod": "3.23.8",
|
"**/zod": "3.23.8",
|
||||||
"**/expo-constants": "16.0.1",
|
"**/expo-constants": "16.0.1",
|
||||||
"**/expo-device": "6.0.2",
|
"**/expo-device": "6.0.2",
|
||||||
"@react-native/babel-preset": "0.74.1"
|
"@react-native/babel-preset": "0.74.1",
|
||||||
|
"@radix-ui/react-focus-scope": "1.1.0"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"preset": "jest-expo/ios",
|
"preset": "jest-expo/ios",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
import {BottomSheetNativeComponent} from '../../../modules/bottom-sheet/src/BottomSheetNativeComponent'
|
import {BottomSheetNativeComponent} from '../../../modules/bottom-sheet/src/BottomSheetNativeComponent'
|
||||||
|
|
||||||
export {useDialogContext, useDialogControl} from '#/components/Dialog/context'
|
export {useDialogContext, useDialogControl} from '#/components/Dialog/context'
|
||||||
|
export * from '#/components/Dialog/shared'
|
||||||
export * from '#/components/Dialog/types'
|
export * from '#/components/Dialog/types'
|
||||||
export * from '#/components/Dialog/utils'
|
export * from '#/components/Dialog/utils'
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -169,25 +170,31 @@ export function Outer({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Inner({children, style}: DialogInnerProps) {
|
export function Inner({children, style, header}: DialogInnerProps) {
|
||||||
const insets = useSafeAreaInsets()
|
const insets = useSafeAreaInsets()
|
||||||
return (
|
return (
|
||||||
<View
|
<>
|
||||||
style={[
|
{header}
|
||||||
a.pt_2xl,
|
<View
|
||||||
a.px_xl,
|
style={[
|
||||||
{
|
a.pt_2xl,
|
||||||
paddingBottom: insets.bottom + insets.top,
|
a.px_xl,
|
||||||
},
|
{
|
||||||
style,
|
paddingBottom: insets.bottom + insets.top,
|
||||||
]}>
|
},
|
||||||
{children}
|
style,
|
||||||
</View>
|
]}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
|
export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
|
||||||
function ScrollableInner({children, style, ...props}, ref) {
|
function ScrollableInner(
|
||||||
|
{children, style, contentContainerStyle, header, ...props},
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
|
const {nativeSnapPoint, disableDrag, setDisableDrag} = useDialogContext()
|
||||||
const insets = useSafeAreaInsets()
|
const insets = useSafeAreaInsets()
|
||||||
const {setEnabled} = useKeyboardController()
|
const {setEnabled} = useKeyboardController()
|
||||||
@@ -232,14 +239,21 @@ export const ScrollableInner = React.forwardRef<ScrollView, DialogInnerProps>(
|
|||||||
return (
|
return (
|
||||||
<KeyboardAwareScrollView
|
<KeyboardAwareScrollView
|
||||||
style={[style]}
|
style={[style]}
|
||||||
contentContainerStyle={[a.pt_2xl, a.px_xl, {paddingBottom}]}
|
contentContainerStyle={[
|
||||||
|
a.pt_2xl,
|
||||||
|
a.px_xl,
|
||||||
|
{paddingBottom},
|
||||||
|
contentContainerStyle,
|
||||||
|
]}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
{...props}
|
{...props}
|
||||||
bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
|
bounces={nativeSnapPoint === BottomSheetSnapPoint.Full}
|
||||||
bottomOffset={30}
|
bottomOffset={30}
|
||||||
scrollEventThrottle={50}
|
scrollEventThrottle={50}
|
||||||
onScroll={isAndroid ? onScroll : undefined}
|
onScroll={isAndroid ? onScroll : undefined}
|
||||||
keyboardShouldPersistTaps="handled">
|
keyboardShouldPersistTaps="handled"
|
||||||
|
stickyHeaderIndices={header ? [0] : undefined}>
|
||||||
|
{header}
|
||||||
{children}
|
{children}
|
||||||
</KeyboardAwareScrollView>
|
</KeyboardAwareScrollView>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
|||||||
import {Portal} from '#/components/Portal'
|
import {Portal} from '#/components/Portal'
|
||||||
|
|
||||||
export {useDialogContext, useDialogControl} from '#/components/Dialog/context'
|
export {useDialogContext, useDialogControl} from '#/components/Dialog/context'
|
||||||
|
export * from '#/components/Dialog/shared'
|
||||||
export * from '#/components/Dialog/types'
|
export * from '#/components/Dialog/types'
|
||||||
export * from '#/components/Dialog/utils'
|
export * from '#/components/Dialog/utils'
|
||||||
export {Input} from '#/components/forms/TextField'
|
export {Input} from '#/components/forms/TextField'
|
||||||
@@ -154,6 +155,8 @@ export function Inner({
|
|||||||
label,
|
label,
|
||||||
accessibilityLabelledBy,
|
accessibilityLabelledBy,
|
||||||
accessibilityDescribedBy,
|
accessibilityDescribedBy,
|
||||||
|
header,
|
||||||
|
contentContainerStyle,
|
||||||
}: DialogInnerProps) {
|
}: DialogInnerProps) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {close} = React.useContext(Context)
|
const {close} = React.useContext(Context)
|
||||||
@@ -178,7 +181,6 @@ export function Inner({
|
|||||||
a.rounded_md,
|
a.rounded_md,
|
||||||
a.w_full,
|
a.w_full,
|
||||||
a.border,
|
a.border,
|
||||||
gtMobile ? a.p_2xl : a.p_xl,
|
|
||||||
t.atoms.bg,
|
t.atoms.bg,
|
||||||
{
|
{
|
||||||
maxWidth: 600,
|
maxWidth: 600,
|
||||||
@@ -194,7 +196,10 @@ export function Inner({
|
|||||||
onFocusOutside={preventDefault}
|
onFocusOutside={preventDefault}
|
||||||
onDismiss={close}
|
onDismiss={close}
|
||||||
style={{display: 'flex', flexDirection: 'column'}}>
|
style={{display: 'flex', flexDirection: 'column'}}>
|
||||||
{children}
|
{header}
|
||||||
|
<View style={[gtMobile ? a.p_2xl : a.p_xl, contentContainerStyle]}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
</DismissableLayer>
|
</DismissableLayer>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</FocusScope>
|
</FocusScope>
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import {StyleProp, TextStyle, View, ViewStyle} from 'react-native'
|
||||||
|
|
||||||
|
import {atoms as a, useTheme, web} from '#/alf'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
export function Header({
|
||||||
|
renderLeft,
|
||||||
|
renderRight,
|
||||||
|
children,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
renderLeft?: () => React.ReactNode
|
||||||
|
renderRight?: () => React.ReactNode
|
||||||
|
children?: React.ReactNode
|
||||||
|
style?: StyleProp<ViewStyle>
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.relative,
|
||||||
|
a.w_full,
|
||||||
|
a.py_sm,
|
||||||
|
a.flex_row,
|
||||||
|
a.justify_center,
|
||||||
|
a.align_center,
|
||||||
|
{minHeight: 50},
|
||||||
|
a.border_b,
|
||||||
|
t.atoms.border_contrast_medium,
|
||||||
|
t.atoms.bg,
|
||||||
|
web([
|
||||||
|
{borderRadiusTopLeft: a.rounded_md.borderRadius},
|
||||||
|
{borderRadiusTopRight: a.rounded_md.borderRadius},
|
||||||
|
]),
|
||||||
|
style,
|
||||||
|
]}>
|
||||||
|
{renderLeft && (
|
||||||
|
<View style={[a.absolute, {left: 6}]}>{renderLeft()}</View>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
{renderRight && (
|
||||||
|
<View style={[a.absolute, {right: 6}]}>{renderRight()}</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HeaderText({
|
||||||
|
children,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
children?: React.ReactNode
|
||||||
|
style?: StyleProp<TextStyle>
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Text style={[a.text_lg, a.text_center, a.font_bold, style]}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import type {
|
|||||||
GestureResponderEvent,
|
GestureResponderEvent,
|
||||||
ScrollViewProps,
|
ScrollViewProps,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
|
import {ViewStyle} from 'react-native'
|
||||||
|
import {StyleProp} from 'react-native'
|
||||||
|
|
||||||
import {ViewStyleProp} from '#/alf'
|
import {ViewStyleProp} from '#/alf'
|
||||||
import {BottomSheetViewProps} from '../../../modules/bottom-sheet'
|
import {BottomSheetViewProps} from '../../../modules/bottom-sheet'
|
||||||
@@ -69,10 +71,14 @@ export type DialogInnerProps =
|
|||||||
accessibilityLabelledBy: A11yProps['aria-labelledby']
|
accessibilityLabelledBy: A11yProps['aria-labelledby']
|
||||||
accessibilityDescribedBy: string
|
accessibilityDescribedBy: string
|
||||||
keyboardDismissMode?: ScrollViewProps['keyboardDismissMode']
|
keyboardDismissMode?: ScrollViewProps['keyboardDismissMode']
|
||||||
|
contentContainerStyle?: StyleProp<ViewStyle>
|
||||||
|
header?: React.ReactNode
|
||||||
}>
|
}>
|
||||||
| DialogInnerPropsBase<{
|
| DialogInnerPropsBase<{
|
||||||
label: string
|
label: string
|
||||||
accessibilityLabelledBy?: undefined
|
accessibilityLabelledBy?: undefined
|
||||||
accessibilityDescribedBy?: undefined
|
accessibilityDescribedBy?: undefined
|
||||||
keyboardDismissMode?: ScrollViewProps['keyboardDismissMode']
|
keyboardDismissMode?: ScrollViewProps['keyboardDismissMode']
|
||||||
|
contentContainerStyle?: StyleProp<ViewStyle>
|
||||||
|
header?: React.ReactNode
|
||||||
}>
|
}>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
|
|
||||||
import {HITSLOP_20} from '#/lib/constants'
|
import {HITSLOP_20} from '#/lib/constants'
|
||||||
import {mergeRefs} from '#/lib/merge-refs'
|
import {mergeRefs} from '#/lib/merge-refs'
|
||||||
import {android, atoms as a, useTheme, web} from '#/alf'
|
import {android, atoms as a, TextStyleProp, useTheme, web} from '#/alf'
|
||||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||||
import {Props as SVGIconProps} from '#/components/icons/common'
|
import {Props as SVGIconProps} from '#/components/icons/common'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
@@ -123,6 +123,11 @@ export function useSharedInputStyles() {
|
|||||||
|
|
||||||
export type InputProps = Omit<TextInputProps, 'value' | 'onChangeText'> & {
|
export type InputProps = Omit<TextInputProps, 'value' | 'onChangeText'> & {
|
||||||
label: string
|
label: string
|
||||||
|
/**
|
||||||
|
* @deprecated Controlled inputs are *strongly* discouraged. Use `defaultValue` instead where possible.
|
||||||
|
*
|
||||||
|
* See https://github.com/facebook/react-native-website/pull/4247
|
||||||
|
*/
|
||||||
value?: string
|
value?: string
|
||||||
onChangeText?: (value: string) => void
|
onChangeText?: (value: string) => void
|
||||||
isInvalid?: boolean
|
isInvalid?: boolean
|
||||||
@@ -308,10 +313,13 @@ export function SuffixText({
|
|||||||
children,
|
children,
|
||||||
label,
|
label,
|
||||||
accessibilityHint,
|
accessibilityHint,
|
||||||
}: React.PropsWithChildren<{
|
style,
|
||||||
label: string
|
}: React.PropsWithChildren<
|
||||||
accessibilityHint?: AccessibilityProps['accessibilityHint']
|
TextStyleProp & {
|
||||||
}>) {
|
label: string
|
||||||
|
accessibilityHint?: AccessibilityProps['accessibilityHint']
|
||||||
|
}
|
||||||
|
>) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const ctx = React.useContext(Context)
|
const ctx = React.useContext(Context)
|
||||||
return (
|
return (
|
||||||
@@ -334,6 +342,7 @@ export function SuffixText({
|
|||||||
color: t.palette.contrast_800,
|
color: t.palette.contrast_800,
|
||||||
}
|
}
|
||||||
: {},
|
: {},
|
||||||
|
style,
|
||||||
]}>
|
]}>
|
||||||
{children}
|
{children}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -41,6 +41,20 @@ export function useEnforceMaxGraphemeCount() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useWarnMaxGraphemeCount({
|
||||||
|
text,
|
||||||
|
maxCount,
|
||||||
|
}: {
|
||||||
|
text: string
|
||||||
|
maxCount: number
|
||||||
|
}) {
|
||||||
|
const splitter = useMemo(() => new Graphemer(), [])
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
return splitter.countGraphemes(text) > maxCount
|
||||||
|
}, [splitter, maxCount, text])
|
||||||
|
}
|
||||||
|
|
||||||
// https://stackoverflow.com/a/52171480
|
// https://stackoverflow.com/a/52171480
|
||||||
export function toHashCode(str: string, seed = 0): number {
|
export function toHashCode(str: string, seed = 0): number {
|
||||||
let h1 = 0xdeadbeef ^ seed,
|
let h1 = 0xdeadbeef ^ seed,
|
||||||
|
|||||||
@@ -0,0 +1,370 @@
|
|||||||
|
import React, {useCallback, useEffect, useState} from 'react'
|
||||||
|
import {Dimensions, View} from 'react-native'
|
||||||
|
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||||
|
import {AppBskyActorDefs} from '@atproto/api'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {compressIfNeeded} from '#/lib/media/manip'
|
||||||
|
import {cleanError} from '#/lib/strings/errors'
|
||||||
|
import {useWarnMaxGraphemeCount} from '#/lib/strings/helpers'
|
||||||
|
import {logger} from '#/logger'
|
||||||
|
import {isWeb} from '#/platform/detection'
|
||||||
|
import {useProfileUpdateMutation} from '#/state/queries/profile'
|
||||||
|
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
|
||||||
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
|
import {EditableUserAvatar} from '#/view/com/util/UserAvatar'
|
||||||
|
import {UserBanner} from '#/view/com/util/UserBanner'
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Button, ButtonText} from '#/components/Button'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import * as TextField from '#/components/forms/TextField'
|
||||||
|
import * as Prompt from '#/components/Prompt'
|
||||||
|
|
||||||
|
const DISPLAY_NAME_MAX_GRAPHEMES = 64
|
||||||
|
const DESCRIPTION_MAX_GRAPHEMES = 256
|
||||||
|
|
||||||
|
const SCREEN_HEIGHT = Dimensions.get('window').height
|
||||||
|
|
||||||
|
export function EditProfileDialog({
|
||||||
|
profile,
|
||||||
|
control,
|
||||||
|
onUpdate,
|
||||||
|
}: {
|
||||||
|
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||||
|
control: Dialog.DialogControlProps
|
||||||
|
onUpdate?: () => void
|
||||||
|
}) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const cancelControl = Dialog.useDialogControl()
|
||||||
|
const [dirty, setDirty] = useState(false)
|
||||||
|
|
||||||
|
// 'You might lose unsaved changes' warning
|
||||||
|
useEffect(() => {
|
||||||
|
if (isWeb && dirty) {
|
||||||
|
const abortController = new AbortController()
|
||||||
|
const {signal} = abortController
|
||||||
|
window.addEventListener('beforeunload', evt => evt.preventDefault(), {
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
abortController.abort()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [dirty])
|
||||||
|
|
||||||
|
const onPressCancel = useCallback(() => {
|
||||||
|
if (dirty) {
|
||||||
|
cancelControl.open()
|
||||||
|
} else {
|
||||||
|
control.close()
|
||||||
|
}
|
||||||
|
}, [dirty, control, cancelControl])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.Outer
|
||||||
|
control={control}
|
||||||
|
nativeOptions={{
|
||||||
|
preventDismiss: dirty,
|
||||||
|
minHeight: SCREEN_HEIGHT,
|
||||||
|
}}>
|
||||||
|
<DialogInner
|
||||||
|
profile={profile}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
setDirty={setDirty}
|
||||||
|
onPressCancel={onPressCancel}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Prompt.Basic
|
||||||
|
control={cancelControl}
|
||||||
|
title={_(msg`Discard changes?`)}
|
||||||
|
description={_(msg`Are you sure you want to discard your changes?`)}
|
||||||
|
onConfirm={() => control.close()}
|
||||||
|
confirmButtonCta={_(msg`Discard`)}
|
||||||
|
confirmButtonColor="negative"
|
||||||
|
/>
|
||||||
|
</Dialog.Outer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogInner({
|
||||||
|
profile,
|
||||||
|
onUpdate,
|
||||||
|
setDirty,
|
||||||
|
onPressCancel,
|
||||||
|
}: {
|
||||||
|
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||||
|
onUpdate?: () => void
|
||||||
|
setDirty: (dirty: boolean) => void
|
||||||
|
onPressCancel: () => void
|
||||||
|
}) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const t = useTheme()
|
||||||
|
const control = Dialog.useDialogContext()
|
||||||
|
const {
|
||||||
|
mutateAsync: updateProfileMutation,
|
||||||
|
error: updateProfileError,
|
||||||
|
isError: isUpdateProfileError,
|
||||||
|
isPending: isUpdatingProfile,
|
||||||
|
} = useProfileUpdateMutation()
|
||||||
|
const [imageError, setImageError] = useState('')
|
||||||
|
const initialDisplayName = profile.displayName || ''
|
||||||
|
const [displayName, setDisplayName] = useState(initialDisplayName)
|
||||||
|
const initialDescription = profile.description || ''
|
||||||
|
const [description, setDescription] = useState(initialDescription)
|
||||||
|
const [userBanner, setUserBanner] = useState<string | undefined | null>(
|
||||||
|
profile.banner,
|
||||||
|
)
|
||||||
|
const [userAvatar, setUserAvatar] = useState<string | undefined | null>(
|
||||||
|
profile.avatar,
|
||||||
|
)
|
||||||
|
const [newUserBanner, setNewUserBanner] = useState<
|
||||||
|
RNImage | undefined | null
|
||||||
|
>()
|
||||||
|
const [newUserAvatar, setNewUserAvatar] = useState<
|
||||||
|
RNImage | undefined | null
|
||||||
|
>()
|
||||||
|
|
||||||
|
const dirty =
|
||||||
|
displayName !== initialDisplayName ||
|
||||||
|
description !== initialDescription ||
|
||||||
|
userAvatar !== profile.avatar ||
|
||||||
|
userBanner !== profile.banner
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDirty(dirty)
|
||||||
|
}, [dirty, setDirty])
|
||||||
|
|
||||||
|
const onSelectNewAvatar = useCallback(
|
||||||
|
async (img: RNImage | null) => {
|
||||||
|
setImageError('')
|
||||||
|
if (img === null) {
|
||||||
|
setNewUserAvatar(null)
|
||||||
|
setUserAvatar(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const finalImg = await compressIfNeeded(img, 1000000)
|
||||||
|
setNewUserAvatar(finalImg)
|
||||||
|
setUserAvatar(finalImg.path)
|
||||||
|
} catch (e: any) {
|
||||||
|
setImageError(cleanError(e))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setNewUserAvatar, setUserAvatar, setImageError],
|
||||||
|
)
|
||||||
|
|
||||||
|
const onSelectNewBanner = useCallback(
|
||||||
|
async (img: RNImage | null) => {
|
||||||
|
setImageError('')
|
||||||
|
if (!img) {
|
||||||
|
setNewUserBanner(null)
|
||||||
|
setUserBanner(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const finalImg = await compressIfNeeded(img, 1000000)
|
||||||
|
setNewUserBanner(finalImg)
|
||||||
|
setUserBanner(finalImg.path)
|
||||||
|
} catch (e: any) {
|
||||||
|
setImageError(cleanError(e))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setNewUserBanner, setUserBanner, setImageError],
|
||||||
|
)
|
||||||
|
|
||||||
|
const onPressSave = useCallback(async () => {
|
||||||
|
setImageError('')
|
||||||
|
try {
|
||||||
|
await updateProfileMutation({
|
||||||
|
profile,
|
||||||
|
updates: {
|
||||||
|
displayName: displayName.trimEnd(),
|
||||||
|
description: description.trimEnd(),
|
||||||
|
},
|
||||||
|
newUserAvatar,
|
||||||
|
newUserBanner,
|
||||||
|
})
|
||||||
|
onUpdate?.()
|
||||||
|
control.close()
|
||||||
|
Toast.show(_(msg`Profile updated`))
|
||||||
|
} catch (e: any) {
|
||||||
|
logger.error('Failed to update user profile', {message: String(e)})
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
updateProfileMutation,
|
||||||
|
profile,
|
||||||
|
onUpdate,
|
||||||
|
control,
|
||||||
|
displayName,
|
||||||
|
description,
|
||||||
|
newUserAvatar,
|
||||||
|
newUserBanner,
|
||||||
|
setImageError,
|
||||||
|
_,
|
||||||
|
])
|
||||||
|
|
||||||
|
const displayNameTooLong = useWarnMaxGraphemeCount({
|
||||||
|
text: displayName,
|
||||||
|
maxCount: DISPLAY_NAME_MAX_GRAPHEMES,
|
||||||
|
})
|
||||||
|
const descriptionTooLong = useWarnMaxGraphemeCount({
|
||||||
|
text: description,
|
||||||
|
maxCount: DESCRIPTION_MAX_GRAPHEMES,
|
||||||
|
})
|
||||||
|
|
||||||
|
const cancelButton = useCallback(
|
||||||
|
() => (
|
||||||
|
<Button
|
||||||
|
label={_(msg`Cancel`)}
|
||||||
|
onPress={onPressCancel}
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
variant="ghost"
|
||||||
|
style={[a.rounded_full]}>
|
||||||
|
<ButtonText style={[a.text_md]}>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
[onPressCancel, _],
|
||||||
|
)
|
||||||
|
|
||||||
|
const saveButton = useCallback(
|
||||||
|
() => (
|
||||||
|
<Button
|
||||||
|
label={_(msg`Save`)}
|
||||||
|
onPress={onPressSave}
|
||||||
|
disabled={
|
||||||
|
!dirty ||
|
||||||
|
isUpdatingProfile ||
|
||||||
|
displayNameTooLong ||
|
||||||
|
descriptionTooLong
|
||||||
|
}
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
variant="ghost"
|
||||||
|
style={[a.rounded_full]}>
|
||||||
|
<ButtonText style={[a.text_md, !dirty && t.atoms.text_contrast_low]}>
|
||||||
|
<Trans>Save</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
[
|
||||||
|
_,
|
||||||
|
t,
|
||||||
|
dirty,
|
||||||
|
onPressSave,
|
||||||
|
isUpdatingProfile,
|
||||||
|
displayNameTooLong,
|
||||||
|
descriptionTooLong,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.ScrollableInner
|
||||||
|
label={_(msg`Edit profile`)}
|
||||||
|
style={[a.overflow_hidden]}
|
||||||
|
contentContainerStyle={[a.px_0, a.pt_0]}
|
||||||
|
header={
|
||||||
|
<Dialog.Header renderLeft={cancelButton} renderRight={saveButton}>
|
||||||
|
<Dialog.HeaderText>
|
||||||
|
<Trans>Edit profile</Trans>
|
||||||
|
</Dialog.HeaderText>
|
||||||
|
</Dialog.Header>
|
||||||
|
}>
|
||||||
|
<View style={[a.relative]}>
|
||||||
|
<UserBanner banner={userBanner} onSelectNewBanner={onSelectNewBanner} />
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
{
|
||||||
|
top: 80,
|
||||||
|
left: 20,
|
||||||
|
width: 84,
|
||||||
|
height: 84,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderRadius: 42,
|
||||||
|
borderColor: t.atoms.bg.backgroundColor,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<EditableUserAvatar
|
||||||
|
size={80}
|
||||||
|
avatar={userAvatar}
|
||||||
|
onSelectNewAvatar={onSelectNewAvatar}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{isUpdateProfileError && (
|
||||||
|
<View style={[a.mt_xl]}>
|
||||||
|
<ErrorMessage message={cleanError(updateProfileError)} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{imageError !== '' && (
|
||||||
|
<View style={[a.mt_xl]}>
|
||||||
|
<ErrorMessage message={imageError} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View style={[a.mt_4xl, a.px_xl, a.gap_xl]}>
|
||||||
|
<View>
|
||||||
|
<TextField.LabelText>
|
||||||
|
<Trans>Display name</Trans>
|
||||||
|
</TextField.LabelText>
|
||||||
|
<TextField.Root isInvalid={displayNameTooLong}>
|
||||||
|
<Dialog.Input
|
||||||
|
defaultValue={displayName}
|
||||||
|
onChangeText={setDisplayName}
|
||||||
|
label={_(msg`Display name`)}
|
||||||
|
placeholder={_(msg`e.g. Alice Lastname`)}
|
||||||
|
/>
|
||||||
|
</TextField.Root>
|
||||||
|
{displayNameTooLong && (
|
||||||
|
<TextField.SuffixText
|
||||||
|
style={[
|
||||||
|
a.text_sm,
|
||||||
|
a.mt_xs,
|
||||||
|
a.font_bold,
|
||||||
|
{color: t.palette.negative_400},
|
||||||
|
]}
|
||||||
|
label={_(msg`Display name is too long`)}>
|
||||||
|
<Trans>
|
||||||
|
Display name is too long. The maximum number of characters is{' '}
|
||||||
|
{DISPLAY_NAME_MAX_GRAPHEMES}.
|
||||||
|
</Trans>
|
||||||
|
</TextField.SuffixText>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View>
|
||||||
|
<TextField.LabelText>
|
||||||
|
<Trans>Description</Trans>
|
||||||
|
</TextField.LabelText>
|
||||||
|
<TextField.Root isInvalid={descriptionTooLong}>
|
||||||
|
<Dialog.Input
|
||||||
|
defaultValue={description}
|
||||||
|
onChangeText={setDescription}
|
||||||
|
multiline
|
||||||
|
label={_(msg`Display name`)}
|
||||||
|
placeholder={_(msg`Tell us a bit about yourself`)}
|
||||||
|
/>
|
||||||
|
</TextField.Root>
|
||||||
|
{descriptionTooLong && (
|
||||||
|
<TextField.SuffixText
|
||||||
|
style={[
|
||||||
|
a.text_sm,
|
||||||
|
a.mt_xs,
|
||||||
|
a.font_bold,
|
||||||
|
{color: t.palette.negative_400},
|
||||||
|
]}
|
||||||
|
label={_(msg`Description is too long`)}>
|
||||||
|
<Trans>
|
||||||
|
Description is too long. The maximum number of characters is{' '}
|
||||||
|
{DESCRIPTION_MAX_GRAPHEMES}.
|
||||||
|
</Trans>
|
||||||
|
</TextField.SuffixText>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Dialog.ScrollableInner>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -18,7 +18,6 @@ import {logger} from '#/logger'
|
|||||||
import {isIOS} from '#/platform/detection'
|
import {isIOS} from '#/platform/detection'
|
||||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||||
import {Shadow} from '#/state/cache/types'
|
import {Shadow} from '#/state/cache/types'
|
||||||
import {useModalControls} from '#/state/modals'
|
|
||||||
import {useLabelerSubscriptionMutation} from '#/state/queries/labeler'
|
import {useLabelerSubscriptionMutation} from '#/state/queries/labeler'
|
||||||
import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like'
|
import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like'
|
||||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||||
@@ -27,7 +26,7 @@ import {ProfileMenu} from '#/view/com/profile/ProfileMenu'
|
|||||||
import * as Toast from '#/view/com/util/Toast'
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
import {atoms as a, tokens, useTheme} from '#/alf'
|
import {atoms as a, tokens, useTheme} from '#/alf'
|
||||||
import {Button, ButtonText} from '#/components/Button'
|
import {Button, ButtonText} from '#/components/Button'
|
||||||
import {DialogOuterProps} from '#/components/Dialog'
|
import {DialogOuterProps, useDialogControl} from '#/components/Dialog'
|
||||||
import {
|
import {
|
||||||
Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled,
|
Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled,
|
||||||
Heart2_Stroke2_Corner0_Rounded as Heart,
|
Heart2_Stroke2_Corner0_Rounded as Heart,
|
||||||
@@ -37,6 +36,7 @@ import * as Prompt from '#/components/Prompt'
|
|||||||
import {RichText} from '#/components/RichText'
|
import {RichText} from '#/components/RichText'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {ProfileHeaderDisplayName} from './DisplayName'
|
import {ProfileHeaderDisplayName} from './DisplayName'
|
||||||
|
import {EditProfileDialog} from './EditProfileDialog'
|
||||||
import {ProfileHeaderHandle} from './Handle'
|
import {ProfileHeaderHandle} from './Handle'
|
||||||
import {ProfileHeaderMetrics} from './Metrics'
|
import {ProfileHeaderMetrics} from './Metrics'
|
||||||
import {ProfileHeaderShell} from './Shell'
|
import {ProfileHeaderShell} from './Shell'
|
||||||
@@ -63,7 +63,6 @@ let ProfileHeaderLabeler = ({
|
|||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {currentAccount, hasSession} = useSession()
|
const {currentAccount, hasSession} = useSession()
|
||||||
const {openModal} = useModalControls()
|
|
||||||
const requireAuth = useRequireAuth()
|
const requireAuth = useRequireAuth()
|
||||||
const playHaptic = useHaptics()
|
const playHaptic = useHaptics()
|
||||||
const cantSubscribePrompt = Prompt.usePromptControl()
|
const cantSubscribePrompt = Prompt.usePromptControl()
|
||||||
@@ -117,12 +116,10 @@ let ProfileHeaderLabeler = ({
|
|||||||
}
|
}
|
||||||
}, [labeler, playHaptic, likeUri, unlikeMod, likeMod, _])
|
}, [labeler, playHaptic, likeUri, unlikeMod, likeMod, _])
|
||||||
|
|
||||||
|
const editProfileControl = useDialogControl()
|
||||||
const onPressEditProfile = React.useCallback(() => {
|
const onPressEditProfile = React.useCallback(() => {
|
||||||
openModal({
|
editProfileControl.open()
|
||||||
name: 'edit-profile',
|
}, [editProfileControl])
|
||||||
profile,
|
|
||||||
})
|
|
||||||
}, [openModal, profile])
|
|
||||||
|
|
||||||
const onPressSubscribe = React.useCallback(
|
const onPressSubscribe = React.useCallback(
|
||||||
() =>
|
() =>
|
||||||
@@ -169,18 +166,24 @@ let ProfileHeaderLabeler = ({
|
|||||||
style={[a.flex_row, a.justify_end, a.align_center, a.gap_xs, a.pb_lg]}
|
style={[a.flex_row, a.justify_end, a.align_center, a.gap_xs, a.pb_lg]}
|
||||||
pointerEvents={isIOS ? 'auto' : 'box-none'}>
|
pointerEvents={isIOS ? 'auto' : 'box-none'}>
|
||||||
{isMe ? (
|
{isMe ? (
|
||||||
<Button
|
<>
|
||||||
testID="profileHeaderEditProfileButton"
|
<Button
|
||||||
size="small"
|
testID="profileHeaderEditProfileButton"
|
||||||
color="secondary"
|
size="small"
|
||||||
variant="solid"
|
color="secondary"
|
||||||
onPress={onPressEditProfile}
|
variant="solid"
|
||||||
label={_(msg`Edit profile`)}
|
onPress={onPressEditProfile}
|
||||||
style={a.rounded_full}>
|
label={_(msg`Edit profile`)}
|
||||||
<ButtonText>
|
style={a.rounded_full}>
|
||||||
<Trans>Edit Profile</Trans>
|
<ButtonText>
|
||||||
</ButtonText>
|
<Trans>Edit Profile</Trans>
|
||||||
</Button>
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
<EditProfileDialog
|
||||||
|
profile={profile}
|
||||||
|
control={editProfileControl}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
) : !isAppLabeler(profile.did) ? (
|
) : !isAppLabeler(profile.did) ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {logger} from '#/logger'
|
|||||||
import {isIOS} from '#/platform/detection'
|
import {isIOS} from '#/platform/detection'
|
||||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||||
import {Shadow} from '#/state/cache/types'
|
import {Shadow} from '#/state/cache/types'
|
||||||
import {useModalControls} from '#/state/modals'
|
|
||||||
import {
|
import {
|
||||||
useProfileBlockMutationQueue,
|
useProfileBlockMutationQueue,
|
||||||
useProfileFollowMutationQueue,
|
useProfileFollowMutationQueue,
|
||||||
@@ -24,6 +23,7 @@ import {ProfileMenu} from '#/view/com/profile/ProfileMenu'
|
|||||||
import * as Toast from '#/view/com/util/Toast'
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
|
import {useDialogControl} from '#/components/Dialog'
|
||||||
import {MessageProfileButton} from '#/components/dms/MessageProfileButton'
|
import {MessageProfileButton} from '#/components/dms/MessageProfileButton'
|
||||||
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
import * as Prompt from '#/components/Prompt'
|
import * as Prompt from '#/components/Prompt'
|
||||||
import {RichText} from '#/components/RichText'
|
import {RichText} from '#/components/RichText'
|
||||||
import {ProfileHeaderDisplayName} from './DisplayName'
|
import {ProfileHeaderDisplayName} from './DisplayName'
|
||||||
|
import {EditProfileDialog} from './EditProfileDialog'
|
||||||
import {ProfileHeaderHandle} from './Handle'
|
import {ProfileHeaderHandle} from './Handle'
|
||||||
import {ProfileHeaderMetrics} from './Metrics'
|
import {ProfileHeaderMetrics} from './Metrics'
|
||||||
import {ProfileHeaderShell} from './Shell'
|
import {ProfileHeaderShell} from './Shell'
|
||||||
@@ -57,7 +58,6 @@ let ProfileHeaderStandard = ({
|
|||||||
useProfileShadow(profileUnshadowed)
|
useProfileShadow(profileUnshadowed)
|
||||||
const {currentAccount, hasSession} = useSession()
|
const {currentAccount, hasSession} = useSession()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {openModal} = useModalControls()
|
|
||||||
const moderation = useMemo(
|
const moderation = useMemo(
|
||||||
() => moderateProfile(profile, moderationOpts),
|
() => moderateProfile(profile, moderationOpts),
|
||||||
[profile, moderationOpts],
|
[profile, moderationOpts],
|
||||||
@@ -74,12 +74,10 @@ let ProfileHeaderStandard = ({
|
|||||||
profile.viewer?.blockedBy ||
|
profile.viewer?.blockedBy ||
|
||||||
profile.viewer?.blockingByList
|
profile.viewer?.blockingByList
|
||||||
|
|
||||||
|
const editProfileControl = useDialogControl()
|
||||||
const onPressEditProfile = React.useCallback(() => {
|
const onPressEditProfile = React.useCallback(() => {
|
||||||
openModal({
|
editProfileControl.open()
|
||||||
name: 'edit-profile',
|
}, [editProfileControl])
|
||||||
profile,
|
|
||||||
})
|
|
||||||
}, [openModal, profile])
|
|
||||||
|
|
||||||
const onPressFollow = () => {
|
const onPressFollow = () => {
|
||||||
requireAuth(async () => {
|
requireAuth(async () => {
|
||||||
@@ -161,18 +159,24 @@ let ProfileHeaderStandard = ({
|
|||||||
]}
|
]}
|
||||||
pointerEvents={isIOS ? 'auto' : 'box-none'}>
|
pointerEvents={isIOS ? 'auto' : 'box-none'}>
|
||||||
{isMe ? (
|
{isMe ? (
|
||||||
<Button
|
<>
|
||||||
testID="profileHeaderEditProfileButton"
|
<Button
|
||||||
size="small"
|
testID="profileHeaderEditProfileButton"
|
||||||
color="secondary"
|
size="small"
|
||||||
variant="solid"
|
color="secondary"
|
||||||
onPress={onPressEditProfile}
|
variant="solid"
|
||||||
label={_(msg`Edit profile`)}
|
onPress={onPressEditProfile}
|
||||||
style={[a.rounded_full]}>
|
label={_(msg`Edit profile`)}
|
||||||
<ButtonText>
|
style={[a.rounded_full]}>
|
||||||
<Trans>Edit Profile</Trans>
|
<ButtonText>
|
||||||
</ButtonText>
|
<Trans>Edit Profile</Trans>
|
||||||
</Button>
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
<EditProfileDialog
|
||||||
|
profile={profile}
|
||||||
|
control={editProfileControl}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
) : profile.viewer?.blocking ? (
|
) : profile.viewer?.blocking ? (
|
||||||
profile.viewer?.blockingByList ? null : (
|
profile.viewer?.blockingByList ? null : (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -4,12 +4,6 @@ import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api'
|
|||||||
|
|
||||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||||
|
|
||||||
export interface EditProfileModal {
|
|
||||||
name: 'edit-profile'
|
|
||||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
|
||||||
onUpdate?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateOrEditListModal {
|
export interface CreateOrEditListModal {
|
||||||
name: 'create-or-edit-list'
|
name: 'create-or-edit-list'
|
||||||
purpose?: string
|
purpose?: string
|
||||||
@@ -111,7 +105,6 @@ export type Modal =
|
|||||||
| AddAppPasswordModal
|
| AddAppPasswordModal
|
||||||
| ChangeHandleModal
|
| ChangeHandleModal
|
||||||
| DeleteAccountModal
|
| DeleteAccountModal
|
||||||
| EditProfileModal
|
|
||||||
| VerifyEmailModal
|
| VerifyEmailModal
|
||||||
| ChangeEmailModal
|
| ChangeEmailModal
|
||||||
| ChangePasswordModal
|
| ChangePasswordModal
|
||||||
|
|||||||
@@ -1,310 +0,0 @@
|
|||||||
import React, {useCallback, useState} from 'react'
|
|
||||||
import {
|
|
||||||
ActivityIndicator,
|
|
||||||
KeyboardAvoidingView,
|
|
||||||
ScrollView,
|
|
||||||
StyleSheet,
|
|
||||||
TextInput,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from 'react-native'
|
|
||||||
import {Image as RNImage} from 'react-native-image-crop-picker'
|
|
||||||
import Animated, {FadeOut} from 'react-native-reanimated'
|
|
||||||
import {LinearGradient} from 'expo-linear-gradient'
|
|
||||||
import {AppBskyActorDefs} from '@atproto/api'
|
|
||||||
import {msg, Trans} from '@lingui/macro'
|
|
||||||
import {useLingui} from '@lingui/react'
|
|
||||||
|
|
||||||
import {MAX_DESCRIPTION, MAX_DISPLAY_NAME} from '#/lib/constants'
|
|
||||||
import {usePalette} from '#/lib/hooks/usePalette'
|
|
||||||
import {compressIfNeeded} from '#/lib/media/manip'
|
|
||||||
import {cleanError} from '#/lib/strings/errors'
|
|
||||||
import {enforceLen} from '#/lib/strings/helpers'
|
|
||||||
import {colors, gradients, s} from '#/lib/styles'
|
|
||||||
import {useTheme} from '#/lib/ThemeContext'
|
|
||||||
import {logger} from '#/logger'
|
|
||||||
import {isWeb} from '#/platform/detection'
|
|
||||||
import {useModalControls} from '#/state/modals'
|
|
||||||
import {useProfileUpdateMutation} from '#/state/queries/profile'
|
|
||||||
import {Text} from '#/view/com/util/text/Text'
|
|
||||||
import * as Toast from '#/view/com/util/Toast'
|
|
||||||
import {EditableUserAvatar} from '#/view/com/util/UserAvatar'
|
|
||||||
import {UserBanner} from '#/view/com/util/UserBanner'
|
|
||||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
|
||||||
|
|
||||||
const AnimatedTouchableOpacity =
|
|
||||||
Animated.createAnimatedComponent(TouchableOpacity)
|
|
||||||
|
|
||||||
export const snapPoints = ['fullscreen']
|
|
||||||
|
|
||||||
export function Component({
|
|
||||||
profile,
|
|
||||||
onUpdate,
|
|
||||||
}: {
|
|
||||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
|
||||||
onUpdate?: () => void
|
|
||||||
}) {
|
|
||||||
const pal = usePalette('default')
|
|
||||||
const theme = useTheme()
|
|
||||||
const {_} = useLingui()
|
|
||||||
const {closeModal} = useModalControls()
|
|
||||||
const updateMutation = useProfileUpdateMutation()
|
|
||||||
const [imageError, setImageError] = useState<string>('')
|
|
||||||
const [displayName, setDisplayName] = useState<string>(
|
|
||||||
profile.displayName || '',
|
|
||||||
)
|
|
||||||
const [description, setDescription] = useState<string>(
|
|
||||||
profile.description || '',
|
|
||||||
)
|
|
||||||
const [userBanner, setUserBanner] = useState<string | undefined | null>(
|
|
||||||
profile.banner,
|
|
||||||
)
|
|
||||||
const [userAvatar, setUserAvatar] = useState<string | undefined | null>(
|
|
||||||
profile.avatar,
|
|
||||||
)
|
|
||||||
const [newUserBanner, setNewUserBanner] = useState<
|
|
||||||
RNImage | undefined | null
|
|
||||||
>()
|
|
||||||
const [newUserAvatar, setNewUserAvatar] = useState<
|
|
||||||
RNImage | undefined | null
|
|
||||||
>()
|
|
||||||
const onPressCancel = () => {
|
|
||||||
closeModal()
|
|
||||||
}
|
|
||||||
const onSelectNewAvatar = useCallback(
|
|
||||||
async (img: RNImage | null) => {
|
|
||||||
setImageError('')
|
|
||||||
if (img === null) {
|
|
||||||
setNewUserAvatar(null)
|
|
||||||
setUserAvatar(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const finalImg = await compressIfNeeded(img, 1000000)
|
|
||||||
setNewUserAvatar(finalImg)
|
|
||||||
setUserAvatar(finalImg.path)
|
|
||||||
} catch (e: any) {
|
|
||||||
setImageError(cleanError(e))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[setNewUserAvatar, setUserAvatar, setImageError],
|
|
||||||
)
|
|
||||||
|
|
||||||
const onSelectNewBanner = useCallback(
|
|
||||||
async (img: RNImage | null) => {
|
|
||||||
setImageError('')
|
|
||||||
if (!img) {
|
|
||||||
setNewUserBanner(null)
|
|
||||||
setUserBanner(null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const finalImg = await compressIfNeeded(img, 1000000)
|
|
||||||
setNewUserBanner(finalImg)
|
|
||||||
setUserBanner(finalImg.path)
|
|
||||||
} catch (e: any) {
|
|
||||||
setImageError(cleanError(e))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[setNewUserBanner, setUserBanner, setImageError],
|
|
||||||
)
|
|
||||||
|
|
||||||
const onPressSave = useCallback(async () => {
|
|
||||||
setImageError('')
|
|
||||||
try {
|
|
||||||
await updateMutation.mutateAsync({
|
|
||||||
profile,
|
|
||||||
updates: {
|
|
||||||
displayName,
|
|
||||||
description,
|
|
||||||
},
|
|
||||||
newUserAvatar,
|
|
||||||
newUserBanner,
|
|
||||||
})
|
|
||||||
Toast.show(_(msg`Profile updated`))
|
|
||||||
onUpdate?.()
|
|
||||||
closeModal()
|
|
||||||
} catch (e: any) {
|
|
||||||
logger.error('Failed to update user profile', {message: String(e)})
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
updateMutation,
|
|
||||||
profile,
|
|
||||||
onUpdate,
|
|
||||||
closeModal,
|
|
||||||
displayName,
|
|
||||||
description,
|
|
||||||
newUserAvatar,
|
|
||||||
newUserBanner,
|
|
||||||
setImageError,
|
|
||||||
_,
|
|
||||||
])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<KeyboardAvoidingView style={s.flex1} behavior="height">
|
|
||||||
<ScrollView style={[pal.view]} testID="editProfileModal">
|
|
||||||
<Text style={[styles.title, pal.text]}>
|
|
||||||
<Trans>Edit my profile</Trans>
|
|
||||||
</Text>
|
|
||||||
<View style={styles.photos}>
|
|
||||||
<UserBanner
|
|
||||||
banner={userBanner}
|
|
||||||
onSelectNewBanner={onSelectNewBanner}
|
|
||||||
/>
|
|
||||||
<View style={[styles.avi, {borderColor: pal.colors.background}]}>
|
|
||||||
<EditableUserAvatar
|
|
||||||
size={80}
|
|
||||||
avatar={userAvatar}
|
|
||||||
onSelectNewAvatar={onSelectNewAvatar}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
{updateMutation.isError && (
|
|
||||||
<View style={styles.errorContainer}>
|
|
||||||
<ErrorMessage message={cleanError(updateMutation.error)} />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{imageError !== '' && (
|
|
||||||
<View style={styles.errorContainer}>
|
|
||||||
<ErrorMessage message={imageError} />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
<View style={styles.form}>
|
|
||||||
<View>
|
|
||||||
<Text style={[styles.label, pal.text]}>
|
|
||||||
<Trans>Display Name</Trans>
|
|
||||||
</Text>
|
|
||||||
<TextInput
|
|
||||||
testID="editProfileDisplayNameInput"
|
|
||||||
style={[styles.textInput, pal.border, pal.text]}
|
|
||||||
placeholder={_(msg`e.g. Alice Roberts`)}
|
|
||||||
placeholderTextColor={colors.gray4}
|
|
||||||
value={displayName}
|
|
||||||
onChangeText={v =>
|
|
||||||
setDisplayName(enforceLen(v, MAX_DISPLAY_NAME))
|
|
||||||
}
|
|
||||||
accessible={true}
|
|
||||||
accessibilityLabel={_(msg`Display name`)}
|
|
||||||
accessibilityHint={_(msg`Edit your display name`)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View style={s.pb10}>
|
|
||||||
<Text style={[styles.label, pal.text]}>
|
|
||||||
<Trans>Description</Trans>
|
|
||||||
</Text>
|
|
||||||
<TextInput
|
|
||||||
testID="editProfileDescriptionInput"
|
|
||||||
style={[styles.textArea, pal.border, pal.text]}
|
|
||||||
placeholder={_(msg`e.g. Artist, dog-lover, and avid reader.`)}
|
|
||||||
placeholderTextColor={colors.gray4}
|
|
||||||
keyboardAppearance={theme.colorScheme}
|
|
||||||
multiline
|
|
||||||
value={description}
|
|
||||||
onChangeText={v => setDescription(enforceLen(v, MAX_DESCRIPTION))}
|
|
||||||
accessible={true}
|
|
||||||
accessibilityLabel={_(msg`Description`)}
|
|
||||||
accessibilityHint={_(msg`Edit your profile description`)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
{updateMutation.isPending ? (
|
|
||||||
<View style={[styles.btn, s.mt10, {backgroundColor: colors.gray2}]}>
|
|
||||||
<ActivityIndicator />
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<TouchableOpacity
|
|
||||||
testID="editProfileSaveBtn"
|
|
||||||
style={s.mt10}
|
|
||||||
onPress={onPressSave}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={_(msg`Save`)}
|
|
||||||
accessibilityHint={_(msg`Saves any changes to your profile`)}>
|
|
||||||
<LinearGradient
|
|
||||||
colors={[gradients.blueLight.start, gradients.blueLight.end]}
|
|
||||||
start={{x: 0, y: 0}}
|
|
||||||
end={{x: 1, y: 1}}
|
|
||||||
style={[styles.btn]}>
|
|
||||||
<Text style={[s.white, s.bold]}>
|
|
||||||
<Trans>Save Changes</Trans>
|
|
||||||
</Text>
|
|
||||||
</LinearGradient>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
{!updateMutation.isPending && (
|
|
||||||
<AnimatedTouchableOpacity
|
|
||||||
exiting={!isWeb ? FadeOut : undefined}
|
|
||||||
testID="editProfileCancelBtn"
|
|
||||||
style={s.mt5}
|
|
||||||
onPress={onPressCancel}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={_(msg`Cancel profile editing`)}
|
|
||||||
accessibilityHint=""
|
|
||||||
onAccessibilityEscape={onPressCancel}>
|
|
||||||
<View style={[styles.btn]}>
|
|
||||||
<Text style={[s.black, s.bold, pal.text]}>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</AnimatedTouchableOpacity>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</KeyboardAvoidingView>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
title: {
|
|
||||||
textAlign: 'center',
|
|
||||||
fontWeight: '600',
|
|
||||||
fontSize: 24,
|
|
||||||
marginBottom: 18,
|
|
||||||
},
|
|
||||||
label: {
|
|
||||||
fontWeight: '600',
|
|
||||||
paddingHorizontal: 4,
|
|
||||||
paddingBottom: 4,
|
|
||||||
marginTop: 20,
|
|
||||||
},
|
|
||||||
form: {
|
|
||||||
paddingHorizontal: 14,
|
|
||||||
},
|
|
||||||
textInput: {
|
|
||||||
borderWidth: 1,
|
|
||||||
borderRadius: 6,
|
|
||||||
paddingHorizontal: 14,
|
|
||||||
paddingVertical: 10,
|
|
||||||
fontSize: 16,
|
|
||||||
},
|
|
||||||
textArea: {
|
|
||||||
borderWidth: 1,
|
|
||||||
borderRadius: 6,
|
|
||||||
paddingHorizontal: 12,
|
|
||||||
paddingTop: 10,
|
|
||||||
fontSize: 16,
|
|
||||||
height: 120,
|
|
||||||
textAlignVertical: 'top',
|
|
||||||
},
|
|
||||||
btn: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
width: '100%',
|
|
||||||
borderRadius: 32,
|
|
||||||
padding: 10,
|
|
||||||
marginBottom: 10,
|
|
||||||
},
|
|
||||||
avi: {
|
|
||||||
position: 'absolute',
|
|
||||||
top: 80,
|
|
||||||
left: 24,
|
|
||||||
width: 84,
|
|
||||||
height: 84,
|
|
||||||
borderWidth: 2,
|
|
||||||
borderRadius: 42,
|
|
||||||
},
|
|
||||||
photos: {
|
|
||||||
marginBottom: 36,
|
|
||||||
marginHorizontal: -14,
|
|
||||||
},
|
|
||||||
errorContainer: {marginTop: 20},
|
|
||||||
})
|
|
||||||
@@ -13,7 +13,6 @@ import * as ChangeHandleModal from './ChangeHandle'
|
|||||||
import * as ChangePasswordModal from './ChangePassword'
|
import * as ChangePasswordModal from './ChangePassword'
|
||||||
import * as CreateOrEditListModal from './CreateOrEditList'
|
import * as CreateOrEditListModal from './CreateOrEditList'
|
||||||
import * as DeleteAccountModal from './DeleteAccount'
|
import * as DeleteAccountModal from './DeleteAccount'
|
||||||
import * as EditProfileModal from './EditProfile'
|
|
||||||
import * as InAppBrowserConsentModal from './InAppBrowserConsent'
|
import * as InAppBrowserConsentModal from './InAppBrowserConsent'
|
||||||
import * as InviteCodesModal from './InviteCodes'
|
import * as InviteCodesModal from './InviteCodes'
|
||||||
import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
|
import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
|
||||||
@@ -55,10 +54,7 @@ export function ModalsContainer() {
|
|||||||
|
|
||||||
let snapPoints: (string | number)[] = DEFAULT_SNAPPOINTS
|
let snapPoints: (string | number)[] = DEFAULT_SNAPPOINTS
|
||||||
let element
|
let element
|
||||||
if (activeModal?.name === 'edit-profile') {
|
if (activeModal?.name === 'create-or-edit-list') {
|
||||||
snapPoints = EditProfileModal.snapPoints
|
|
||||||
element = <EditProfileModal.Component {...activeModal} />
|
|
||||||
} else if (activeModal?.name === 'create-or-edit-list') {
|
|
||||||
snapPoints = CreateOrEditListModal.snapPoints
|
snapPoints = CreateOrEditListModal.snapPoints
|
||||||
element = <CreateOrEditListModal.Component {...activeModal} />
|
element = <CreateOrEditListModal.Component {...activeModal} />
|
||||||
} else if (activeModal?.name === 'user-add-remove-lists') {
|
} else if (activeModal?.name === 'user-add-remove-lists') {
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import * as ChangePasswordModal from './ChangePassword'
|
|||||||
import * as CreateOrEditListModal from './CreateOrEditList'
|
import * as CreateOrEditListModal from './CreateOrEditList'
|
||||||
import * as CropImageModal from './CropImage.web'
|
import * as CropImageModal from './CropImage.web'
|
||||||
import * as DeleteAccountModal from './DeleteAccount'
|
import * as DeleteAccountModal from './DeleteAccount'
|
||||||
import * as EditProfileModal from './EditProfile'
|
|
||||||
import * as InviteCodesModal from './InviteCodes'
|
import * as InviteCodesModal from './InviteCodes'
|
||||||
import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
|
import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
|
||||||
import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
|
import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
|
||||||
@@ -63,9 +62,7 @@ function Modal({modal}: {modal: ModalIface}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let element
|
let element
|
||||||
if (modal.name === 'edit-profile') {
|
if (modal.name === 'create-or-edit-list') {
|
||||||
element = <EditProfileModal.Component {...modal} />
|
|
||||||
} else if (modal.name === 'create-or-edit-list') {
|
|
||||||
element = <CreateOrEditListModal.Component {...modal} />
|
element = <CreateOrEditListModal.Component {...modal} />
|
||||||
} else if (modal.name === 'user-add-remove-lists') {
|
} else if (modal.name === 'user-add-remove-lists') {
|
||||||
element = <UserAddRemoveLists.Component {...modal} />
|
element = <UserAddRemoveLists.Component {...modal} />
|
||||||
|
|||||||
@@ -5197,17 +5197,7 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz#8635edd346304f8b42cae86b05912b61aef27afe"
|
resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz#8635edd346304f8b42cae86b05912b61aef27afe"
|
||||||
integrity sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==
|
integrity sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==
|
||||||
|
|
||||||
"@radix-ui/react-focus-scope@1.0.1":
|
"@radix-ui/react-focus-scope@1.0.1", "@radix-ui/react-focus-scope@1.1.0", "@radix-ui/react-focus-scope@^1.1.0":
|
||||||
version "1.0.1"
|
|
||||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.0.1.tgz#faea8c25f537c5a5c38c50914b63722db0e7f951"
|
|
||||||
integrity sha512-Ej2MQTit8IWJiS2uuujGUmxXjF/y5xZptIIQnyd2JHLwtV0R2j9NRVoRj/1j/gJ7e3REdaBw4Hjf4a1ImhkZcQ==
|
|
||||||
dependencies:
|
|
||||||
"@babel/runtime" "^7.13.10"
|
|
||||||
"@radix-ui/react-compose-refs" "1.0.0"
|
|
||||||
"@radix-ui/react-primitive" "1.0.1"
|
|
||||||
"@radix-ui/react-use-callback-ref" "1.0.0"
|
|
||||||
|
|
||||||
"@radix-ui/react-focus-scope@1.1.0", "@radix-ui/react-focus-scope@^1.1.0":
|
|
||||||
version "1.1.0"
|
version "1.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.0.tgz#ebe2891a298e0a33ad34daab2aad8dea31caf0b2"
|
resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.0.tgz#ebe2891a298e0a33ad34daab2aad8dea31caf0b2"
|
||||||
integrity sha512-200UD8zylvEyL8Bx+z76RJnASR2gRMuxlgFCPAe/Q/679a/r0eK3MBVYMb7vZODZcffZBdob1EGnky78xmVvcA==
|
integrity sha512-200UD8zylvEyL8Bx+z76RJnASR2gRMuxlgFCPAe/Q/679a/r0eK3MBVYMb7vZODZcffZBdob1EGnky78xmVvcA==
|
||||||
|
|||||||
Reference in New Issue
Block a user