New Edit Profile dialog on web, use new Edit Image dialog everywhere (#8220)
This commit is contained in:
@@ -1,4 +1,11 @@
|
|||||||
import React from 'react'
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useId,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
} from 'react'
|
||||||
|
|
||||||
import {useDialogStateContext} from '#/state/dialogs'
|
import {useDialogStateContext} from '#/state/dialogs'
|
||||||
import {
|
import {
|
||||||
@@ -8,7 +15,7 @@ import {
|
|||||||
} from '#/components/Dialog/types'
|
} from '#/components/Dialog/types'
|
||||||
import {BottomSheetSnapPoint} from '../../../modules/bottom-sheet/src/BottomSheet.types'
|
import {BottomSheetSnapPoint} from '../../../modules/bottom-sheet/src/BottomSheet.types'
|
||||||
|
|
||||||
export const Context = React.createContext<DialogContextProps>({
|
export const Context = createContext<DialogContextProps>({
|
||||||
close: () => {},
|
close: () => {},
|
||||||
isNativeDialog: false,
|
isNativeDialog: false,
|
||||||
nativeSnapPoint: BottomSheetSnapPoint.Hidden,
|
nativeSnapPoint: BottomSheetSnapPoint.Hidden,
|
||||||
@@ -18,18 +25,18 @@ export const Context = React.createContext<DialogContextProps>({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export function useDialogContext() {
|
export function useDialogContext() {
|
||||||
return React.useContext(Context)
|
return useContext(Context)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDialogControl(): DialogOuterProps['control'] {
|
export function useDialogControl(): DialogOuterProps['control'] {
|
||||||
const id = React.useId()
|
const id = useId()
|
||||||
const control = React.useRef<DialogControlRefProps>({
|
const control = useRef<DialogControlRefProps>({
|
||||||
open: () => {},
|
open: () => {},
|
||||||
close: () => {},
|
close: () => {},
|
||||||
})
|
})
|
||||||
const {activeDialogs} = useDialogStateContext()
|
const {activeDialogs} = useDialogStateContext()
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
activeDialogs.current.set(id, control)
|
activeDialogs.current.set(id, control)
|
||||||
return () => {
|
return () => {
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -37,7 +44,7 @@ export function useDialogControl(): DialogOuterProps['control'] {
|
|||||||
}
|
}
|
||||||
}, [id, activeDialogs])
|
}, [id, activeDialogs])
|
||||||
|
|
||||||
return React.useMemo<DialogOuterProps['control']>(
|
return useMemo<DialogOuterProps['control']>(
|
||||||
() => ({
|
() => ({
|
||||||
id,
|
id,
|
||||||
ref: control,
|
ref: control,
|
||||||
|
|||||||
+24
-14
@@ -1,4 +1,14 @@
|
|||||||
import React from 'react'
|
import {
|
||||||
|
createContext,
|
||||||
|
Fragment,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useId,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
|
||||||
type Component = React.ReactElement
|
type Component = React.ReactElement
|
||||||
|
|
||||||
@@ -9,32 +19,32 @@ type ContextType = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ComponentMap = {
|
type ComponentMap = {
|
||||||
[id: string]: Component
|
[id: string]: Component | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createPortalGroup() {
|
export function createPortalGroup() {
|
||||||
const Context = React.createContext<ContextType>({
|
const Context = createContext<ContextType>({
|
||||||
outlet: null,
|
outlet: null,
|
||||||
append: () => {},
|
append: () => {},
|
||||||
remove: () => {},
|
remove: () => {},
|
||||||
})
|
})
|
||||||
|
|
||||||
function Provider(props: React.PropsWithChildren<{}>) {
|
function Provider(props: React.PropsWithChildren<{}>) {
|
||||||
const map = React.useRef<ComponentMap>({})
|
const map = useRef<ComponentMap>({})
|
||||||
const [outlet, setOutlet] = React.useState<ContextType['outlet']>(null)
|
const [outlet, setOutlet] = useState<ContextType['outlet']>(null)
|
||||||
|
|
||||||
const append = React.useCallback<ContextType['append']>((id, component) => {
|
const append = useCallback<ContextType['append']>((id, component) => {
|
||||||
if (map.current[id]) return
|
if (map.current[id]) return
|
||||||
map.current[id] = <React.Fragment key={id}>{component}</React.Fragment>
|
map.current[id] = <Fragment key={id}>{component}</Fragment>
|
||||||
setOutlet(<>{Object.values(map.current)}</>)
|
setOutlet(<>{Object.values(map.current)}</>)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const remove = React.useCallback<ContextType['remove']>(id => {
|
const remove = useCallback<ContextType['remove']>(id => {
|
||||||
delete map.current[id]
|
map.current[id] = null
|
||||||
setOutlet(<>{Object.values(map.current)}</>)
|
setOutlet(<>{Object.values(map.current)}</>)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const contextValue = React.useMemo(
|
const contextValue = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
outlet,
|
outlet,
|
||||||
append,
|
append,
|
||||||
@@ -49,14 +59,14 @@ export function createPortalGroup() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Outlet() {
|
function Outlet() {
|
||||||
const ctx = React.useContext(Context)
|
const ctx = useContext(Context)
|
||||||
return ctx.outlet
|
return ctx.outlet
|
||||||
}
|
}
|
||||||
|
|
||||||
function Portal({children}: React.PropsWithChildren<{}>) {
|
function Portal({children}: React.PropsWithChildren<{}>) {
|
||||||
const {append, remove} = React.useContext(Context)
|
const {append, remove} = useContext(Context)
|
||||||
const id = React.useId()
|
const id = useId()
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
append(id, children as Component)
|
append(id, children as Component)
|
||||||
return () => remove(id)
|
return () => remove(id)
|
||||||
}, [id, children, append, remove])
|
}, [id, children, append, remove])
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {type Dimensions} from './types'
|
|||||||
|
|
||||||
export async function compressIfNeeded(
|
export async function compressIfNeeded(
|
||||||
img: PickerImage,
|
img: PickerImage,
|
||||||
maxSize: number = 1000000,
|
maxSize: number = POST_IMG_MAX.size,
|
||||||
): Promise<PickerImage> {
|
): Promise<PickerImage> {
|
||||||
if (img.size < maxSize) {
|
if (img.size < maxSize) {
|
||||||
return img
|
return img
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
/// <reference lib="dom" />
|
||||||
|
|
||||||
import {type PickerImage} from './picker.shared'
|
import {type PickerImage} from './picker.shared'
|
||||||
import {type Dimensions} from './types'
|
import {type Dimensions} from './types'
|
||||||
import {blobToDataUri, getDataUriSize} from './util'
|
import {blobToDataUri, getDataUriSize} from './util'
|
||||||
|
|||||||
@@ -1,25 +1,21 @@
|
|||||||
import {
|
import {
|
||||||
type ImagePickerOptions,
|
type ImagePickerOptions,
|
||||||
launchImageLibraryAsync,
|
launchImageLibraryAsync,
|
||||||
MediaTypeOptions,
|
|
||||||
} from 'expo-image-picker'
|
} from 'expo-image-picker'
|
||||||
import {t} from '@lingui/macro'
|
import {t} from '@lingui/macro'
|
||||||
|
|
||||||
|
import {type ImageMeta} from '#/state/gallery'
|
||||||
import * as Toast from '#/view/com/util/Toast'
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
import {getDataUriSize} from './util'
|
import {getDataUriSize} from './util'
|
||||||
|
|
||||||
export type PickerImage = {
|
export type PickerImage = ImageMeta & {
|
||||||
mime: string
|
|
||||||
height: number
|
|
||||||
width: number
|
|
||||||
path: string
|
|
||||||
size: number
|
size: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openPicker(opts?: ImagePickerOptions) {
|
export async function openPicker(opts?: ImagePickerOptions) {
|
||||||
const response = await launchImageLibraryAsync({
|
const response = await launchImageLibraryAsync({
|
||||||
exif: false,
|
exif: false,
|
||||||
mediaTypes: MediaTypeOptions.Images,
|
mediaTypes: ['images'],
|
||||||
quality: 1,
|
quality: 1,
|
||||||
...opts,
|
...opts,
|
||||||
legacy: true,
|
legacy: true,
|
||||||
|
|||||||
@@ -1,35 +1,18 @@
|
|||||||
/// <reference lib="dom" />
|
|
||||||
|
|
||||||
import {type OpenCropperOptions} from 'expo-image-crop-tool'
|
import {type OpenCropperOptions} from 'expo-image-crop-tool'
|
||||||
|
|
||||||
import {unstable__openModal} from '#/state/modals'
|
|
||||||
import {type PickerImage} from './picker.shared'
|
import {type PickerImage} from './picker.shared'
|
||||||
import {type CameraOpts} from './types'
|
import {type CameraOpts} from './types'
|
||||||
|
|
||||||
export {openPicker, type PickerImage as RNImage} from './picker.shared'
|
export {openPicker} from './picker.shared'
|
||||||
|
|
||||||
export async function openCamera(_opts: CameraOpts): Promise<PickerImage> {
|
export async function openCamera(_opts: CameraOpts): Promise<PickerImage> {
|
||||||
// const mediaType = opts.mediaType || 'photo' TODO
|
throw new Error('openCamera is not supported on web')
|
||||||
throw new Error('TODO')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openCropper(
|
export async function openCropper(
|
||||||
opts: OpenCropperOptions,
|
_opts: OpenCropperOptions,
|
||||||
): Promise<PickerImage> {
|
): Promise<PickerImage> {
|
||||||
// TODO handle more opts
|
throw new Error(
|
||||||
return new Promise((resolve, reject) => {
|
'openCropper is not supported on web. Use EditImageDialog instead.',
|
||||||
unstable__openModal({
|
)
|
||||||
name: 'crop-image',
|
|
||||||
uri: opts.imageUri,
|
|
||||||
aspect: opts.aspectRatio,
|
|
||||||
circular: opts.shape === 'circle',
|
|
||||||
onSelect: (img?: PickerImage) => {
|
|
||||||
if (img) {
|
|
||||||
resolve(img)
|
|
||||||
} else {
|
|
||||||
reject(new Error('Canceled'))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,11 @@ import {msg, Plural, Trans} from '@lingui/macro'
|
|||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
import {urls} from '#/lib/constants'
|
import {urls} from '#/lib/constants'
|
||||||
import {compressIfNeeded} from '#/lib/media/manip'
|
|
||||||
import {type PickerImage} from '#/lib/media/picker.shared'
|
|
||||||
import {cleanError} from '#/lib/strings/errors'
|
import {cleanError} from '#/lib/strings/errors'
|
||||||
import {useWarnMaxGraphemeCount} from '#/lib/strings/helpers'
|
import {useWarnMaxGraphemeCount} from '#/lib/strings/helpers'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {isWeb} from '#/platform/detection'
|
import {isWeb} from '#/platform/detection'
|
||||||
|
import {type ImageMeta} from '#/state/gallery'
|
||||||
import {useProfileUpdateMutation} from '#/state/queries/profile'
|
import {useProfileUpdateMutation} from '#/state/queries/profile'
|
||||||
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
|
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
|
||||||
import * as Toast from '#/view/com/util/Toast'
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
@@ -18,10 +17,11 @@ import {EditableUserAvatar} from '#/view/com/util/UserAvatar'
|
|||||||
import {UserBanner} from '#/view/com/util/UserBanner'
|
import {UserBanner} from '#/view/com/util/UserBanner'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {Admonition} from '#/components/Admonition'
|
import {Admonition} from '#/components/Admonition'
|
||||||
import {Button, ButtonText} from '#/components/Button'
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
import * as Dialog from '#/components/Dialog'
|
import * as Dialog from '#/components/Dialog'
|
||||||
import * as TextField from '#/components/forms/TextField'
|
import * as TextField from '#/components/forms/TextField'
|
||||||
import {InlineLinkText} from '#/components/Link'
|
import {InlineLinkText} from '#/components/Link'
|
||||||
|
import {Loader} from '#/components/Loader'
|
||||||
import * as Prompt from '#/components/Prompt'
|
import * as Prompt from '#/components/Prompt'
|
||||||
import {useSimpleVerificationState} from '#/components/verification'
|
import {useSimpleVerificationState} from '#/components/verification'
|
||||||
|
|
||||||
@@ -127,10 +127,10 @@ function DialogInner({
|
|||||||
profile.avatar,
|
profile.avatar,
|
||||||
)
|
)
|
||||||
const [newUserBanner, setNewUserBanner] = useState<
|
const [newUserBanner, setNewUserBanner] = useState<
|
||||||
PickerImage | undefined | null
|
ImageMeta | undefined | null
|
||||||
>()
|
>()
|
||||||
const [newUserAvatar, setNewUserAvatar] = useState<
|
const [newUserAvatar, setNewUserAvatar] = useState<
|
||||||
PickerImage | undefined | null
|
ImageMeta | undefined | null
|
||||||
>()
|
>()
|
||||||
|
|
||||||
const dirty =
|
const dirty =
|
||||||
@@ -144,7 +144,7 @@ function DialogInner({
|
|||||||
}, [dirty, setDirty])
|
}, [dirty, setDirty])
|
||||||
|
|
||||||
const onSelectNewAvatar = useCallback(
|
const onSelectNewAvatar = useCallback(
|
||||||
async (img: PickerImage | null) => {
|
(img: ImageMeta | null) => {
|
||||||
setImageError('')
|
setImageError('')
|
||||||
if (img === null) {
|
if (img === null) {
|
||||||
setNewUserAvatar(null)
|
setNewUserAvatar(null)
|
||||||
@@ -152,9 +152,8 @@ function DialogInner({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const finalImg = await compressIfNeeded(img, 1000000)
|
setNewUserAvatar(img)
|
||||||
setNewUserAvatar(finalImg)
|
setUserAvatar(img.path)
|
||||||
setUserAvatar(finalImg.path)
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setImageError(cleanError(e))
|
setImageError(cleanError(e))
|
||||||
}
|
}
|
||||||
@@ -163,7 +162,7 @@ function DialogInner({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const onSelectNewBanner = useCallback(
|
const onSelectNewBanner = useCallback(
|
||||||
async (img: PickerImage | null) => {
|
(img: ImageMeta | null) => {
|
||||||
setImageError('')
|
setImageError('')
|
||||||
if (!img) {
|
if (!img) {
|
||||||
setNewUserBanner(null)
|
setNewUserBanner(null)
|
||||||
@@ -171,9 +170,8 @@ function DialogInner({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const finalImg = await compressIfNeeded(img, 1000000)
|
setNewUserBanner(img)
|
||||||
setNewUserBanner(finalImg)
|
setUserBanner(img.path)
|
||||||
setUserBanner(finalImg.path)
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setImageError(cleanError(e))
|
setImageError(cleanError(e))
|
||||||
}
|
}
|
||||||
@@ -258,6 +256,7 @@ function DialogInner({
|
|||||||
<ButtonText style={[a.text_md, !dirty && t.atoms.text_contrast_low]}>
|
<ButtonText style={[a.text_md, !dirty && t.atoms.text_contrast_low]}>
|
||||||
<Trans>Save</Trans>
|
<Trans>Save</Trans>
|
||||||
</ButtonText>
|
</ButtonText>
|
||||||
|
{isUpdatingProfile && <ButtonIcon icon={Loader} />}
|
||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import React, {memo, useMemo} from 'react'
|
import React, {memo, useMemo} from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {
|
import {
|
||||||
AppBskyActorDefs,
|
type AppBskyActorDefs,
|
||||||
AppBskyLabelerDefs,
|
type AppBskyLabelerDefs,
|
||||||
moderateProfile,
|
moderateProfile,
|
||||||
ModerationOpts,
|
type ModerationOpts,
|
||||||
RichText as RichTextAPI,
|
type RichText as RichTextAPI,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {msg, Plural, plural, Trans} from '@lingui/macro'
|
import {msg, Plural, plural, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
@@ -15,10 +15,9 @@ import {MAX_LABELERS} from '#/lib/constants'
|
|||||||
import {useHaptics} from '#/lib/haptics'
|
import {useHaptics} from '#/lib/haptics'
|
||||||
import {isAppLabeler} from '#/lib/moderation'
|
import {isAppLabeler} from '#/lib/moderation'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {isIOS, isWeb} 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 {type 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, useDialogControl} from '#/components/Dialog'
|
import {type 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,
|
||||||
@@ -117,19 +116,7 @@ let ProfileHeaderLabeler = ({
|
|||||||
}
|
}
|
||||||
}, [labeler, playHaptic, likeUri, unlikeMod, likeMod, _])
|
}, [labeler, playHaptic, likeUri, unlikeMod, likeMod, _])
|
||||||
|
|
||||||
const {openModal} = useModalControls()
|
|
||||||
const editProfileControl = useDialogControl()
|
const editProfileControl = useDialogControl()
|
||||||
const onPressEditProfile = React.useCallback(() => {
|
|
||||||
if (isWeb) {
|
|
||||||
// temp, while we figure out the nested dialog bug
|
|
||||||
openModal({
|
|
||||||
name: 'edit-profile',
|
|
||||||
profile,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
editProfileControl.open()
|
|
||||||
}
|
|
||||||
}, [editProfileControl, openModal, profile])
|
|
||||||
|
|
||||||
const onPressSubscribe = React.useCallback(
|
const onPressSubscribe = React.useCallback(
|
||||||
() =>
|
() =>
|
||||||
@@ -192,7 +179,7 @@ let ProfileHeaderLabeler = ({
|
|||||||
size="small"
|
size="small"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
variant="solid"
|
variant="solid"
|
||||||
onPress={onPressEditProfile}
|
onPress={editProfileControl.open}
|
||||||
label={_(msg`Edit profile`)}
|
label={_(msg`Edit profile`)}
|
||||||
style={a.rounded_full}>
|
style={a.rounded_full}>
|
||||||
<ButtonText>
|
<ButtonText>
|
||||||
|
|||||||
@@ -12,10 +12,9 @@ import {useLingui} from '@lingui/react'
|
|||||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {isIOS, isWeb} from '#/platform/detection'
|
import {isIOS} from '#/platform/detection'
|
||||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||||
import {type Shadow} from '#/state/cache/types'
|
import {type Shadow} from '#/state/cache/types'
|
||||||
import {useModalControls} from '#/state/modals'
|
|
||||||
import {
|
import {
|
||||||
useProfileBlockMutationQueue,
|
useProfileBlockMutationQueue,
|
||||||
useProfileFollowMutationQueue,
|
useProfileFollowMutationQueue,
|
||||||
@@ -78,19 +77,7 @@ let ProfileHeaderStandard = ({
|
|||||||
profile.viewer?.blockedBy ||
|
profile.viewer?.blockedBy ||
|
||||||
profile.viewer?.blockingByList
|
profile.viewer?.blockingByList
|
||||||
|
|
||||||
const {openModal} = useModalControls()
|
|
||||||
const editProfileControl = useDialogControl()
|
const editProfileControl = useDialogControl()
|
||||||
const onPressEditProfile = React.useCallback(() => {
|
|
||||||
if (isWeb) {
|
|
||||||
// temp, while we figure out the nested dialog bug
|
|
||||||
openModal({
|
|
||||||
name: 'edit-profile',
|
|
||||||
profile,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
editProfileControl.open()
|
|
||||||
}
|
|
||||||
}, [editProfileControl, openModal, profile])
|
|
||||||
|
|
||||||
const onPressFollow = () => {
|
const onPressFollow = () => {
|
||||||
requireAuth(async () => {
|
requireAuth(async () => {
|
||||||
@@ -178,7 +165,7 @@ let ProfileHeaderStandard = ({
|
|||||||
size="small"
|
size="small"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
variant="solid"
|
variant="solid"
|
||||||
onPress={onPressEditProfile}
|
onPress={editProfileControl.open}
|
||||||
label={_(msg`Edit profile`)}
|
label={_(msg`Edit profile`)}
|
||||||
style={[a.rounded_full]}>
|
style={[a.rounded_full]}>
|
||||||
<ButtonText>
|
<ButtonText>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {nanoid} from 'nanoid/non-secure'
|
|||||||
import {POST_IMG_MAX} from '#/lib/constants'
|
import {POST_IMG_MAX} from '#/lib/constants'
|
||||||
import {getImageDim} from '#/lib/media/manip'
|
import {getImageDim} from '#/lib/media/manip'
|
||||||
import {openCropper} from '#/lib/media/picker'
|
import {openCropper} from '#/lib/media/picker'
|
||||||
|
import {type PickerImage} from '#/lib/media/picker.shared'
|
||||||
import {getDataUriSize} from '#/lib/media/util'
|
import {getDataUriSize} from '#/lib/media/util'
|
||||||
import {isNative} from '#/platform/detection'
|
import {isNative} from '#/platform/detection'
|
||||||
|
|
||||||
@@ -194,7 +195,7 @@ export function resetImageManipulation(
|
|||||||
return img
|
return img
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function compressImage(img: ComposerImage): Promise<ImageMeta> {
|
export async function compressImage(img: ComposerImage): Promise<PickerImage> {
|
||||||
const source = img.transformed || img.source
|
const source = img.transformed || img.source
|
||||||
|
|
||||||
const [w, h] = containImageRes(source.width, source.height, POST_IMG_MAX)
|
const [w, h] = containImageRes(source.width, source.height, POST_IMG_MAX)
|
||||||
@@ -219,14 +220,15 @@ export async function compressImage(img: ComposerImage): Promise<ImageMeta> {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const base64 = res.base64
|
const base64 = res.base64
|
||||||
|
const size = base64 ? getDataUriSize(base64) : 0
|
||||||
if (base64 !== undefined && getDataUriSize(base64) <= POST_IMG_MAX.size) {
|
if (base64 && size <= POST_IMG_MAX.size) {
|
||||||
minQualityPercentage = qualityPercentage
|
minQualityPercentage = qualityPercentage
|
||||||
newDataUri = {
|
newDataUri = {
|
||||||
path: await moveIfNecessary(res.uri),
|
path: await moveIfNecessary(res.uri),
|
||||||
width: res.width,
|
width: res.width,
|
||||||
height: res.height,
|
height: res.height,
|
||||||
mime: 'image/jpeg',
|
mime: 'image/jpeg',
|
||||||
|
size,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
maxQualityPercentage = qualityPercentage
|
maxQualityPercentage = qualityPercentage
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {type AppBskyActorDefs, type AppBskyGraphDefs} from '@atproto/api'
|
import {type AppBskyGraphDefs} from '@atproto/api'
|
||||||
|
|
||||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||||
import {type PickerImage} from '#/lib/media/picker.shared'
|
|
||||||
|
|
||||||
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'
|
||||||
@@ -26,15 +19,6 @@ export interface UserAddRemoveListsModal {
|
|||||||
onRemove?: (listUri: string) => void
|
onRemove?: (listUri: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CropImageModal {
|
|
||||||
name: 'crop-image'
|
|
||||||
uri: string
|
|
||||||
dimensions?: {width: number; height: number}
|
|
||||||
aspect?: number
|
|
||||||
circular?: boolean
|
|
||||||
onSelect: (img?: PickerImage) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeleteAccountModal {
|
export interface DeleteAccountModal {
|
||||||
name: 'delete-account'
|
name: 'delete-account'
|
||||||
}
|
}
|
||||||
@@ -71,9 +55,6 @@ export type Modal =
|
|||||||
| DeleteAccountModal
|
| DeleteAccountModal
|
||||||
| ChangePasswordModal
|
| ChangePasswordModal
|
||||||
|
|
||||||
// Temp
|
|
||||||
| EditProfileModal
|
|
||||||
|
|
||||||
// Curation
|
// Curation
|
||||||
| ContentLanguagesSettingsModal
|
| ContentLanguagesSettingsModal
|
||||||
| PostLanguagesSettingsModal
|
| PostLanguagesSettingsModal
|
||||||
@@ -82,9 +63,6 @@ export type Modal =
|
|||||||
| CreateOrEditListModal
|
| CreateOrEditListModal
|
||||||
| UserAddRemoveListsModal
|
| UserAddRemoveListsModal
|
||||||
|
|
||||||
// Posts
|
|
||||||
| CropImageModal
|
|
||||||
|
|
||||||
// Bluesky access
|
// Bluesky access
|
||||||
| WaitlistModal
|
| WaitlistModal
|
||||||
| InviteCodesModal
|
| InviteCodesModal
|
||||||
@@ -110,20 +88,6 @@ const ModalControlContext = React.createContext<{
|
|||||||
closeAllModals: () => false,
|
closeAllModals: () => false,
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated DO NOT USE THIS unless you have no other choice.
|
|
||||||
*/
|
|
||||||
export let unstable__openModal: (modal: Modal) => void = () => {
|
|
||||||
throw new Error(`ModalContext is not initialized`)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated DO NOT USE THIS unless you have no other choice.
|
|
||||||
*/
|
|
||||||
export let unstable__closeModal: () => boolean = () => {
|
|
||||||
throw new Error(`ModalContext is not initialized`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||||
const [activeModals, setActiveModals] = React.useState<Modal[]>([])
|
const [activeModals, setActiveModals] = React.useState<Modal[]>([])
|
||||||
|
|
||||||
@@ -145,9 +109,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
return wasActive
|
return wasActive
|
||||||
})
|
})
|
||||||
|
|
||||||
unstable__openModal = openModal
|
|
||||||
unstable__closeModal = closeModal
|
|
||||||
|
|
||||||
const state = React.useMemo(
|
const state = React.useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
isModalActive: activeModals.length > 0,
|
isModalActive: activeModals.length > 0,
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ import chunk from 'lodash.chunk'
|
|||||||
|
|
||||||
import {uploadBlob} from '#/lib/api'
|
import {uploadBlob} from '#/lib/api'
|
||||||
import {until} from '#/lib/async/until'
|
import {until} from '#/lib/async/until'
|
||||||
import {type PickerImage} from '#/lib/media/picker.shared'
|
import {type ImageMeta} from '#/state/gallery'
|
||||||
import {STALE} from '#/state/queries'
|
import {STALE} from '#/state/queries'
|
||||||
import {useAgent, useSession} from '../session'
|
import {useAgent, useSession} from '#/state/session'
|
||||||
import {invalidate as invalidateMyLists} from './my-lists'
|
import {invalidate as invalidateMyLists} from './my-lists'
|
||||||
import {RQKEY as PROFILE_LISTS_RQKEY} from './profile-lists'
|
import {RQKEY as PROFILE_LISTS_RQKEY} from './profile-lists'
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ export interface ListCreateMutateParams {
|
|||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
descriptionFacets: Facet[] | undefined
|
descriptionFacets: Facet[] | undefined
|
||||||
avatar: PickerImage | null | undefined
|
avatar: ImageMeta | null | undefined
|
||||||
}
|
}
|
||||||
export function useListCreateMutation() {
|
export function useListCreateMutation() {
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
@@ -115,7 +115,7 @@ export interface ListMetadataMutateParams {
|
|||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
descriptionFacets: Facet[] | undefined
|
descriptionFacets: Facet[] | undefined
|
||||||
avatar: PickerImage | null | undefined
|
avatar: ImageMeta | null | undefined
|
||||||
}
|
}
|
||||||
export function useListMetadataMutation() {
|
export function useListMetadataMutation() {
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
|
|||||||
@@ -20,9 +20,10 @@ import {
|
|||||||
import {uploadBlob} from '#/lib/api'
|
import {uploadBlob} from '#/lib/api'
|
||||||
import {until} from '#/lib/async/until'
|
import {until} from '#/lib/async/until'
|
||||||
import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue'
|
import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue'
|
||||||
import {type PickerImage} from '#/lib/media/picker.shared'
|
|
||||||
import {logEvent, type LogEvents, toClout} from '#/lib/statsig/statsig'
|
import {logEvent, type LogEvents, toClout} from '#/lib/statsig/statsig'
|
||||||
|
import {updateProfileShadow} from '#/state/cache/profile-shadow'
|
||||||
import {type Shadow} from '#/state/cache/types'
|
import {type Shadow} from '#/state/cache/types'
|
||||||
|
import {type ImageMeta} from '#/state/gallery'
|
||||||
import {STALE} from '#/state/queries'
|
import {STALE} from '#/state/queries'
|
||||||
import {resetProfilePostsQueries} from '#/state/queries/post-feed'
|
import {resetProfilePostsQueries} from '#/state/queries/post-feed'
|
||||||
import {
|
import {
|
||||||
@@ -30,10 +31,9 @@ import {
|
|||||||
useUnstableProfileViewCache,
|
useUnstableProfileViewCache,
|
||||||
} from '#/state/queries/unstable-profile-cache'
|
} from '#/state/queries/unstable-profile-cache'
|
||||||
import {useUpdateProfileVerificationCache} from '#/state/queries/verification/useUpdateProfileVerificationCache'
|
import {useUpdateProfileVerificationCache} from '#/state/queries/verification/useUpdateProfileVerificationCache'
|
||||||
|
import {useAgent, useSession} from '#/state/session'
|
||||||
import * as userActionHistory from '#/state/userActionHistory'
|
import * as userActionHistory from '#/state/userActionHistory'
|
||||||
import type * as bsky from '#/types/bsky'
|
import type * as bsky from '#/types/bsky'
|
||||||
import {updateProfileShadow} from '../cache/profile-shadow'
|
|
||||||
import {useAgent, useSession} from '../session'
|
|
||||||
import {
|
import {
|
||||||
ProgressGuideAction,
|
ProgressGuideAction,
|
||||||
useProgressGuideControls,
|
useProgressGuideControls,
|
||||||
@@ -131,8 +131,8 @@ interface ProfileUpdateParams {
|
|||||||
| ((
|
| ((
|
||||||
existing: Un$Typed<AppBskyActorProfile.Record>,
|
existing: Un$Typed<AppBskyActorProfile.Record>,
|
||||||
) => Un$Typed<AppBskyActorProfile.Record>)
|
) => Un$Typed<AppBskyActorProfile.Record>)
|
||||||
newUserAvatar?: PickerImage | undefined | null
|
newUserAvatar?: ImageMeta | undefined | null
|
||||||
newUserBanner?: PickerImage | undefined | null
|
newUserBanner?: ImageMeta | undefined | null
|
||||||
checkCommitted?: (res: AppBskyActorGetProfile.Response) => boolean
|
checkCommitted?: (res: AppBskyActorGetProfile.Response) => boolean
|
||||||
}
|
}
|
||||||
export function useProfileUpdateMutation() {
|
export function useProfileUpdateMutation() {
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import React from 'react'
|
import type React from 'react'
|
||||||
|
|
||||||
import {ComposerImage} from '#/state/gallery'
|
import {type ComposerImage} from '#/state/gallery'
|
||||||
import * as Dialog from '#/components/Dialog'
|
import type * as Dialog from '#/components/Dialog'
|
||||||
|
|
||||||
export type EditImageDialogProps = {
|
export type EditImageDialogProps = {
|
||||||
control: Dialog.DialogOuterProps['control']
|
control: Dialog.DialogOuterProps['control']
|
||||||
image: ComposerImage
|
image?: ComposerImage
|
||||||
onChange: (next: ComposerImage) => void
|
onChange: (next: ComposerImage) => void
|
||||||
|
aspectRatio?: number
|
||||||
|
circularCrop?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EditImageDialog = ({}: EditImageDialogProps): React.ReactNode => {
|
export const EditImageDialog = ({}: EditImageDialogProps): React.ReactNode => {
|
||||||
|
|||||||
@@ -1,43 +1,130 @@
|
|||||||
import 'react-image-crop/dist/ReactCrop.css'
|
import 'react-image-crop/dist/ReactCrop.css'
|
||||||
|
|
||||||
import React from 'react'
|
import {useCallback, useImperativeHandle, useRef, useState} from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import ReactCrop, {PercentCrop} from 'react-image-crop'
|
import ReactCrop, {type PercentCrop} from 'react-image-crop'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ImageSource,
|
type ImageSource,
|
||||||
ImageTransformation,
|
type ImageTransformation,
|
||||||
manipulateImage,
|
manipulateImage,
|
||||||
} from '#/state/gallery'
|
} from '#/state/gallery'
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {Button, ButtonText} from '#/components/Button'
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
import * as Dialog from '#/components/Dialog'
|
import * as Dialog from '#/components/Dialog'
|
||||||
import {Text} from '#/components/Typography'
|
import {Loader} from '#/components/Loader'
|
||||||
import {EditImageDialogProps} from './EditImageDialog'
|
import {type EditImageDialogProps} from './EditImageDialog'
|
||||||
|
|
||||||
export const EditImageDialog = (props: EditImageDialogProps) => {
|
export function EditImageDialog(props: EditImageDialogProps) {
|
||||||
return (
|
return (
|
||||||
<Dialog.Outer control={props.control}>
|
<Dialog.Outer control={props.control}>
|
||||||
<Dialog.Handle />
|
<Dialog.Handle />
|
||||||
<EditImageInner key={props.image.source.id} {...props} />
|
<DialogInner {...props} />
|
||||||
</Dialog.Outer>
|
</Dialog.Outer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const EditImageInner = ({control, image, onChange}: EditImageDialogProps) => {
|
function DialogInner({
|
||||||
|
control,
|
||||||
|
image,
|
||||||
|
onChange,
|
||||||
|
circularCrop,
|
||||||
|
aspectRatio,
|
||||||
|
}: EditImageDialogProps) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
|
const [pending, setPending] = useState(false)
|
||||||
|
const ref = useRef<{save: () => Promise<void>}>(null)
|
||||||
|
|
||||||
|
const cancelButton = useCallback(
|
||||||
|
() => (
|
||||||
|
<Button
|
||||||
|
label={_(msg`Cancel`)}
|
||||||
|
disabled={pending}
|
||||||
|
onPress={() => control.close()}
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
variant="ghost"
|
||||||
|
style={[a.rounded_full]}
|
||||||
|
testID="cropImageCancelBtn">
|
||||||
|
<ButtonText style={[a.text_md]}>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
[control, _, pending],
|
||||||
|
)
|
||||||
|
|
||||||
|
const saveButton = useCallback(
|
||||||
|
() => (
|
||||||
|
<Button
|
||||||
|
label={_(msg`Save`)}
|
||||||
|
onPress={async () => {
|
||||||
|
setPending(true)
|
||||||
|
await ref.current?.save()
|
||||||
|
setPending(false)
|
||||||
|
}}
|
||||||
|
disabled={pending}
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
variant="ghost"
|
||||||
|
style={[a.rounded_full]}
|
||||||
|
testID="cropImageSaveBtn">
|
||||||
|
<ButtonText style={[a.text_md]}>
|
||||||
|
<Trans>Save</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
{pending && <ButtonIcon icon={Loader} />}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
[_, pending],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.Inner
|
||||||
|
label={_(msg`Edit image`)}
|
||||||
|
header={
|
||||||
|
<Dialog.Header renderLeft={cancelButton} renderRight={saveButton}>
|
||||||
|
<Dialog.HeaderText>
|
||||||
|
<Trans>Edit image</Trans>
|
||||||
|
</Dialog.HeaderText>
|
||||||
|
</Dialog.Header>
|
||||||
|
}>
|
||||||
|
{image && (
|
||||||
|
<EditImageInner
|
||||||
|
saveRef={ref}
|
||||||
|
key={image.source.id}
|
||||||
|
image={image}
|
||||||
|
onChange={onChange}
|
||||||
|
circularCrop={circularCrop}
|
||||||
|
aspectRatio={aspectRatio}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Dialog.Inner>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditImageInner({
|
||||||
|
image,
|
||||||
|
onChange,
|
||||||
|
saveRef,
|
||||||
|
circularCrop = false,
|
||||||
|
aspectRatio,
|
||||||
|
}: Required<Pick<EditImageDialogProps, 'image'>> &
|
||||||
|
Omit<EditImageDialogProps, 'control' | 'image'> & {
|
||||||
|
saveRef: React.RefObject<{save: () => Promise<void>}>
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
|
const {_} = useLingui()
|
||||||
|
const control = Dialog.useDialogContext()
|
||||||
|
|
||||||
const source = image.source
|
const source = image.source
|
||||||
|
|
||||||
const initialCrop = getInitialCrop(source, image.manips)
|
const initialCrop = getInitialCrop(source, image.manips)
|
||||||
const [crop, setCrop] = React.useState(initialCrop)
|
const [crop, setCrop] = useState(initialCrop)
|
||||||
|
|
||||||
const isEmpty = !crop || (crop.width || crop.height) === 0
|
const onPressSubmit = useCallback(async () => {
|
||||||
const isNew = initialCrop ? true : !isEmpty
|
|
||||||
|
|
||||||
const onPressSubmit = React.useCallback(async () => {
|
|
||||||
const result = await manipulateImage(image, {
|
const result = await manipulateImage(image, {
|
||||||
crop:
|
crop:
|
||||||
crop && (crop.width || crop.height) !== 0
|
crop && (crop.width || crop.height) !== 0
|
||||||
@@ -50,41 +137,43 @@ const EditImageInner = ({control, image, onChange}: EditImageDialogProps) => {
|
|||||||
: undefined,
|
: undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
onChange(result)
|
control.close(() => {
|
||||||
control.close()
|
onChange(result)
|
||||||
|
})
|
||||||
}, [crop, image, source, control, onChange])
|
}, [crop, image, source, control, onChange])
|
||||||
|
|
||||||
|
useImperativeHandle(
|
||||||
|
saveRef,
|
||||||
|
() => ({
|
||||||
|
save: onPressSubmit,
|
||||||
|
}),
|
||||||
|
[onPressSubmit],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog.Inner label={_(msg`Edit image`)}>
|
<View
|
||||||
<Dialog.Close />
|
style={[
|
||||||
|
a.mx_auto,
|
||||||
<Text style={[a.text_2xl, a.font_bold, a.leading_tight, a.pb_sm]}>
|
a.border,
|
||||||
<Trans>Edit image</Trans>
|
t.atoms.border_contrast_low,
|
||||||
</Text>
|
a.rounded_xs,
|
||||||
|
a.overflow_hidden,
|
||||||
<View style={[a.align_center]}>
|
a.align_center,
|
||||||
<ReactCrop
|
]}>
|
||||||
crop={crop}
|
<ReactCrop
|
||||||
onChange={(_pixelCrop, percentCrop) => setCrop(percentCrop)}
|
crop={crop}
|
||||||
className="ReactCrop--no-animate">
|
aspect={aspectRatio}
|
||||||
<img src={source.path} style={{maxHeight: `50vh`}} />
|
circularCrop={circularCrop}
|
||||||
</ReactCrop>
|
onChange={(_pixelCrop, percentCrop) => setCrop(percentCrop)}
|
||||||
</View>
|
className="ReactCrop--no-animate"
|
||||||
|
onDragStart={() => setIsDragging(true)}
|
||||||
<View style={[a.mt_md, a.gap_md]}>
|
onDragEnd={() => setIsDragging(false)}>
|
||||||
<Button
|
<img src={source.path} style={{maxHeight: `50vh`}} />
|
||||||
disabled={!isNew}
|
</ReactCrop>
|
||||||
label={_(msg`Save`)}
|
{/* Eat clicks when dragging, otherwise mousing up over the backdrop
|
||||||
size="large"
|
causes the dialog to close */}
|
||||||
color="primary"
|
{isDragging && <View style={[a.fixed, a.inset_0]} />}
|
||||||
variant="solid"
|
</View>
|
||||||
onPress={onPressSubmit}>
|
|
||||||
<ButtonText>
|
|
||||||
<Trans>Save</Trans>
|
|
||||||
</ButtonText>
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</Dialog.Inner>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,24 +15,23 @@ import {useLingui} from '@lingui/react'
|
|||||||
|
|
||||||
import {usePalette} from '#/lib/hooks/usePalette'
|
import {usePalette} from '#/lib/hooks/usePalette'
|
||||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||||
import {compressIfNeeded} from '#/lib/media/manip'
|
|
||||||
import {type PickerImage} from '#/lib/media/picker.shared'
|
|
||||||
import {cleanError, isNetworkError} from '#/lib/strings/errors'
|
import {cleanError, isNetworkError} from '#/lib/strings/errors'
|
||||||
import {enforceLen} from '#/lib/strings/helpers'
|
import {enforceLen} from '#/lib/strings/helpers'
|
||||||
import {richTextToString} from '#/lib/strings/rich-text-helpers'
|
import {richTextToString} from '#/lib/strings/rich-text-helpers'
|
||||||
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
|
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
|
||||||
import {colors, gradients, s} from '#/lib/styles'
|
import {colors, gradients, s} from '#/lib/styles'
|
||||||
import {useTheme} from '#/lib/ThemeContext'
|
import {useTheme} from '#/lib/ThemeContext'
|
||||||
|
import {type ImageMeta} from '#/state/gallery'
|
||||||
import {useModalControls} from '#/state/modals'
|
import {useModalControls} from '#/state/modals'
|
||||||
import {
|
import {
|
||||||
useListCreateMutation,
|
useListCreateMutation,
|
||||||
useListMetadataMutation,
|
useListMetadataMutation,
|
||||||
} from '#/state/queries/list'
|
} from '#/state/queries/list'
|
||||||
import {useAgent} from '#/state/session'
|
import {useAgent} from '#/state/session'
|
||||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
|
||||||
import {Text} from '../util/text/Text'
|
import {Text} from '#/view/com/util/text/Text'
|
||||||
import * as Toast from '../util/Toast'
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
import {EditableUserAvatar} from '../util/UserAvatar'
|
import {EditableUserAvatar} from '#/view/com/util/UserAvatar'
|
||||||
|
|
||||||
const MAX_NAME = 64 // todo
|
const MAX_NAME = 64 // todo
|
||||||
const MAX_DESCRIPTION = 300 // todo
|
const MAX_DESCRIPTION = 300 // todo
|
||||||
@@ -95,7 +94,7 @@ export function Component({
|
|||||||
const isDescriptionOver = graphemeLength > MAX_DESCRIPTION
|
const isDescriptionOver = graphemeLength > MAX_DESCRIPTION
|
||||||
|
|
||||||
const [avatar, setAvatar] = useState<string | undefined>(list?.avatar)
|
const [avatar, setAvatar] = useState<string | undefined>(list?.avatar)
|
||||||
const [newAvatar, setNewAvatar] = useState<PickerImage | undefined | null>()
|
const [newAvatar, setNewAvatar] = useState<ImageMeta | undefined | null>()
|
||||||
|
|
||||||
const onDescriptionChange = useCallback(
|
const onDescriptionChange = useCallback(
|
||||||
(newText: string) => {
|
(newText: string) => {
|
||||||
@@ -112,16 +111,15 @@ export function Component({
|
|||||||
}, [closeModal])
|
}, [closeModal])
|
||||||
|
|
||||||
const onSelectNewAvatar = useCallback(
|
const onSelectNewAvatar = useCallback(
|
||||||
async (img: PickerImage | null) => {
|
(img: ImageMeta | null) => {
|
||||||
if (!img) {
|
if (!img) {
|
||||||
setNewAvatar(null)
|
setNewAvatar(null)
|
||||||
setAvatar(undefined)
|
setAvatar(undefined)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const finalImg = await compressIfNeeded(img, 1000000)
|
setNewAvatar(img)
|
||||||
setNewAvatar(finalImg)
|
setAvatar(img.path)
|
||||||
setAvatar(finalImg.path)
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(cleanError(e))
|
setError(cleanError(e))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop'
|
|||||||
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 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'
|
||||||
@@ -48,10 +47,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') {
|
||||||
|
|||||||
@@ -8,9 +8,7 @@ import {type Modal as ModalIface} from '#/state/modals'
|
|||||||
import {useModalControls, useModals} from '#/state/modals'
|
import {useModalControls, useModals} from '#/state/modals'
|
||||||
import * as ChangePasswordModal from './ChangePassword'
|
import * as ChangePasswordModal from './ChangePassword'
|
||||||
import * as CreateOrEditListModal from './CreateOrEditList'
|
import * as CreateOrEditListModal from './CreateOrEditList'
|
||||||
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'
|
||||||
@@ -45,9 +43,6 @@ function Modal({modal}: {modal: ModalIface}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onPressMask = () => {
|
const onPressMask = () => {
|
||||||
if (modal.name === 'crop-image') {
|
|
||||||
return // dont close on mask presses during crop
|
|
||||||
}
|
|
||||||
closeModal()
|
closeModal()
|
||||||
}
|
}
|
||||||
const onInnerPress = () => {
|
const onInnerPress = () => {
|
||||||
@@ -56,14 +51,10 @@ 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} />
|
||||||
} else if (modal.name === 'crop-image') {
|
|
||||||
element = <CropImageModal.Component {...modal} />
|
|
||||||
} else if (modal.name === 'delete-account') {
|
} else if (modal.name === 'delete-account') {
|
||||||
element = <DeleteAccountModal.Component />
|
element = <DeleteAccountModal.Component />
|
||||||
} else if (modal.name === 'invite-codes') {
|
} else if (modal.name === 'invite-codes') {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react'
|
import {View, type ViewStyle} from 'react-native'
|
||||||
import {View, ViewStyle} from 'react-native'
|
import type React from 'react'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This utility function captures events and stops
|
* This utility function captures events and stops
|
||||||
|
|||||||
+148
-105
@@ -1,4 +1,4 @@
|
|||||||
import React, {memo, useMemo} from 'react'
|
import React, {memo, useCallback, useMemo, useState} from 'react'
|
||||||
import {
|
import {
|
||||||
Image,
|
Image,
|
||||||
Pressable,
|
Pressable,
|
||||||
@@ -14,36 +14,38 @@ import {msg, Trans} from '@lingui/macro'
|
|||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {useQueryClient} from '@tanstack/react-query'
|
import {useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
import {usePalette} from '#/lib/hooks/usePalette'
|
|
||||||
import {
|
import {
|
||||||
useCameraPermission,
|
useCameraPermission,
|
||||||
usePhotoLibraryPermission,
|
usePhotoLibraryPermission,
|
||||||
} from '#/lib/hooks/usePermissions'
|
} from '#/lib/hooks/usePermissions'
|
||||||
|
import {compressIfNeeded} from '#/lib/media/manip'
|
||||||
|
import {openCamera, openCropper, openPicker} from '#/lib/media/picker'
|
||||||
|
import {type PickerImage} from '#/lib/media/picker.shared'
|
||||||
import {makeProfileLink} from '#/lib/routes/links'
|
import {makeProfileLink} from '#/lib/routes/links'
|
||||||
import {colors} from '#/lib/styles'
|
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {isAndroid, isNative, isWeb} from '#/platform/detection'
|
import {isAndroid, isNative, isWeb} from '#/platform/detection'
|
||||||
import {precacheProfile} from '#/state/queries/profile'
|
import {
|
||||||
|
type ComposerImage,
|
||||||
|
compressImage,
|
||||||
|
createComposerImage,
|
||||||
|
} from '#/state/gallery'
|
||||||
|
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
|
||||||
|
import {EditImageDialog} from '#/view/com/composer/photos/EditImageDialog'
|
||||||
import {HighPriorityImage} from '#/view/com/util/images/Image'
|
import {HighPriorityImage} from '#/view/com/util/images/Image'
|
||||||
import {tokens, useTheme} from '#/alf'
|
import {atoms as a, tokens, useTheme} from '#/alf'
|
||||||
|
import {useDialogControl} from '#/components/Dialog'
|
||||||
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
|
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
|
||||||
import {
|
import {
|
||||||
Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled,
|
Camera_Filled_Stroke2_Corner0_Rounded as CameraFilledIcon,
|
||||||
Camera_Stroke2_Corner0_Rounded as Camera,
|
Camera_Stroke2_Corner0_Rounded as CameraIcon,
|
||||||
} from '#/components/icons/Camera'
|
} from '#/components/icons/Camera'
|
||||||
import {StreamingLive_Stroke2_Corner0_Rounded as Library} from '#/components/icons/StreamingLive'
|
import {StreamingLive_Stroke2_Corner0_Rounded as LibraryIcon} from '#/components/icons/StreamingLive'
|
||||||
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
|
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
|
||||||
import {Link} from '#/components/Link'
|
import {Link} from '#/components/Link'
|
||||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||||
import * as Menu from '#/components/Menu'
|
import * as Menu from '#/components/Menu'
|
||||||
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
|
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
|
||||||
import type * as bsky from '#/types/bsky'
|
import type * as bsky from '#/types/bsky'
|
||||||
import {
|
|
||||||
openCamera,
|
|
||||||
openCropper,
|
|
||||||
openPicker,
|
|
||||||
type RNImage,
|
|
||||||
} from '../../../lib/media/picker'
|
|
||||||
|
|
||||||
export type UserAvatarType = 'user' | 'algo' | 'list' | 'labeler'
|
export type UserAvatarType = 'user' | 'algo' | 'list' | 'labeler'
|
||||||
|
|
||||||
@@ -63,7 +65,7 @@ interface UserAvatarProps extends BaseUserAvatarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface EditableUserAvatarProps extends BaseUserAvatarProps {
|
interface EditableUserAvatarProps extends BaseUserAvatarProps {
|
||||||
onSelectNewAvatar: (img: RNImage | null) => void
|
onSelectNewAvatar: (img: PickerImage | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PreviewableUserAvatarProps extends BaseUserAvatarProps {
|
interface PreviewableUserAvatarProps extends BaseUserAvatarProps {
|
||||||
@@ -195,8 +197,8 @@ let UserAvatar = ({
|
|||||||
onLoad,
|
onLoad,
|
||||||
style,
|
style,
|
||||||
}: UserAvatarProps): React.ReactNode => {
|
}: UserAvatarProps): React.ReactNode => {
|
||||||
const pal = usePalette('default')
|
const t = useTheme()
|
||||||
const backgroundColor = pal.colors.backgroundLight
|
const backgroundColor = t.palette.contrast_25
|
||||||
const finalShape = overrideShape ?? (type === 'user' ? 'circle' : 'square')
|
const finalShape = overrideShape ?? (type === 'user' ? 'circle' : 'square')
|
||||||
|
|
||||||
const aviStyle = useMemo(() => {
|
const aviStyle = useMemo(() => {
|
||||||
@@ -221,15 +223,22 @@ let UserAvatar = ({
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<View style={[styles.alertIconContainer, pal.view]}>
|
<View
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
a.right_0,
|
||||||
|
a.bottom_0,
|
||||||
|
a.rounded_full,
|
||||||
|
{backgroundColor: t.palette.white},
|
||||||
|
]}>
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
icon="exclamation-circle"
|
icon="exclamation-circle"
|
||||||
style={styles.alertIcon}
|
style={{color: t.palette.negative_400}}
|
||||||
size={Math.floor(size / 3)}
|
size={Math.floor(size / 3)}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}, [moderation?.alert, size, pal])
|
}, [moderation?.alert, size, t])
|
||||||
|
|
||||||
const containerStyle = useMemo(() => {
|
const containerStyle = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
@@ -288,14 +297,18 @@ let EditableUserAvatar = ({
|
|||||||
onSelectNewAvatar,
|
onSelectNewAvatar,
|
||||||
}: EditableUserAvatarProps): React.ReactNode => {
|
}: EditableUserAvatarProps): React.ReactNode => {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const pal = usePalette('default')
|
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {requestCameraAccessIfNeeded} = useCameraPermission()
|
const {requestCameraAccessIfNeeded} = useCameraPermission()
|
||||||
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
|
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
|
||||||
|
const [rawImage, setRawImage] = useState<ComposerImage | undefined>()
|
||||||
|
const editImageDialogControl = useDialogControl()
|
||||||
|
|
||||||
const sheetWrapper = useSheetWrapper()
|
const sheetWrapper = useSheetWrapper()
|
||||||
|
|
||||||
|
const circular = type !== 'algo' && type !== 'list'
|
||||||
|
|
||||||
const aviStyle = useMemo(() => {
|
const aviStyle = useMemo(() => {
|
||||||
if (type === 'algo' || type === 'list') {
|
if (!circular) {
|
||||||
return {
|
return {
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
@@ -307,7 +320,7 @@ let EditableUserAvatar = ({
|
|||||||
height: size,
|
height: size,
|
||||||
borderRadius: Math.floor(size / 2),
|
borderRadius: Math.floor(size / 2),
|
||||||
}
|
}
|
||||||
}, [type, size])
|
}, [circular, size])
|
||||||
|
|
||||||
const onOpenCamera = React.useCallback(async () => {
|
const onOpenCamera = React.useCallback(async () => {
|
||||||
if (!(await requestCameraAccessIfNeeded())) {
|
if (!(await requestCameraAccessIfNeeded())) {
|
||||||
@@ -315,9 +328,11 @@ let EditableUserAvatar = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
onSelectNewAvatar(
|
onSelectNewAvatar(
|
||||||
await openCamera({
|
await compressIfNeeded(
|
||||||
aspect: [1, 1],
|
await openCamera({
|
||||||
}),
|
aspect: [1, 1],
|
||||||
|
}),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}, [onSelectNewAvatar, requestCameraAccessIfNeeded])
|
}, [onSelectNewAvatar, requestCameraAccessIfNeeded])
|
||||||
|
|
||||||
@@ -337,91 +352,129 @@ let EditableUserAvatar = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const croppedImage = await openCropper({
|
if (isNative) {
|
||||||
imageUri: item.path,
|
onSelectNewAvatar(
|
||||||
shape: 'circle',
|
await compressIfNeeded(
|
||||||
aspectRatio: 1,
|
await openCropper({
|
||||||
})
|
imageUri: item.path,
|
||||||
onSelectNewAvatar(croppedImage)
|
shape: circular ? 'circle' : 'rectangle',
|
||||||
|
aspectRatio: 1,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
setRawImage(await createComposerImage(item))
|
||||||
|
editImageDialogControl.open()
|
||||||
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
// Don't log errors for cancelling selection to sentry on ios or android
|
// Don't log errors for cancelling selection to sentry on ios or android
|
||||||
if (!String(e).toLowerCase().includes('cancel')) {
|
if (!String(e).toLowerCase().includes('cancel')) {
|
||||||
logger.error('Failed to crop banner', {error: e})
|
logger.error('Failed to crop banner', {error: e})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [onSelectNewAvatar, requestPhotoAccessIfNeeded, sheetWrapper])
|
}, [
|
||||||
|
onSelectNewAvatar,
|
||||||
|
requestPhotoAccessIfNeeded,
|
||||||
|
sheetWrapper,
|
||||||
|
editImageDialogControl,
|
||||||
|
circular,
|
||||||
|
])
|
||||||
|
|
||||||
const onRemoveAvatar = React.useCallback(() => {
|
const onRemoveAvatar = React.useCallback(() => {
|
||||||
onSelectNewAvatar(null)
|
onSelectNewAvatar(null)
|
||||||
}, [onSelectNewAvatar])
|
}, [onSelectNewAvatar])
|
||||||
|
|
||||||
return (
|
const onChangeEditImage = useCallback(
|
||||||
<Menu.Root>
|
async (image: ComposerImage) => {
|
||||||
<Menu.Trigger label={_(msg`Edit avatar`)}>
|
const compressed = await compressImage(image)
|
||||||
{({props}) => (
|
onSelectNewAvatar(compressed)
|
||||||
<Pressable {...props} testID="changeAvatarBtn">
|
},
|
||||||
{avatar ? (
|
[onSelectNewAvatar],
|
||||||
<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>
|
|
||||||
</Pressable>
|
|
||||||
)}
|
|
||||||
</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
|
return (
|
||||||
testID="changeAvatarLibraryBtn"
|
<>
|
||||||
label={_(msg`Upload from Library`)}
|
<Menu.Root>
|
||||||
onPress={onOpenLibrary}>
|
<Menu.Trigger label={_(msg`Edit avatar`)}>
|
||||||
<Menu.ItemText>
|
{({props}) => (
|
||||||
{isNative ? (
|
<Pressable {...props} testID="changeAvatarBtn">
|
||||||
<Trans>Upload from Library</Trans>
|
{avatar ? (
|
||||||
|
<HighPriorityImage
|
||||||
|
testID="userAvatarImage"
|
||||||
|
style={aviStyle}
|
||||||
|
source={{uri: avatar}}
|
||||||
|
accessibilityRole="image"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Trans>Upload from Files</Trans>
|
<DefaultAvatar type={type} size={size} />
|
||||||
)}
|
)}
|
||||||
</Menu.ItemText>
|
<View
|
||||||
<Menu.ItemIcon icon={Library} />
|
style={[
|
||||||
</Menu.Item>
|
styles.editButtonContainer,
|
||||||
</Menu.Group>
|
t.atoms.bg_contrast_25,
|
||||||
{!!avatar && (
|
a.border,
|
||||||
<>
|
t.atoms.border_contrast_low,
|
||||||
<Menu.Divider />
|
]}>
|
||||||
<Menu.Group>
|
<CameraFilledIcon height={14} width={14} style={t.atoms.text} />
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</Menu.Trigger>
|
||||||
|
<Menu.Outer showCancel>
|
||||||
|
<Menu.Group>
|
||||||
|
{isNative && (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
testID="changeAvatarRemoveBtn"
|
testID="changeAvatarCameraBtn"
|
||||||
label={_(msg`Remove Avatar`)}
|
label={_(msg`Upload from Camera`)}
|
||||||
onPress={onRemoveAvatar}>
|
onPress={onOpenCamera}>
|
||||||
<Menu.ItemText>
|
<Menu.ItemText>
|
||||||
<Trans>Remove Avatar</Trans>
|
<Trans>Upload from Camera</Trans>
|
||||||
</Menu.ItemText>
|
</Menu.ItemText>
|
||||||
<Menu.ItemIcon icon={Trash} />
|
<Menu.ItemIcon icon={CameraIcon} />
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu.Group>
|
)}
|
||||||
</>
|
|
||||||
)}
|
<Menu.Item
|
||||||
</Menu.Outer>
|
testID="changeAvatarLibraryBtn"
|
||||||
</Menu.Root>
|
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={LibraryIcon} />
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Group>
|
||||||
|
{!!avatar && (
|
||||||
|
<>
|
||||||
|
<Menu.Divider />
|
||||||
|
<Menu.Group>
|
||||||
|
<Menu.Item
|
||||||
|
testID="changeAvatarRemoveBtn"
|
||||||
|
label={_(msg`Remove Avatar`)}
|
||||||
|
onPress={onRemoveAvatar}>
|
||||||
|
<Menu.ItemText>
|
||||||
|
<Trans>Remove Avatar</Trans>
|
||||||
|
</Menu.ItemText>
|
||||||
|
<Menu.ItemIcon icon={TrashIcon} />
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Group>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Menu.Outer>
|
||||||
|
</Menu.Root>
|
||||||
|
|
||||||
|
<EditImageDialog
|
||||||
|
control={editImageDialogControl}
|
||||||
|
image={rawImage}
|
||||||
|
onChange={onChangeEditImage}
|
||||||
|
aspectRatio={1}
|
||||||
|
circularCrop={circular}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
EditableUserAvatar = memo(EditableUserAvatar)
|
EditableUserAvatar = memo(EditableUserAvatar)
|
||||||
@@ -440,7 +493,7 @@ let PreviewableUserAvatar = ({
|
|||||||
|
|
||||||
const onPress = React.useCallback(() => {
|
const onPress = React.useCallback(() => {
|
||||||
onBeforePress?.()
|
onBeforePress?.()
|
||||||
precacheProfile(queryClient, profile)
|
unstableCacheProfileView(queryClient, profile)
|
||||||
}, [profile, queryClient, onBeforePress])
|
}, [profile, queryClient, onBeforePress])
|
||||||
|
|
||||||
const avatarEl = (
|
const avatarEl = (
|
||||||
@@ -494,15 +547,5 @@ const styles = StyleSheet.create({
|
|||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
backgroundColor: colors.gray5,
|
|
||||||
},
|
|
||||||
alertIconContainer: {
|
|
||||||
position: 'absolute',
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
borderRadius: 100,
|
|
||||||
},
|
|
||||||
alertIcon: {
|
|
||||||
color: colors.red3,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
+141
-103
@@ -1,35 +1,36 @@
|
|||||||
import React from 'react'
|
import {useCallback, useState} from 'react'
|
||||||
import {Pressable, StyleSheet, View} from 'react-native'
|
import {Pressable, StyleSheet, View} from 'react-native'
|
||||||
import {Image} from 'expo-image'
|
import {Image} from 'expo-image'
|
||||||
import {type ModerationUI} from '@atproto/api'
|
import {type ModerationUI} from '@atproto/api'
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
import {usePalette} from '#/lib/hooks/usePalette'
|
|
||||||
import {
|
import {
|
||||||
useCameraPermission,
|
useCameraPermission,
|
||||||
usePhotoLibraryPermission,
|
usePhotoLibraryPermission,
|
||||||
} from '#/lib/hooks/usePermissions'
|
} from '#/lib/hooks/usePermissions'
|
||||||
import {colors} from '#/lib/styles'
|
import {compressIfNeeded} from '#/lib/media/manip'
|
||||||
import {useTheme} from '#/lib/ThemeContext'
|
import {openCamera, openCropper, openPicker} from '#/lib/media/picker'
|
||||||
|
import {type PickerImage} from '#/lib/media/picker.shared'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {isAndroid, isNative} from '#/platform/detection'
|
import {isAndroid, isNative} from '#/platform/detection'
|
||||||
|
import {
|
||||||
|
type ComposerImage,
|
||||||
|
compressImage,
|
||||||
|
createComposerImage,
|
||||||
|
} from '#/state/gallery'
|
||||||
|
import {EditImageDialog} from '#/view/com/composer/photos/EditImageDialog'
|
||||||
import {EventStopper} from '#/view/com/util/EventStopper'
|
import {EventStopper} from '#/view/com/util/EventStopper'
|
||||||
import {tokens, useTheme as useAlfTheme} from '#/alf'
|
import {atoms as a, tokens, useTheme} from '#/alf'
|
||||||
|
import {useDialogControl} from '#/components/Dialog'
|
||||||
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
|
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
|
||||||
import {
|
import {
|
||||||
Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled,
|
Camera_Filled_Stroke2_Corner0_Rounded as CameraFilledIcon,
|
||||||
Camera_Stroke2_Corner0_Rounded as Camera,
|
Camera_Stroke2_Corner0_Rounded as CameraIcon,
|
||||||
} from '#/components/icons/Camera'
|
} from '#/components/icons/Camera'
|
||||||
import {StreamingLive_Stroke2_Corner0_Rounded as Library} from '#/components/icons/StreamingLive'
|
import {StreamingLive_Stroke2_Corner0_Rounded as LibraryIcon} from '#/components/icons/StreamingLive'
|
||||||
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
|
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
|
||||||
import * as Menu from '#/components/Menu'
|
import * as Menu from '#/components/Menu'
|
||||||
import {
|
|
||||||
openCamera,
|
|
||||||
openCropper,
|
|
||||||
openPicker,
|
|
||||||
type RNImage,
|
|
||||||
} from '../../../lib/media/picker'
|
|
||||||
|
|
||||||
export function UserBanner({
|
export function UserBanner({
|
||||||
type,
|
type,
|
||||||
@@ -40,28 +41,30 @@ export function UserBanner({
|
|||||||
type?: 'labeler' | 'default'
|
type?: 'labeler' | 'default'
|
||||||
banner?: string | null
|
banner?: string | null
|
||||||
moderation?: ModerationUI
|
moderation?: ModerationUI
|
||||||
onSelectNewBanner?: (img: RNImage | null) => void
|
onSelectNewBanner?: (img: PickerImage | null) => void
|
||||||
}) {
|
}) {
|
||||||
const pal = usePalette('default')
|
const t = useTheme()
|
||||||
const theme = useTheme()
|
|
||||||
const t = useAlfTheme()
|
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {requestCameraAccessIfNeeded} = useCameraPermission()
|
const {requestCameraAccessIfNeeded} = useCameraPermission()
|
||||||
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
|
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
|
||||||
const sheetWrapper = useSheetWrapper()
|
const sheetWrapper = useSheetWrapper()
|
||||||
|
const [rawImage, setRawImage] = useState<ComposerImage | undefined>()
|
||||||
|
const editImageDialogControl = useDialogControl()
|
||||||
|
|
||||||
const onOpenCamera = React.useCallback(async () => {
|
const onOpenCamera = useCallback(async () => {
|
||||||
if (!(await requestCameraAccessIfNeeded())) {
|
if (!(await requestCameraAccessIfNeeded())) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
onSelectNewBanner?.(
|
onSelectNewBanner?.(
|
||||||
await openCamera({
|
await compressIfNeeded(
|
||||||
aspect: [3, 1],
|
await openCamera({
|
||||||
}),
|
aspect: [3, 1],
|
||||||
|
}),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}, [onSelectNewBanner, requestCameraAccessIfNeeded])
|
}, [onSelectNewBanner, requestCameraAccessIfNeeded])
|
||||||
|
|
||||||
const onOpenLibrary = React.useCallback(async () => {
|
const onOpenLibrary = useCallback(async () => {
|
||||||
if (!(await requestPhotoAccessIfNeeded())) {
|
if (!(await requestPhotoAccessIfNeeded())) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -71,105 +74,141 @@ export function UserBanner({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
onSelectNewBanner?.(
|
if (isNative) {
|
||||||
await openCropper({
|
onSelectNewBanner?.(
|
||||||
imageUri: items[0].path,
|
await compressIfNeeded(
|
||||||
aspectRatio: 3 / 1,
|
await openCropper({
|
||||||
}),
|
imageUri: items[0].path,
|
||||||
)
|
aspectRatio: 3 / 1,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
setRawImage(await createComposerImage(items[0]))
|
||||||
|
editImageDialogControl.open()
|
||||||
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (!String(e).includes('Canceled')) {
|
if (!String(e).includes('Canceled')) {
|
||||||
logger.error('Failed to crop banner', {error: e})
|
logger.error('Failed to crop banner', {error: e})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [onSelectNewBanner, requestPhotoAccessIfNeeded, sheetWrapper])
|
}, [
|
||||||
|
onSelectNewBanner,
|
||||||
|
requestPhotoAccessIfNeeded,
|
||||||
|
sheetWrapper,
|
||||||
|
editImageDialogControl,
|
||||||
|
])
|
||||||
|
|
||||||
const onRemoveBanner = React.useCallback(() => {
|
const onRemoveBanner = useCallback(() => {
|
||||||
onSelectNewBanner?.(null)
|
onSelectNewBanner?.(null)
|
||||||
}, [onSelectNewBanner])
|
}, [onSelectNewBanner])
|
||||||
|
|
||||||
|
const onChangeEditImage = useCallback(
|
||||||
|
async (image: ComposerImage) => {
|
||||||
|
const compressed = await compressImage(image)
|
||||||
|
onSelectNewBanner?.(compressed)
|
||||||
|
},
|
||||||
|
[onSelectNewBanner],
|
||||||
|
)
|
||||||
|
|
||||||
// setUserBanner is only passed as prop on the EditProfile component
|
// setUserBanner is only passed as prop on the EditProfile component
|
||||||
return onSelectNewBanner ? (
|
return onSelectNewBanner ? (
|
||||||
<EventStopper onKeyDown={true}>
|
<>
|
||||||
<Menu.Root>
|
<EventStopper onKeyDown={true}>
|
||||||
<Menu.Trigger label={_(msg`Edit avatar`)}>
|
<Menu.Root>
|
||||||
{({props}) => (
|
<Menu.Trigger label={_(msg`Edit avatar`)}>
|
||||||
<Pressable {...props} testID="changeBannerBtn">
|
{({props}) => (
|
||||||
{banner ? (
|
<Pressable {...props} testID="changeBannerBtn">
|
||||||
<Image
|
{banner ? (
|
||||||
testID="userBannerImage"
|
<Image
|
||||||
style={styles.bannerImage}
|
testID="userBannerImage"
|
||||||
source={{uri: banner}}
|
style={styles.bannerImage}
|
||||||
accessible={true}
|
source={{uri: banner}}
|
||||||
accessibilityIgnoresInvertColors
|
accessible={true}
|
||||||
/>
|
accessibilityIgnoresInvertColors
|
||||||
) : (
|
/>
|
||||||
<View
|
|
||||||
testID="userBannerFallback"
|
|
||||||
style={[styles.bannerImage, t.atoms.bg_contrast_25]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<View style={[styles.editButtonContainer, pal.btn]}>
|
|
||||||
<CameraFilled height={14} width={14} style={t.atoms.text} />
|
|
||||||
</View>
|
|
||||||
</Pressable>
|
|
||||||
)}
|
|
||||||
</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>
|
<View
|
||||||
|
testID="userBannerFallback"
|
||||||
|
style={[styles.bannerImage, t.atoms.bg_contrast_25]}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Menu.ItemText>
|
<View
|
||||||
<Menu.ItemIcon icon={Library} />
|
style={[
|
||||||
</Menu.Item>
|
styles.editButtonContainer,
|
||||||
</Menu.Group>
|
t.atoms.bg_contrast_25,
|
||||||
{!!banner && (
|
a.border,
|
||||||
<>
|
t.atoms.border_contrast_low,
|
||||||
<Menu.Divider />
|
]}>
|
||||||
<Menu.Group>
|
<CameraFilledIcon
|
||||||
|
height={14}
|
||||||
|
width={14}
|
||||||
|
style={t.atoms.text}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</Menu.Trigger>
|
||||||
|
<Menu.Outer showCancel>
|
||||||
|
<Menu.Group>
|
||||||
|
{isNative && (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
testID="changeBannerRemoveBtn"
|
testID="changeBannerCameraBtn"
|
||||||
label={_(msg`Remove Banner`)}
|
label={_(msg`Upload from Camera`)}
|
||||||
onPress={onRemoveBanner}>
|
onPress={onOpenCamera}>
|
||||||
<Menu.ItemText>
|
<Menu.ItemText>
|
||||||
<Trans>Remove Banner</Trans>
|
<Trans>Upload from Camera</Trans>
|
||||||
</Menu.ItemText>
|
</Menu.ItemText>
|
||||||
<Menu.ItemIcon icon={Trash} />
|
<Menu.ItemIcon icon={CameraIcon} />
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu.Group>
|
)}
|
||||||
</>
|
|
||||||
)}
|
<Menu.Item
|
||||||
</Menu.Outer>
|
testID="changeBannerLibraryBtn"
|
||||||
</Menu.Root>
|
label={_(msg`Upload from Library`)}
|
||||||
</EventStopper>
|
onPress={onOpenLibrary}>
|
||||||
|
<Menu.ItemText>
|
||||||
|
{isNative ? (
|
||||||
|
<Trans>Upload from Library</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>Upload from Files</Trans>
|
||||||
|
)}
|
||||||
|
</Menu.ItemText>
|
||||||
|
<Menu.ItemIcon icon={LibraryIcon} />
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Group>
|
||||||
|
{!!banner && (
|
||||||
|
<>
|
||||||
|
<Menu.Divider />
|
||||||
|
<Menu.Group>
|
||||||
|
<Menu.Item
|
||||||
|
testID="changeBannerRemoveBtn"
|
||||||
|
label={_(msg`Remove Banner`)}
|
||||||
|
onPress={onRemoveBanner}>
|
||||||
|
<Menu.ItemText>
|
||||||
|
<Trans>Remove Banner</Trans>
|
||||||
|
</Menu.ItemText>
|
||||||
|
<Menu.ItemIcon icon={TrashIcon} />
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Group>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Menu.Outer>
|
||||||
|
</Menu.Root>
|
||||||
|
</EventStopper>
|
||||||
|
|
||||||
|
<EditImageDialog
|
||||||
|
control={editImageDialogControl}
|
||||||
|
image={rawImage}
|
||||||
|
onChange={onChangeEditImage}
|
||||||
|
aspectRatio={3}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
) : banner &&
|
) : banner &&
|
||||||
!((moderation?.blur && isAndroid) /* android crashes with blur */) ? (
|
!((moderation?.blur && isAndroid) /* android crashes with blur */) ? (
|
||||||
<Image
|
<Image
|
||||||
testID="userBannerImage"
|
testID="userBannerImage"
|
||||||
style={[
|
style={[styles.bannerImage, t.atoms.bg_contrast_25]}
|
||||||
styles.bannerImage,
|
|
||||||
{backgroundColor: theme.palette.default.backgroundLight},
|
|
||||||
]}
|
|
||||||
contentFit="cover"
|
contentFit="cover"
|
||||||
source={{uri: banner}}
|
source={{uri: banner}}
|
||||||
blurRadius={moderation?.blur ? 100 : 0}
|
blurRadius={moderation?.blur ? 100 : 0}
|
||||||
@@ -197,7 +236,6 @@ const styles = StyleSheet.create({
|
|||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
backgroundColor: colors.gray5,
|
|
||||||
},
|
},
|
||||||
bannerImage: {
|
bannerImage: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
|||||||
Reference in New Issue
Block a user