New Edit Profile dialog on web, use new Edit Image dialog everywhere (#8220)

This commit is contained in:
Samuel Newman
2025-05-06 22:50:28 +03:00
committed by GitHub
parent 3f7dc9a8e5
commit 4c8fd006f6
21 changed files with 532 additions and 441 deletions
+14 -7
View File
@@ -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
View File
@@ -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])
+1 -1
View File
@@ -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
+2
View File
@@ -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'
+3 -7
View File
@@ -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,
+6 -23
View File
@@ -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>
+5 -3
View File
@@ -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 -40
View File
@@ -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,
+4 -4
View File
@@ -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()
+5 -5
View File
@@ -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,
}) })
control.close(() => {
onChange(result) onChange(result)
control.close() })
}, [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 <ReactCrop
crop={crop} crop={crop}
aspect={aspectRatio}
circularCrop={circularCrop}
onChange={(_pixelCrop, percentCrop) => setCrop(percentCrop)} onChange={(_pixelCrop, percentCrop) => setCrop(percentCrop)}
className="ReactCrop--no-animate"> className="ReactCrop--no-animate"
onDragStart={() => setIsDragging(true)}
onDragEnd={() => setIsDragging(false)}>
<img src={source.path} style={{maxHeight: `50vh`}} /> <img src={source.path} style={{maxHeight: `50vh`}} />
</ReactCrop> </ReactCrop>
{/* Eat clicks when dragging, otherwise mousing up over the backdrop
causes the dialog to close */}
{isDragging && <View style={[a.fixed, a.inset_0]} />}
</View> </View>
<View style={[a.mt_md, a.gap_md]}>
<Button
disabled={!isNew}
label={_(msg`Save`)}
size="large"
color="primary"
variant="solid"
onPress={onPressSubmit}>
<ButtonText>
<Trans>Save</Trans>
</ButtonText>
</Button>
</View>
</Dialog.Inner>
) )
} }
+9 -11
View File
@@ -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))
} }
+1 -5
View File
@@ -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') {
+1 -10
View File
@@ -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') {
+2 -2
View File
@@ -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
+88 -45
View File
@@ -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 compressIfNeeded(
await openCamera({ await openCamera({
aspect: [1, 1], aspect: [1, 1],
}), }),
),
) )
}, [onSelectNewAvatar, requestCameraAccessIfNeeded]) }, [onSelectNewAvatar, requestCameraAccessIfNeeded])
@@ -337,25 +352,48 @@ let EditableUserAvatar = ({
} }
try { try {
const croppedImage = await openCropper({ if (isNative) {
onSelectNewAvatar(
await compressIfNeeded(
await openCropper({
imageUri: item.path, imageUri: item.path,
shape: 'circle', shape: circular ? 'circle' : 'rectangle',
aspectRatio: 1, aspectRatio: 1,
}) }),
onSelectNewAvatar(croppedImage) ),
)
} 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])
const onChangeEditImage = useCallback(
async (image: ComposerImage) => {
const compressed = await compressImage(image)
onSelectNewAvatar(compressed)
},
[onSelectNewAvatar],
)
return ( return (
<>
<Menu.Root> <Menu.Root>
<Menu.Trigger label={_(msg`Edit avatar`)}> <Menu.Trigger label={_(msg`Edit avatar`)}>
{({props}) => ( {({props}) => (
@@ -370,8 +408,14 @@ let EditableUserAvatar = ({
) : ( ) : (
<DefaultAvatar type={type} size={size} /> <DefaultAvatar type={type} size={size} />
)} )}
<View style={[styles.editButtonContainer, pal.btn]}> <View
<CameraFilled height={14} width={14} style={t.atoms.text} /> style={[
styles.editButtonContainer,
t.atoms.bg_contrast_25,
a.border,
t.atoms.border_contrast_low,
]}>
<CameraFilledIcon height={14} width={14} style={t.atoms.text} />
</View> </View>
</Pressable> </Pressable>
)} )}
@@ -386,7 +430,7 @@ let EditableUserAvatar = ({
<Menu.ItemText> <Menu.ItemText>
<Trans>Upload from Camera</Trans> <Trans>Upload from Camera</Trans>
</Menu.ItemText> </Menu.ItemText>
<Menu.ItemIcon icon={Camera} /> <Menu.ItemIcon icon={CameraIcon} />
</Menu.Item> </Menu.Item>
)} )}
@@ -401,7 +445,7 @@ let EditableUserAvatar = ({
<Trans>Upload from Files</Trans> <Trans>Upload from Files</Trans>
)} )}
</Menu.ItemText> </Menu.ItemText>
<Menu.ItemIcon icon={Library} /> <Menu.ItemIcon icon={LibraryIcon} />
</Menu.Item> </Menu.Item>
</Menu.Group> </Menu.Group>
{!!avatar && ( {!!avatar && (
@@ -415,13 +459,22 @@ let EditableUserAvatar = ({
<Menu.ItemText> <Menu.ItemText>
<Trans>Remove Avatar</Trans> <Trans>Remove Avatar</Trans>
</Menu.ItemText> </Menu.ItemText>
<Menu.ItemIcon icon={Trash} /> <Menu.ItemIcon icon={TrashIcon} />
</Menu.Item> </Menu.Item>
</Menu.Group> </Menu.Group>
</> </>
)} )}
</Menu.Outer> </Menu.Outer>
</Menu.Root> </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,
}, },
}) })
+71 -33
View File
@@ -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 compressIfNeeded(
await openCamera({ await openCamera({
aspect: [3, 1], 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,25 +74,46 @@ export function UserBanner({
} }
try { try {
if (isNative) {
onSelectNewBanner?.( onSelectNewBanner?.(
await compressIfNeeded(
await openCropper({ await openCropper({
imageUri: items[0].path, imageUri: items[0].path,
aspectRatio: 3 / 1, 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}> <EventStopper onKeyDown={true}>
<Menu.Root> <Menu.Root>
<Menu.Trigger label={_(msg`Edit avatar`)}> <Menu.Trigger label={_(msg`Edit avatar`)}>
@@ -109,8 +133,18 @@ export function UserBanner({
style={[styles.bannerImage, t.atoms.bg_contrast_25]} style={[styles.bannerImage, t.atoms.bg_contrast_25]}
/> />
)} )}
<View style={[styles.editButtonContainer, pal.btn]}> <View
<CameraFilled height={14} width={14} style={t.atoms.text} /> style={[
styles.editButtonContainer,
t.atoms.bg_contrast_25,
a.border,
t.atoms.border_contrast_low,
]}>
<CameraFilledIcon
height={14}
width={14}
style={t.atoms.text}
/>
</View> </View>
</Pressable> </Pressable>
)} )}
@@ -125,7 +159,7 @@ export function UserBanner({
<Menu.ItemText> <Menu.ItemText>
<Trans>Upload from Camera</Trans> <Trans>Upload from Camera</Trans>
</Menu.ItemText> </Menu.ItemText>
<Menu.ItemIcon icon={Camera} /> <Menu.ItemIcon icon={CameraIcon} />
</Menu.Item> </Menu.Item>
)} )}
@@ -140,7 +174,7 @@ export function UserBanner({
<Trans>Upload from Files</Trans> <Trans>Upload from Files</Trans>
)} )}
</Menu.ItemText> </Menu.ItemText>
<Menu.ItemIcon icon={Library} /> <Menu.ItemIcon icon={LibraryIcon} />
</Menu.Item> </Menu.Item>
</Menu.Group> </Menu.Group>
{!!banner && ( {!!banner && (
@@ -154,7 +188,7 @@ export function UserBanner({
<Menu.ItemText> <Menu.ItemText>
<Trans>Remove Banner</Trans> <Trans>Remove Banner</Trans>
</Menu.ItemText> </Menu.ItemText>
<Menu.ItemIcon icon={Trash} /> <Menu.ItemIcon icon={TrashIcon} />
</Menu.Item> </Menu.Item>
</Menu.Group> </Menu.Group>
</> </>
@@ -162,14 +196,19 @@ export function UserBanner({
</Menu.Outer> </Menu.Outer>
</Menu.Root> </Menu.Root>
</EventStopper> </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%',