Bluesky app fork with some witchin' additions 💫

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

authored by samuel.fm and committed by

GitHub 4c8fd006 3f7dc9a8

+529 -438
+14 -7
src/components/Dialog/context.ts
··· 1 - import React from 'react' 1 + import { 2 + createContext, 3 + useContext, 4 + useEffect, 5 + useId, 6 + useMemo, 7 + useRef, 8 + } from 'react' 2 9 3 10 import {useDialogStateContext} from '#/state/dialogs' 4 11 import { ··· 8 15 } from '#/components/Dialog/types' 9 16 import {BottomSheetSnapPoint} from '../../../modules/bottom-sheet/src/BottomSheet.types' 10 17 11 - export const Context = React.createContext<DialogContextProps>({ 18 + export const Context = createContext<DialogContextProps>({ 12 19 close: () => {}, 13 20 isNativeDialog: false, 14 21 nativeSnapPoint: BottomSheetSnapPoint.Hidden, ··· 18 25 }) 19 26 20 27 export function useDialogContext() { 21 - return React.useContext(Context) 28 + return useContext(Context) 22 29 } 23 30 24 31 export function useDialogControl(): DialogOuterProps['control'] { 25 - const id = React.useId() 26 - const control = React.useRef<DialogControlRefProps>({ 32 + const id = useId() 33 + const control = useRef<DialogControlRefProps>({ 27 34 open: () => {}, 28 35 close: () => {}, 29 36 }) 30 37 const {activeDialogs} = useDialogStateContext() 31 38 32 - React.useEffect(() => { 39 + useEffect(() => { 33 40 activeDialogs.current.set(id, control) 34 41 return () => { 35 42 // eslint-disable-next-line react-hooks/exhaustive-deps ··· 37 44 } 38 45 }, [id, activeDialogs]) 39 46 40 - return React.useMemo<DialogOuterProps['control']>( 47 + return useMemo<DialogOuterProps['control']>( 41 48 () => ({ 42 49 id, 43 50 ref: control,
+24 -14
src/components/Portal.tsx
··· 1 - import React from 'react' 1 + import { 2 + createContext, 3 + Fragment, 4 + useCallback, 5 + useContext, 6 + useEffect, 7 + useId, 8 + useMemo, 9 + useRef, 10 + useState, 11 + } from 'react' 2 12 3 13 type Component = React.ReactElement 4 14 ··· 9 19 } 10 20 11 21 type ComponentMap = { 12 - [id: string]: Component 22 + [id: string]: Component | null 13 23 } 14 24 15 25 export function createPortalGroup() { 16 - const Context = React.createContext<ContextType>({ 26 + const Context = createContext<ContextType>({ 17 27 outlet: null, 18 28 append: () => {}, 19 29 remove: () => {}, 20 30 }) 21 31 22 32 function Provider(props: React.PropsWithChildren<{}>) { 23 - const map = React.useRef<ComponentMap>({}) 24 - const [outlet, setOutlet] = React.useState<ContextType['outlet']>(null) 33 + const map = useRef<ComponentMap>({}) 34 + const [outlet, setOutlet] = useState<ContextType['outlet']>(null) 25 35 26 - const append = React.useCallback<ContextType['append']>((id, component) => { 36 + const append = useCallback<ContextType['append']>((id, component) => { 27 37 if (map.current[id]) return 28 - map.current[id] = <React.Fragment key={id}>{component}</React.Fragment> 38 + map.current[id] = <Fragment key={id}>{component}</Fragment> 29 39 setOutlet(<>{Object.values(map.current)}</>) 30 40 }, []) 31 41 32 - const remove = React.useCallback<ContextType['remove']>(id => { 33 - delete map.current[id] 42 + const remove = useCallback<ContextType['remove']>(id => { 43 + map.current[id] = null 34 44 setOutlet(<>{Object.values(map.current)}</>) 35 45 }, []) 36 46 37 - const contextValue = React.useMemo( 47 + const contextValue = useMemo( 38 48 () => ({ 39 49 outlet, 40 50 append, ··· 49 59 } 50 60 51 61 function Outlet() { 52 - const ctx = React.useContext(Context) 62 + const ctx = useContext(Context) 53 63 return ctx.outlet 54 64 } 55 65 56 66 function Portal({children}: React.PropsWithChildren<{}>) { 57 - const {append, remove} = React.useContext(Context) 58 - const id = React.useId() 59 - React.useEffect(() => { 67 + const {append, remove} = useContext(Context) 68 + const id = useId() 69 + useEffect(() => { 60 70 append(id, children as Component) 61 71 return () => remove(id) 62 72 }, [id, children, append, remove])
+1 -1
src/lib/media/manip.ts
··· 24 24 25 25 export async function compressIfNeeded( 26 26 img: PickerImage, 27 - maxSize: number = 1000000, 27 + maxSize: number = POST_IMG_MAX.size, 28 28 ): Promise<PickerImage> { 29 29 if (img.size < maxSize) { 30 30 return img
+2
src/lib/media/manip.web.ts
··· 1 + /// <reference lib="dom" /> 2 + 1 3 import {type PickerImage} from './picker.shared' 2 4 import {type Dimensions} from './types' 3 5 import {blobToDataUri, getDataUriSize} from './util'
+3 -7
src/lib/media/picker.shared.ts
··· 1 1 import { 2 2 type ImagePickerOptions, 3 3 launchImageLibraryAsync, 4 - MediaTypeOptions, 5 4 } from 'expo-image-picker' 6 5 import {t} from '@lingui/macro' 7 6 7 + import {type ImageMeta} from '#/state/gallery' 8 8 import * as Toast from '#/view/com/util/Toast' 9 9 import {getDataUriSize} from './util' 10 10 11 - export type PickerImage = { 12 - mime: string 13 - height: number 14 - width: number 15 - path: string 11 + export type PickerImage = ImageMeta & { 16 12 size: number 17 13 } 18 14 19 15 export async function openPicker(opts?: ImagePickerOptions) { 20 16 const response = await launchImageLibraryAsync({ 21 17 exif: false, 22 - mediaTypes: MediaTypeOptions.Images, 18 + mediaTypes: ['images'], 23 19 quality: 1, 24 20 ...opts, 25 21 legacy: true,
+6 -23
src/lib/media/picker.web.tsx
··· 1 - /// <reference lib="dom" /> 2 - 3 1 import {type OpenCropperOptions} from 'expo-image-crop-tool' 4 2 5 - import {unstable__openModal} from '#/state/modals' 6 3 import {type PickerImage} from './picker.shared' 7 4 import {type CameraOpts} from './types' 8 5 9 - export {openPicker, type PickerImage as RNImage} from './picker.shared' 6 + export {openPicker} from './picker.shared' 10 7 11 8 export async function openCamera(_opts: CameraOpts): Promise<PickerImage> { 12 - // const mediaType = opts.mediaType || 'photo' TODO 13 - throw new Error('TODO') 9 + throw new Error('openCamera is not supported on web') 14 10 } 15 11 16 12 export async function openCropper( 17 - opts: OpenCropperOptions, 13 + _opts: OpenCropperOptions, 18 14 ): Promise<PickerImage> { 19 - // TODO handle more opts 20 - return new Promise((resolve, reject) => { 21 - unstable__openModal({ 22 - name: 'crop-image', 23 - uri: opts.imageUri, 24 - aspect: opts.aspectRatio, 25 - circular: opts.shape === 'circle', 26 - onSelect: (img?: PickerImage) => { 27 - if (img) { 28 - resolve(img) 29 - } else { 30 - reject(new Error('Canceled')) 31 - } 32 - }, 33 - }) 34 - }) 15 + throw new Error( 16 + 'openCropper is not supported on web. Use EditImageDialog instead.', 17 + ) 35 18 }
+12 -13
src/screens/Profile/Header/EditProfileDialog.tsx
··· 5 5 import {useLingui} from '@lingui/react' 6 6 7 7 import {urls} from '#/lib/constants' 8 - import {compressIfNeeded} from '#/lib/media/manip' 9 - import {type PickerImage} from '#/lib/media/picker.shared' 10 8 import {cleanError} from '#/lib/strings/errors' 11 9 import {useWarnMaxGraphemeCount} from '#/lib/strings/helpers' 12 10 import {logger} from '#/logger' 13 11 import {isWeb} from '#/platform/detection' 12 + import {type ImageMeta} from '#/state/gallery' 14 13 import {useProfileUpdateMutation} from '#/state/queries/profile' 15 14 import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' 16 15 import * as Toast from '#/view/com/util/Toast' ··· 18 17 import {UserBanner} from '#/view/com/util/UserBanner' 19 18 import {atoms as a, useTheme} from '#/alf' 20 19 import {Admonition} from '#/components/Admonition' 21 - import {Button, ButtonText} from '#/components/Button' 20 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 22 21 import * as Dialog from '#/components/Dialog' 23 22 import * as TextField from '#/components/forms/TextField' 24 23 import {InlineLinkText} from '#/components/Link' 24 + import {Loader} from '#/components/Loader' 25 25 import * as Prompt from '#/components/Prompt' 26 26 import {useSimpleVerificationState} from '#/components/verification' 27 27 ··· 127 127 profile.avatar, 128 128 ) 129 129 const [newUserBanner, setNewUserBanner] = useState< 130 - PickerImage | undefined | null 130 + ImageMeta | undefined | null 131 131 >() 132 132 const [newUserAvatar, setNewUserAvatar] = useState< 133 - PickerImage | undefined | null 133 + ImageMeta | undefined | null 134 134 >() 135 135 136 136 const dirty = ··· 144 144 }, [dirty, setDirty]) 145 145 146 146 const onSelectNewAvatar = useCallback( 147 - async (img: PickerImage | null) => { 147 + (img: ImageMeta | null) => { 148 148 setImageError('') 149 149 if (img === null) { 150 150 setNewUserAvatar(null) ··· 152 152 return 153 153 } 154 154 try { 155 - const finalImg = await compressIfNeeded(img, 1000000) 156 - setNewUserAvatar(finalImg) 157 - setUserAvatar(finalImg.path) 155 + setNewUserAvatar(img) 156 + setUserAvatar(img.path) 158 157 } catch (e: any) { 159 158 setImageError(cleanError(e)) 160 159 } ··· 163 162 ) 164 163 165 164 const onSelectNewBanner = useCallback( 166 - async (img: PickerImage | null) => { 165 + (img: ImageMeta | null) => { 167 166 setImageError('') 168 167 if (!img) { 169 168 setNewUserBanner(null) ··· 171 170 return 172 171 } 173 172 try { 174 - const finalImg = await compressIfNeeded(img, 1000000) 175 - setNewUserBanner(finalImg) 176 - setUserBanner(finalImg.path) 173 + setNewUserBanner(img) 174 + setUserBanner(img.path) 177 175 } catch (e: any) { 178 176 setImageError(cleanError(e)) 179 177 } ··· 258 256 <ButtonText style={[a.text_md, !dirty && t.atoms.text_contrast_low]}> 259 257 <Trans>Save</Trans> 260 258 </ButtonText> 259 + {isUpdatingProfile && <ButtonIcon icon={Loader} />} 261 260 </Button> 262 261 ), 263 262 [
+8 -21
src/screens/Profile/Header/ProfileHeaderLabeler.tsx
··· 1 1 import React, {memo, useMemo} from 'react' 2 2 import {View} from 'react-native' 3 3 import { 4 - AppBskyActorDefs, 5 - AppBskyLabelerDefs, 4 + type AppBskyActorDefs, 5 + type AppBskyLabelerDefs, 6 6 moderateProfile, 7 - ModerationOpts, 8 - RichText as RichTextAPI, 7 + type ModerationOpts, 8 + type RichText as RichTextAPI, 9 9 } from '@atproto/api' 10 10 import {msg, Plural, plural, Trans} from '@lingui/macro' 11 11 import {useLingui} from '@lingui/react' ··· 15 15 import {useHaptics} from '#/lib/haptics' 16 16 import {isAppLabeler} from '#/lib/moderation' 17 17 import {logger} from '#/logger' 18 - import {isIOS, isWeb} from '#/platform/detection' 18 + import {isIOS} from '#/platform/detection' 19 19 import {useProfileShadow} from '#/state/cache/profile-shadow' 20 - import {Shadow} from '#/state/cache/types' 21 - import {useModalControls} from '#/state/modals' 20 + import {type Shadow} from '#/state/cache/types' 22 21 import {useLabelerSubscriptionMutation} from '#/state/queries/labeler' 23 22 import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like' 24 23 import {usePreferencesQuery} from '#/state/queries/preferences' ··· 27 26 import * as Toast from '#/view/com/util/Toast' 28 27 import {atoms as a, tokens, useTheme} from '#/alf' 29 28 import {Button, ButtonText} from '#/components/Button' 30 - import {DialogOuterProps, useDialogControl} from '#/components/Dialog' 29 + import {type DialogOuterProps, useDialogControl} from '#/components/Dialog' 31 30 import { 32 31 Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled, 33 32 Heart2_Stroke2_Corner0_Rounded as Heart, ··· 117 116 } 118 117 }, [labeler, playHaptic, likeUri, unlikeMod, likeMod, _]) 119 118 120 - const {openModal} = useModalControls() 121 119 const editProfileControl = useDialogControl() 122 - const onPressEditProfile = React.useCallback(() => { 123 - if (isWeb) { 124 - // temp, while we figure out the nested dialog bug 125 - openModal({ 126 - name: 'edit-profile', 127 - profile, 128 - }) 129 - } else { 130 - editProfileControl.open() 131 - } 132 - }, [editProfileControl, openModal, profile]) 133 120 134 121 const onPressSubscribe = React.useCallback( 135 122 () => ··· 192 179 size="small" 193 180 color="secondary" 194 181 variant="solid" 195 - onPress={onPressEditProfile} 182 + onPress={editProfileControl.open} 196 183 label={_(msg`Edit profile`)} 197 184 style={a.rounded_full}> 198 185 <ButtonText>
+2 -15
src/screens/Profile/Header/ProfileHeaderStandard.tsx
··· 12 12 import {sanitizeDisplayName} from '#/lib/strings/display-names' 13 13 import {sanitizeHandle} from '#/lib/strings/handles' 14 14 import {logger} from '#/logger' 15 - import {isIOS, isWeb} from '#/platform/detection' 15 + import {isIOS} from '#/platform/detection' 16 16 import {useProfileShadow} from '#/state/cache/profile-shadow' 17 17 import {type Shadow} from '#/state/cache/types' 18 - import {useModalControls} from '#/state/modals' 19 18 import { 20 19 useProfileBlockMutationQueue, 21 20 useProfileFollowMutationQueue, ··· 78 77 profile.viewer?.blockedBy || 79 78 profile.viewer?.blockingByList 80 79 81 - const {openModal} = useModalControls() 82 80 const editProfileControl = useDialogControl() 83 - const onPressEditProfile = React.useCallback(() => { 84 - if (isWeb) { 85 - // temp, while we figure out the nested dialog bug 86 - openModal({ 87 - name: 'edit-profile', 88 - profile, 89 - }) 90 - } else { 91 - editProfileControl.open() 92 - } 93 - }, [editProfileControl, openModal, profile]) 94 81 95 82 const onPressFollow = () => { 96 83 requireAuth(async () => { ··· 178 165 size="small" 179 166 color="secondary" 180 167 variant="solid" 181 - onPress={onPressEditProfile} 168 + onPress={editProfileControl.open} 182 169 label={_(msg`Edit profile`)} 183 170 style={[a.rounded_full]}> 184 171 <ButtonText>
+5 -3
src/state/gallery.ts
··· 15 15 import {POST_IMG_MAX} from '#/lib/constants' 16 16 import {getImageDim} from '#/lib/media/manip' 17 17 import {openCropper} from '#/lib/media/picker' 18 + import {type PickerImage} from '#/lib/media/picker.shared' 18 19 import {getDataUriSize} from '#/lib/media/util' 19 20 import {isNative} from '#/platform/detection' 20 21 ··· 194 195 return img 195 196 } 196 197 197 - export async function compressImage(img: ComposerImage): Promise<ImageMeta> { 198 + export async function compressImage(img: ComposerImage): Promise<PickerImage> { 198 199 const source = img.transformed || img.source 199 200 200 201 const [w, h] = containImageRes(source.width, source.height, POST_IMG_MAX) ··· 219 220 ) 220 221 221 222 const base64 = res.base64 222 - 223 - if (base64 !== undefined && getDataUriSize(base64) <= POST_IMG_MAX.size) { 223 + const size = base64 ? getDataUriSize(base64) : 0 224 + if (base64 && size <= POST_IMG_MAX.size) { 224 225 minQualityPercentage = qualityPercentage 225 226 newDataUri = { 226 227 path: await moveIfNecessary(res.uri), 227 228 width: res.width, 228 229 height: res.height, 229 230 mime: 'image/jpeg', 231 + size, 230 232 } 231 233 } else { 232 234 maxQualityPercentage = qualityPercentage
+1 -40
src/state/modals/index.tsx
··· 1 1 import React from 'react' 2 - import {type AppBskyActorDefs, type AppBskyGraphDefs} from '@atproto/api' 2 + import {type AppBskyGraphDefs} from '@atproto/api' 3 3 4 4 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 5 - import {type PickerImage} from '#/lib/media/picker.shared' 6 - 7 - export interface EditProfileModal { 8 - name: 'edit-profile' 9 - profile: AppBskyActorDefs.ProfileViewDetailed 10 - onUpdate?: () => void 11 - } 12 5 13 6 export interface CreateOrEditListModal { 14 7 name: 'create-or-edit-list' ··· 24 17 displayName: string 25 18 onAdd?: (listUri: string) => void 26 19 onRemove?: (listUri: string) => void 27 - } 28 - 29 - export interface CropImageModal { 30 - name: 'crop-image' 31 - uri: string 32 - dimensions?: {width: number; height: number} 33 - aspect?: number 34 - circular?: boolean 35 - onSelect: (img?: PickerImage) => void 36 20 } 37 21 38 22 export interface DeleteAccountModal { ··· 70 54 // Account 71 55 | DeleteAccountModal 72 56 | ChangePasswordModal 73 - 74 - // Temp 75 - | EditProfileModal 76 57 77 58 // Curation 78 59 | ContentLanguagesSettingsModal ··· 82 63 | CreateOrEditListModal 83 64 | UserAddRemoveListsModal 84 65 85 - // Posts 86 - | CropImageModal 87 - 88 66 // Bluesky access 89 67 | WaitlistModal 90 68 | InviteCodesModal ··· 110 88 closeAllModals: () => false, 111 89 }) 112 90 113 - /** 114 - * @deprecated DO NOT USE THIS unless you have no other choice. 115 - */ 116 - export let unstable__openModal: (modal: Modal) => void = () => { 117 - throw new Error(`ModalContext is not initialized`) 118 - } 119 - 120 - /** 121 - * @deprecated DO NOT USE THIS unless you have no other choice. 122 - */ 123 - export let unstable__closeModal: () => boolean = () => { 124 - throw new Error(`ModalContext is not initialized`) 125 - } 126 - 127 91 export function Provider({children}: React.PropsWithChildren<{}>) { 128 92 const [activeModals, setActiveModals] = React.useState<Modal[]>([]) 129 93 ··· 144 108 setActiveModals([]) 145 109 return wasActive 146 110 }) 147 - 148 - unstable__openModal = openModal 149 - unstable__closeModal = closeModal 150 111 151 112 const state = React.useMemo( 152 113 () => ({
+4 -4
src/state/queries/list.ts
··· 14 14 15 15 import {uploadBlob} from '#/lib/api' 16 16 import {until} from '#/lib/async/until' 17 - import {type PickerImage} from '#/lib/media/picker.shared' 17 + import {type ImageMeta} from '#/state/gallery' 18 18 import {STALE} from '#/state/queries' 19 - import {useAgent, useSession} from '../session' 19 + import {useAgent, useSession} from '#/state/session' 20 20 import {invalidate as invalidateMyLists} from './my-lists' 21 21 import {RQKEY as PROFILE_LISTS_RQKEY} from './profile-lists' 22 22 ··· 47 47 name: string 48 48 description: string 49 49 descriptionFacets: Facet[] | undefined 50 - avatar: PickerImage | null | undefined 50 + avatar: ImageMeta | null | undefined 51 51 } 52 52 export function useListCreateMutation() { 53 53 const {currentAccount} = useSession() ··· 115 115 name: string 116 116 description: string 117 117 descriptionFacets: Facet[] | undefined 118 - avatar: PickerImage | null | undefined 118 + avatar: ImageMeta | null | undefined 119 119 } 120 120 export function useListMetadataMutation() { 121 121 const {currentAccount} = useSession()
+5 -5
src/state/queries/profile.ts
··· 20 20 import {uploadBlob} from '#/lib/api' 21 21 import {until} from '#/lib/async/until' 22 22 import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue' 23 - import {type PickerImage} from '#/lib/media/picker.shared' 24 23 import {logEvent, type LogEvents, toClout} from '#/lib/statsig/statsig' 24 + import {updateProfileShadow} from '#/state/cache/profile-shadow' 25 25 import {type Shadow} from '#/state/cache/types' 26 + import {type ImageMeta} from '#/state/gallery' 26 27 import {STALE} from '#/state/queries' 27 28 import {resetProfilePostsQueries} from '#/state/queries/post-feed' 28 29 import { ··· 30 31 useUnstableProfileViewCache, 31 32 } from '#/state/queries/unstable-profile-cache' 32 33 import {useUpdateProfileVerificationCache} from '#/state/queries/verification/useUpdateProfileVerificationCache' 34 + import {useAgent, useSession} from '#/state/session' 33 35 import * as userActionHistory from '#/state/userActionHistory' 34 36 import type * as bsky from '#/types/bsky' 35 - import {updateProfileShadow} from '../cache/profile-shadow' 36 - import {useAgent, useSession} from '../session' 37 37 import { 38 38 ProgressGuideAction, 39 39 useProgressGuideControls, ··· 131 131 | (( 132 132 existing: Un$Typed<AppBskyActorProfile.Record>, 133 133 ) => Un$Typed<AppBskyActorProfile.Record>) 134 - newUserAvatar?: PickerImage | undefined | null 135 - newUserBanner?: PickerImage | undefined | null 134 + newUserAvatar?: ImageMeta | undefined | null 135 + newUserBanner?: ImageMeta | undefined | null 136 136 checkCommitted?: (res: AppBskyActorGetProfile.Response) => boolean 137 137 } 138 138 export function useProfileUpdateMutation() {
+6 -4
src/view/com/composer/photos/EditImageDialog.tsx
··· 1 - import React from 'react' 1 + import type React from 'react' 2 2 3 - import {ComposerImage} from '#/state/gallery' 4 - import * as Dialog from '#/components/Dialog' 3 + import {type ComposerImage} from '#/state/gallery' 4 + import type * as Dialog from '#/components/Dialog' 5 5 6 6 export type EditImageDialogProps = { 7 7 control: Dialog.DialogOuterProps['control'] 8 - image: ComposerImage 8 + image?: ComposerImage 9 9 onChange: (next: ComposerImage) => void 10 + aspectRatio?: number 11 + circularCrop?: boolean 10 12 } 11 13 12 14 export const EditImageDialog = ({}: EditImageDialogProps): React.ReactNode => {
+137 -48
src/view/com/composer/photos/EditImageDialog.web.tsx
··· 1 1 import 'react-image-crop/dist/ReactCrop.css' 2 2 3 - import React from 'react' 3 + import {useCallback, useImperativeHandle, useRef, useState} from 'react' 4 4 import {View} from 'react-native' 5 5 import {msg, Trans} from '@lingui/macro' 6 6 import {useLingui} from '@lingui/react' 7 - import ReactCrop, {PercentCrop} from 'react-image-crop' 7 + import ReactCrop, {type PercentCrop} from 'react-image-crop' 8 8 9 9 import { 10 - ImageSource, 11 - ImageTransformation, 10 + type ImageSource, 11 + type ImageTransformation, 12 12 manipulateImage, 13 13 } from '#/state/gallery' 14 - import {atoms as a} from '#/alf' 15 - import {Button, ButtonText} from '#/components/Button' 14 + import {atoms as a, useTheme} from '#/alf' 15 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 16 16 import * as Dialog from '#/components/Dialog' 17 - import {Text} from '#/components/Typography' 18 - import {EditImageDialogProps} from './EditImageDialog' 17 + import {Loader} from '#/components/Loader' 18 + import {type EditImageDialogProps} from './EditImageDialog' 19 19 20 - export const EditImageDialog = (props: EditImageDialogProps) => { 20 + export function EditImageDialog(props: EditImageDialogProps) { 21 21 return ( 22 22 <Dialog.Outer control={props.control}> 23 23 <Dialog.Handle /> 24 - <EditImageInner key={props.image.source.id} {...props} /> 24 + <DialogInner {...props} /> 25 25 </Dialog.Outer> 26 26 ) 27 27 } 28 28 29 - const EditImageInner = ({control, image, onChange}: EditImageDialogProps) => { 29 + function DialogInner({ 30 + control, 31 + image, 32 + onChange, 33 + circularCrop, 34 + aspectRatio, 35 + }: EditImageDialogProps) { 36 + const {_} = useLingui() 37 + const [pending, setPending] = useState(false) 38 + const ref = useRef<{save: () => Promise<void>}>(null) 39 + 40 + const cancelButton = useCallback( 41 + () => ( 42 + <Button 43 + label={_(msg`Cancel`)} 44 + disabled={pending} 45 + onPress={() => control.close()} 46 + size="small" 47 + color="primary" 48 + variant="ghost" 49 + style={[a.rounded_full]} 50 + testID="cropImageCancelBtn"> 51 + <ButtonText style={[a.text_md]}> 52 + <Trans>Cancel</Trans> 53 + </ButtonText> 54 + </Button> 55 + ), 56 + [control, _, pending], 57 + ) 58 + 59 + const saveButton = useCallback( 60 + () => ( 61 + <Button 62 + label={_(msg`Save`)} 63 + onPress={async () => { 64 + setPending(true) 65 + await ref.current?.save() 66 + setPending(false) 67 + }} 68 + disabled={pending} 69 + size="small" 70 + color="primary" 71 + variant="ghost" 72 + style={[a.rounded_full]} 73 + testID="cropImageSaveBtn"> 74 + <ButtonText style={[a.text_md]}> 75 + <Trans>Save</Trans> 76 + </ButtonText> 77 + {pending && <ButtonIcon icon={Loader} />} 78 + </Button> 79 + ), 80 + [_, pending], 81 + ) 82 + 83 + return ( 84 + <Dialog.Inner 85 + label={_(msg`Edit image`)} 86 + header={ 87 + <Dialog.Header renderLeft={cancelButton} renderRight={saveButton}> 88 + <Dialog.HeaderText> 89 + <Trans>Edit image</Trans> 90 + </Dialog.HeaderText> 91 + </Dialog.Header> 92 + }> 93 + {image && ( 94 + <EditImageInner 95 + saveRef={ref} 96 + key={image.source.id} 97 + image={image} 98 + onChange={onChange} 99 + circularCrop={circularCrop} 100 + aspectRatio={aspectRatio} 101 + /> 102 + )} 103 + </Dialog.Inner> 104 + ) 105 + } 106 + 107 + function EditImageInner({ 108 + image, 109 + onChange, 110 + saveRef, 111 + circularCrop = false, 112 + aspectRatio, 113 + }: Required<Pick<EditImageDialogProps, 'image'>> & 114 + Omit<EditImageDialogProps, 'control' | 'image'> & { 115 + saveRef: React.RefObject<{save: () => Promise<void>}> 116 + }) { 117 + const t = useTheme() 118 + const [isDragging, setIsDragging] = useState(false) 30 119 const {_} = useLingui() 120 + const control = Dialog.useDialogContext() 31 121 32 122 const source = image.source 33 123 34 124 const initialCrop = getInitialCrop(source, image.manips) 35 - const [crop, setCrop] = React.useState(initialCrop) 36 - 37 - const isEmpty = !crop || (crop.width || crop.height) === 0 38 - const isNew = initialCrop ? true : !isEmpty 125 + const [crop, setCrop] = useState(initialCrop) 39 126 40 - const onPressSubmit = React.useCallback(async () => { 127 + const onPressSubmit = useCallback(async () => { 41 128 const result = await manipulateImage(image, { 42 129 crop: 43 130 crop && (crop.width || crop.height) !== 0 ··· 50 137 : undefined, 51 138 }) 52 139 53 - onChange(result) 54 - control.close() 140 + control.close(() => { 141 + onChange(result) 142 + }) 55 143 }, [crop, image, source, control, onChange]) 56 144 57 - return ( 58 - <Dialog.Inner label={_(msg`Edit image`)}> 59 - <Dialog.Close /> 60 - 61 - <Text style={[a.text_2xl, a.font_bold, a.leading_tight, a.pb_sm]}> 62 - <Trans>Edit image</Trans> 63 - </Text> 64 - 65 - <View style={[a.align_center]}> 66 - <ReactCrop 67 - crop={crop} 68 - onChange={(_pixelCrop, percentCrop) => setCrop(percentCrop)} 69 - className="ReactCrop--no-animate"> 70 - <img src={source.path} style={{maxHeight: `50vh`}} /> 71 - </ReactCrop> 72 - </View> 145 + useImperativeHandle( 146 + saveRef, 147 + () => ({ 148 + save: onPressSubmit, 149 + }), 150 + [onPressSubmit], 151 + ) 73 152 74 - <View style={[a.mt_md, a.gap_md]}> 75 - <Button 76 - disabled={!isNew} 77 - label={_(msg`Save`)} 78 - size="large" 79 - color="primary" 80 - variant="solid" 81 - onPress={onPressSubmit}> 82 - <ButtonText> 83 - <Trans>Save</Trans> 84 - </ButtonText> 85 - </Button> 86 - </View> 87 - </Dialog.Inner> 153 + return ( 154 + <View 155 + style={[ 156 + a.mx_auto, 157 + a.border, 158 + t.atoms.border_contrast_low, 159 + a.rounded_xs, 160 + a.overflow_hidden, 161 + a.align_center, 162 + ]}> 163 + <ReactCrop 164 + crop={crop} 165 + aspect={aspectRatio} 166 + circularCrop={circularCrop} 167 + onChange={(_pixelCrop, percentCrop) => setCrop(percentCrop)} 168 + className="ReactCrop--no-animate" 169 + onDragStart={() => setIsDragging(true)} 170 + onDragEnd={() => setIsDragging(false)}> 171 + <img src={source.path} style={{maxHeight: `50vh`}} /> 172 + </ReactCrop> 173 + {/* Eat clicks when dragging, otherwise mousing up over the backdrop 174 + causes the dialog to close */} 175 + {isDragging && <View style={[a.fixed, a.inset_0]} />} 176 + </View> 88 177 ) 89 178 } 90 179
+9 -11
src/view/com/modals/CreateOrEditList.tsx
··· 15 15 16 16 import {usePalette} from '#/lib/hooks/usePalette' 17 17 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 18 - import {compressIfNeeded} from '#/lib/media/manip' 19 - import {type PickerImage} from '#/lib/media/picker.shared' 20 18 import {cleanError, isNetworkError} from '#/lib/strings/errors' 21 19 import {enforceLen} from '#/lib/strings/helpers' 22 20 import {richTextToString} from '#/lib/strings/rich-text-helpers' 23 21 import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip' 24 22 import {colors, gradients, s} from '#/lib/styles' 25 23 import {useTheme} from '#/lib/ThemeContext' 24 + import {type ImageMeta} from '#/state/gallery' 26 25 import {useModalControls} from '#/state/modals' 27 26 import { 28 27 useListCreateMutation, 29 28 useListMetadataMutation, 30 29 } from '#/state/queries/list' 31 30 import {useAgent} from '#/state/session' 32 - import {ErrorMessage} from '../util/error/ErrorMessage' 33 - import {Text} from '../util/text/Text' 34 - import * as Toast from '../util/Toast' 35 - import {EditableUserAvatar} from '../util/UserAvatar' 31 + import {ErrorMessage} from '#/view/com/util/error/ErrorMessage' 32 + import {Text} from '#/view/com/util/text/Text' 33 + import * as Toast from '#/view/com/util/Toast' 34 + import {EditableUserAvatar} from '#/view/com/util/UserAvatar' 36 35 37 36 const MAX_NAME = 64 // todo 38 37 const MAX_DESCRIPTION = 300 // todo ··· 95 94 const isDescriptionOver = graphemeLength > MAX_DESCRIPTION 96 95 97 96 const [avatar, setAvatar] = useState<string | undefined>(list?.avatar) 98 - const [newAvatar, setNewAvatar] = useState<PickerImage | undefined | null>() 97 + const [newAvatar, setNewAvatar] = useState<ImageMeta | undefined | null>() 99 98 100 99 const onDescriptionChange = useCallback( 101 100 (newText: string) => { ··· 112 111 }, [closeModal]) 113 112 114 113 const onSelectNewAvatar = useCallback( 115 - async (img: PickerImage | null) => { 114 + (img: ImageMeta | null) => { 116 115 if (!img) { 117 116 setNewAvatar(null) 118 117 setAvatar(undefined) 119 118 return 120 119 } 121 120 try { 122 - const finalImg = await compressIfNeeded(img, 1000000) 123 - setNewAvatar(finalImg) 124 - setAvatar(finalImg.path) 121 + setNewAvatar(img) 122 + setAvatar(img.path) 125 123 } catch (e: any) { 126 124 setError(cleanError(e)) 127 125 }
+1 -5
src/view/com/modals/Modal.tsx
··· 10 10 import * as ChangePasswordModal from './ChangePassword' 11 11 import * as CreateOrEditListModal from './CreateOrEditList' 12 12 import * as DeleteAccountModal from './DeleteAccount' 13 - import * as EditProfileModal from './EditProfile' 14 13 import * as InviteCodesModal from './InviteCodes' 15 14 import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' 16 15 import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' ··· 48 47 49 48 let snapPoints: (string | number)[] = DEFAULT_SNAPPOINTS 50 49 let element 51 - if (activeModal?.name === 'edit-profile') { 52 - snapPoints = EditProfileModal.snapPoints 53 - element = <EditProfileModal.Component {...activeModal} /> 54 - } else if (activeModal?.name === 'create-or-edit-list') { 50 + if (activeModal?.name === 'create-or-edit-list') { 55 51 snapPoints = CreateOrEditListModal.snapPoints 56 52 element = <CreateOrEditListModal.Component {...activeModal} /> 57 53 } else if (activeModal?.name === 'user-add-remove-lists') {
+1 -10
src/view/com/modals/Modal.web.tsx
··· 8 8 import {useModalControls, useModals} from '#/state/modals' 9 9 import * as ChangePasswordModal from './ChangePassword' 10 10 import * as CreateOrEditListModal from './CreateOrEditList' 11 - import * as CropImageModal from './CropImage.web' 12 11 import * as DeleteAccountModal from './DeleteAccount' 13 - import * as EditProfileModal from './EditProfile' 14 12 import * as InviteCodesModal from './InviteCodes' 15 13 import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' 16 14 import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings' ··· 45 43 } 46 44 47 45 const onPressMask = () => { 48 - if (modal.name === 'crop-image') { 49 - return // dont close on mask presses during crop 50 - } 51 46 closeModal() 52 47 } 53 48 const onInnerPress = () => { ··· 56 51 } 57 52 58 53 let element 59 - if (modal.name === 'edit-profile') { 60 - element = <EditProfileModal.Component {...modal} /> 61 - } else if (modal.name === 'create-or-edit-list') { 54 + if (modal.name === 'create-or-edit-list') { 62 55 element = <CreateOrEditListModal.Component {...modal} /> 63 56 } else if (modal.name === 'user-add-remove-lists') { 64 57 element = <UserAddRemoveLists.Component {...modal} /> 65 - } else if (modal.name === 'crop-image') { 66 - element = <CropImageModal.Component {...modal} /> 67 58 } else if (modal.name === 'delete-account') { 68 59 element = <DeleteAccountModal.Component /> 69 60 } else if (modal.name === 'invite-codes') {
+2 -2
src/view/com/util/EventStopper.tsx
··· 1 - import React from 'react' 2 - import {View, ViewStyle} from 'react-native' 1 + import {View, type ViewStyle} from 'react-native' 2 + import type React from 'react' 3 3 4 4 /** 5 5 * This utility function captures events and stops
+147 -104
src/view/com/util/UserAvatar.tsx
··· 1 - import React, {memo, useMemo} from 'react' 1 + import React, {memo, useCallback, useMemo, useState} from 'react' 2 2 import { 3 3 Image, 4 4 Pressable, ··· 14 14 import {useLingui} from '@lingui/react' 15 15 import {useQueryClient} from '@tanstack/react-query' 16 16 17 - import {usePalette} from '#/lib/hooks/usePalette' 18 17 import { 19 18 useCameraPermission, 20 19 usePhotoLibraryPermission, 21 20 } from '#/lib/hooks/usePermissions' 21 + import {compressIfNeeded} from '#/lib/media/manip' 22 + import {openCamera, openCropper, openPicker} from '#/lib/media/picker' 23 + import {type PickerImage} from '#/lib/media/picker.shared' 22 24 import {makeProfileLink} from '#/lib/routes/links' 23 - import {colors} from '#/lib/styles' 24 25 import {logger} from '#/logger' 25 26 import {isAndroid, isNative, isWeb} from '#/platform/detection' 26 - import {precacheProfile} from '#/state/queries/profile' 27 + import { 28 + type ComposerImage, 29 + compressImage, 30 + createComposerImage, 31 + } from '#/state/gallery' 32 + import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache' 33 + import {EditImageDialog} from '#/view/com/composer/photos/EditImageDialog' 27 34 import {HighPriorityImage} from '#/view/com/util/images/Image' 28 - import {tokens, useTheme} from '#/alf' 35 + import {atoms as a, tokens, useTheme} from '#/alf' 36 + import {useDialogControl} from '#/components/Dialog' 29 37 import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' 30 38 import { 31 - Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled, 32 - Camera_Stroke2_Corner0_Rounded as Camera, 39 + Camera_Filled_Stroke2_Corner0_Rounded as CameraFilledIcon, 40 + Camera_Stroke2_Corner0_Rounded as CameraIcon, 33 41 } from '#/components/icons/Camera' 34 - import {StreamingLive_Stroke2_Corner0_Rounded as Library} from '#/components/icons/StreamingLive' 35 - import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' 42 + import {StreamingLive_Stroke2_Corner0_Rounded as LibraryIcon} from '#/components/icons/StreamingLive' 43 + import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' 36 44 import {Link} from '#/components/Link' 37 45 import {MediaInsetBorder} from '#/components/MediaInsetBorder' 38 46 import * as Menu from '#/components/Menu' 39 47 import {ProfileHoverCard} from '#/components/ProfileHoverCard' 40 48 import type * as bsky from '#/types/bsky' 41 - import { 42 - openCamera, 43 - openCropper, 44 - openPicker, 45 - type RNImage, 46 - } from '../../../lib/media/picker' 47 49 48 50 export type UserAvatarType = 'user' | 'algo' | 'list' | 'labeler' 49 51 ··· 63 65 } 64 66 65 67 interface EditableUserAvatarProps extends BaseUserAvatarProps { 66 - onSelectNewAvatar: (img: RNImage | null) => void 68 + onSelectNewAvatar: (img: PickerImage | null) => void 67 69 } 68 70 69 71 interface PreviewableUserAvatarProps extends BaseUserAvatarProps { ··· 195 197 onLoad, 196 198 style, 197 199 }: UserAvatarProps): React.ReactNode => { 198 - const pal = usePalette('default') 199 - const backgroundColor = pal.colors.backgroundLight 200 + const t = useTheme() 201 + const backgroundColor = t.palette.contrast_25 200 202 const finalShape = overrideShape ?? (type === 'user' ? 'circle' : 'square') 201 203 202 204 const aviStyle = useMemo(() => { ··· 221 223 return null 222 224 } 223 225 return ( 224 - <View style={[styles.alertIconContainer, pal.view]}> 226 + <View 227 + style={[ 228 + a.absolute, 229 + a.right_0, 230 + a.bottom_0, 231 + a.rounded_full, 232 + {backgroundColor: t.palette.white}, 233 + ]}> 225 234 <FontAwesomeIcon 226 235 icon="exclamation-circle" 227 - style={styles.alertIcon} 236 + style={{color: t.palette.negative_400}} 228 237 size={Math.floor(size / 3)} 229 238 /> 230 239 </View> 231 240 ) 232 - }, [moderation?.alert, size, pal]) 241 + }, [moderation?.alert, size, t]) 233 242 234 243 const containerStyle = useMemo(() => { 235 244 return [ ··· 288 297 onSelectNewAvatar, 289 298 }: EditableUserAvatarProps): React.ReactNode => { 290 299 const t = useTheme() 291 - const pal = usePalette('default') 292 300 const {_} = useLingui() 293 301 const {requestCameraAccessIfNeeded} = useCameraPermission() 294 302 const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() 303 + const [rawImage, setRawImage] = useState<ComposerImage | undefined>() 304 + const editImageDialogControl = useDialogControl() 305 + 295 306 const sheetWrapper = useSheetWrapper() 307 + 308 + const circular = type !== 'algo' && type !== 'list' 296 309 297 310 const aviStyle = useMemo(() => { 298 - if (type === 'algo' || type === 'list') { 311 + if (!circular) { 299 312 return { 300 313 width: size, 301 314 height: size, ··· 307 320 height: size, 308 321 borderRadius: Math.floor(size / 2), 309 322 } 310 - }, [type, size]) 323 + }, [circular, size]) 311 324 312 325 const onOpenCamera = React.useCallback(async () => { 313 326 if (!(await requestCameraAccessIfNeeded())) { ··· 315 328 } 316 329 317 330 onSelectNewAvatar( 318 - await openCamera({ 319 - aspect: [1, 1], 320 - }), 331 + await compressIfNeeded( 332 + await openCamera({ 333 + aspect: [1, 1], 334 + }), 335 + ), 321 336 ) 322 337 }, [onSelectNewAvatar, requestCameraAccessIfNeeded]) 323 338 ··· 337 352 } 338 353 339 354 try { 340 - const croppedImage = await openCropper({ 341 - imageUri: item.path, 342 - shape: 'circle', 343 - aspectRatio: 1, 344 - }) 345 - onSelectNewAvatar(croppedImage) 355 + if (isNative) { 356 + onSelectNewAvatar( 357 + await compressIfNeeded( 358 + await openCropper({ 359 + imageUri: item.path, 360 + shape: circular ? 'circle' : 'rectangle', 361 + aspectRatio: 1, 362 + }), 363 + ), 364 + ) 365 + } else { 366 + setRawImage(await createComposerImage(item)) 367 + editImageDialogControl.open() 368 + } 346 369 } catch (e: any) { 347 370 // Don't log errors for cancelling selection to sentry on ios or android 348 371 if (!String(e).toLowerCase().includes('cancel')) { 349 372 logger.error('Failed to crop banner', {error: e}) 350 373 } 351 374 } 352 - }, [onSelectNewAvatar, requestPhotoAccessIfNeeded, sheetWrapper]) 375 + }, [ 376 + onSelectNewAvatar, 377 + requestPhotoAccessIfNeeded, 378 + sheetWrapper, 379 + editImageDialogControl, 380 + circular, 381 + ]) 353 382 354 383 const onRemoveAvatar = React.useCallback(() => { 355 384 onSelectNewAvatar(null) 356 385 }, [onSelectNewAvatar]) 357 386 387 + const onChangeEditImage = useCallback( 388 + async (image: ComposerImage) => { 389 + const compressed = await compressImage(image) 390 + onSelectNewAvatar(compressed) 391 + }, 392 + [onSelectNewAvatar], 393 + ) 394 + 358 395 return ( 359 - <Menu.Root> 360 - <Menu.Trigger label={_(msg`Edit avatar`)}> 361 - {({props}) => ( 362 - <Pressable {...props} testID="changeAvatarBtn"> 363 - {avatar ? ( 364 - <HighPriorityImage 365 - testID="userAvatarImage" 366 - style={aviStyle} 367 - source={{uri: avatar}} 368 - accessibilityRole="image" 369 - /> 370 - ) : ( 371 - <DefaultAvatar type={type} size={size} /> 396 + <> 397 + <Menu.Root> 398 + <Menu.Trigger label={_(msg`Edit avatar`)}> 399 + {({props}) => ( 400 + <Pressable {...props} testID="changeAvatarBtn"> 401 + {avatar ? ( 402 + <HighPriorityImage 403 + testID="userAvatarImage" 404 + style={aviStyle} 405 + source={{uri: avatar}} 406 + accessibilityRole="image" 407 + /> 408 + ) : ( 409 + <DefaultAvatar type={type} size={size} /> 410 + )} 411 + <View 412 + style={[ 413 + styles.editButtonContainer, 414 + t.atoms.bg_contrast_25, 415 + a.border, 416 + t.atoms.border_contrast_low, 417 + ]}> 418 + <CameraFilledIcon height={14} width={14} style={t.atoms.text} /> 419 + </View> 420 + </Pressable> 421 + )} 422 + </Menu.Trigger> 423 + <Menu.Outer showCancel> 424 + <Menu.Group> 425 + {isNative && ( 426 + <Menu.Item 427 + testID="changeAvatarCameraBtn" 428 + label={_(msg`Upload from Camera`)} 429 + onPress={onOpenCamera}> 430 + <Menu.ItemText> 431 + <Trans>Upload from Camera</Trans> 432 + </Menu.ItemText> 433 + <Menu.ItemIcon icon={CameraIcon} /> 434 + </Menu.Item> 372 435 )} 373 - <View style={[styles.editButtonContainer, pal.btn]}> 374 - <CameraFilled height={14} width={14} style={t.atoms.text} /> 375 - </View> 376 - </Pressable> 377 - )} 378 - </Menu.Trigger> 379 - <Menu.Outer showCancel> 380 - <Menu.Group> 381 - {isNative && ( 436 + 382 437 <Menu.Item 383 - testID="changeAvatarCameraBtn" 384 - label={_(msg`Upload from Camera`)} 385 - onPress={onOpenCamera}> 438 + testID="changeAvatarLibraryBtn" 439 + label={_(msg`Upload from Library`)} 440 + onPress={onOpenLibrary}> 386 441 <Menu.ItemText> 387 - <Trans>Upload from Camera</Trans> 442 + {isNative ? ( 443 + <Trans>Upload from Library</Trans> 444 + ) : ( 445 + <Trans>Upload from Files</Trans> 446 + )} 388 447 </Menu.ItemText> 389 - <Menu.ItemIcon icon={Camera} /> 448 + <Menu.ItemIcon icon={LibraryIcon} /> 390 449 </Menu.Item> 450 + </Menu.Group> 451 + {!!avatar && ( 452 + <> 453 + <Menu.Divider /> 454 + <Menu.Group> 455 + <Menu.Item 456 + testID="changeAvatarRemoveBtn" 457 + label={_(msg`Remove Avatar`)} 458 + onPress={onRemoveAvatar}> 459 + <Menu.ItemText> 460 + <Trans>Remove Avatar</Trans> 461 + </Menu.ItemText> 462 + <Menu.ItemIcon icon={TrashIcon} /> 463 + </Menu.Item> 464 + </Menu.Group> 465 + </> 391 466 )} 467 + </Menu.Outer> 468 + </Menu.Root> 392 469 393 - <Menu.Item 394 - testID="changeAvatarLibraryBtn" 395 - label={_(msg`Upload from Library`)} 396 - onPress={onOpenLibrary}> 397 - <Menu.ItemText> 398 - {isNative ? ( 399 - <Trans>Upload from Library</Trans> 400 - ) : ( 401 - <Trans>Upload from Files</Trans> 402 - )} 403 - </Menu.ItemText> 404 - <Menu.ItemIcon icon={Library} /> 405 - </Menu.Item> 406 - </Menu.Group> 407 - {!!avatar && ( 408 - <> 409 - <Menu.Divider /> 410 - <Menu.Group> 411 - <Menu.Item 412 - testID="changeAvatarRemoveBtn" 413 - label={_(msg`Remove Avatar`)} 414 - onPress={onRemoveAvatar}> 415 - <Menu.ItemText> 416 - <Trans>Remove Avatar</Trans> 417 - </Menu.ItemText> 418 - <Menu.ItemIcon icon={Trash} /> 419 - </Menu.Item> 420 - </Menu.Group> 421 - </> 422 - )} 423 - </Menu.Outer> 424 - </Menu.Root> 470 + <EditImageDialog 471 + control={editImageDialogControl} 472 + image={rawImage} 473 + onChange={onChangeEditImage} 474 + aspectRatio={1} 475 + circularCrop={circular} 476 + /> 477 + </> 425 478 ) 426 479 } 427 480 EditableUserAvatar = memo(EditableUserAvatar) ··· 440 493 441 494 const onPress = React.useCallback(() => { 442 495 onBeforePress?.() 443 - precacheProfile(queryClient, profile) 496 + unstableCacheProfileView(queryClient, profile) 444 497 }, [profile, queryClient, onBeforePress]) 445 498 446 499 const avatarEl = ( ··· 494 547 borderRadius: 12, 495 548 alignItems: 'center', 496 549 justifyContent: 'center', 497 - backgroundColor: colors.gray5, 498 - }, 499 - alertIconContainer: { 500 - position: 'absolute', 501 - right: 0, 502 - bottom: 0, 503 - borderRadius: 100, 504 - }, 505 - alertIcon: { 506 - color: colors.red3, 507 550 }, 508 551 })
+139 -101
src/view/com/util/UserBanner.tsx
··· 1 - import React from 'react' 1 + import {useCallback, useState} from 'react' 2 2 import {Pressable, StyleSheet, View} from 'react-native' 3 3 import {Image} from 'expo-image' 4 4 import {type ModerationUI} from '@atproto/api' 5 5 import {msg, Trans} from '@lingui/macro' 6 6 import {useLingui} from '@lingui/react' 7 7 8 - import {usePalette} from '#/lib/hooks/usePalette' 9 8 import { 10 9 useCameraPermission, 11 10 usePhotoLibraryPermission, 12 11 } from '#/lib/hooks/usePermissions' 13 - import {colors} from '#/lib/styles' 14 - import {useTheme} from '#/lib/ThemeContext' 12 + import {compressIfNeeded} from '#/lib/media/manip' 13 + import {openCamera, openCropper, openPicker} from '#/lib/media/picker' 14 + import {type PickerImage} from '#/lib/media/picker.shared' 15 15 import {logger} from '#/logger' 16 16 import {isAndroid, isNative} from '#/platform/detection' 17 + import { 18 + type ComposerImage, 19 + compressImage, 20 + createComposerImage, 21 + } from '#/state/gallery' 22 + import {EditImageDialog} from '#/view/com/composer/photos/EditImageDialog' 17 23 import {EventStopper} from '#/view/com/util/EventStopper' 18 - import {tokens, useTheme as useAlfTheme} from '#/alf' 24 + import {atoms as a, tokens, useTheme} from '#/alf' 25 + import {useDialogControl} from '#/components/Dialog' 19 26 import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' 20 27 import { 21 - Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled, 22 - Camera_Stroke2_Corner0_Rounded as Camera, 28 + Camera_Filled_Stroke2_Corner0_Rounded as CameraFilledIcon, 29 + Camera_Stroke2_Corner0_Rounded as CameraIcon, 23 30 } from '#/components/icons/Camera' 24 - import {StreamingLive_Stroke2_Corner0_Rounded as Library} from '#/components/icons/StreamingLive' 25 - import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' 31 + import {StreamingLive_Stroke2_Corner0_Rounded as LibraryIcon} from '#/components/icons/StreamingLive' 32 + import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' 26 33 import * as Menu from '#/components/Menu' 27 - import { 28 - openCamera, 29 - openCropper, 30 - openPicker, 31 - type RNImage, 32 - } from '../../../lib/media/picker' 33 34 34 35 export function UserBanner({ 35 36 type, ··· 40 41 type?: 'labeler' | 'default' 41 42 banner?: string | null 42 43 moderation?: ModerationUI 43 - onSelectNewBanner?: (img: RNImage | null) => void 44 + onSelectNewBanner?: (img: PickerImage | null) => void 44 45 }) { 45 - const pal = usePalette('default') 46 - const theme = useTheme() 47 - const t = useAlfTheme() 46 + const t = useTheme() 48 47 const {_} = useLingui() 49 48 const {requestCameraAccessIfNeeded} = useCameraPermission() 50 49 const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() 51 50 const sheetWrapper = useSheetWrapper() 51 + const [rawImage, setRawImage] = useState<ComposerImage | undefined>() 52 + const editImageDialogControl = useDialogControl() 52 53 53 - const onOpenCamera = React.useCallback(async () => { 54 + const onOpenCamera = useCallback(async () => { 54 55 if (!(await requestCameraAccessIfNeeded())) { 55 56 return 56 57 } 57 58 onSelectNewBanner?.( 58 - await openCamera({ 59 - aspect: [3, 1], 60 - }), 59 + await compressIfNeeded( 60 + await openCamera({ 61 + aspect: [3, 1], 62 + }), 63 + ), 61 64 ) 62 65 }, [onSelectNewBanner, requestCameraAccessIfNeeded]) 63 66 64 - const onOpenLibrary = React.useCallback(async () => { 67 + const onOpenLibrary = useCallback(async () => { 65 68 if (!(await requestPhotoAccessIfNeeded())) { 66 69 return 67 70 } ··· 71 74 } 72 75 73 76 try { 74 - onSelectNewBanner?.( 75 - await openCropper({ 76 - imageUri: items[0].path, 77 - aspectRatio: 3 / 1, 78 - }), 79 - ) 77 + if (isNative) { 78 + onSelectNewBanner?.( 79 + await compressIfNeeded( 80 + await openCropper({ 81 + imageUri: items[0].path, 82 + aspectRatio: 3 / 1, 83 + }), 84 + ), 85 + ) 86 + } else { 87 + setRawImage(await createComposerImage(items[0])) 88 + editImageDialogControl.open() 89 + } 80 90 } catch (e: any) { 81 91 if (!String(e).includes('Canceled')) { 82 92 logger.error('Failed to crop banner', {error: e}) 83 93 } 84 94 } 85 - }, [onSelectNewBanner, requestPhotoAccessIfNeeded, sheetWrapper]) 95 + }, [ 96 + onSelectNewBanner, 97 + requestPhotoAccessIfNeeded, 98 + sheetWrapper, 99 + editImageDialogControl, 100 + ]) 86 101 87 - const onRemoveBanner = React.useCallback(() => { 102 + const onRemoveBanner = useCallback(() => { 88 103 onSelectNewBanner?.(null) 89 104 }, [onSelectNewBanner]) 90 105 106 + const onChangeEditImage = useCallback( 107 + async (image: ComposerImage) => { 108 + const compressed = await compressImage(image) 109 + onSelectNewBanner?.(compressed) 110 + }, 111 + [onSelectNewBanner], 112 + ) 113 + 91 114 // setUserBanner is only passed as prop on the EditProfile component 92 115 return onSelectNewBanner ? ( 93 - <EventStopper onKeyDown={true}> 94 - <Menu.Root> 95 - <Menu.Trigger label={_(msg`Edit avatar`)}> 96 - {({props}) => ( 97 - <Pressable {...props} testID="changeBannerBtn"> 98 - {banner ? ( 99 - <Image 100 - testID="userBannerImage" 101 - style={styles.bannerImage} 102 - source={{uri: banner}} 103 - accessible={true} 104 - accessibilityIgnoresInvertColors 105 - /> 106 - ) : ( 116 + <> 117 + <EventStopper onKeyDown={true}> 118 + <Menu.Root> 119 + <Menu.Trigger label={_(msg`Edit avatar`)}> 120 + {({props}) => ( 121 + <Pressable {...props} testID="changeBannerBtn"> 122 + {banner ? ( 123 + <Image 124 + testID="userBannerImage" 125 + style={styles.bannerImage} 126 + source={{uri: banner}} 127 + accessible={true} 128 + accessibilityIgnoresInvertColors 129 + /> 130 + ) : ( 131 + <View 132 + testID="userBannerFallback" 133 + style={[styles.bannerImage, t.atoms.bg_contrast_25]} 134 + /> 135 + )} 107 136 <View 108 - testID="userBannerFallback" 109 - style={[styles.bannerImage, t.atoms.bg_contrast_25]} 110 - /> 137 + style={[ 138 + styles.editButtonContainer, 139 + t.atoms.bg_contrast_25, 140 + a.border, 141 + t.atoms.border_contrast_low, 142 + ]}> 143 + <CameraFilledIcon 144 + height={14} 145 + width={14} 146 + style={t.atoms.text} 147 + /> 148 + </View> 149 + </Pressable> 150 + )} 151 + </Menu.Trigger> 152 + <Menu.Outer showCancel> 153 + <Menu.Group> 154 + {isNative && ( 155 + <Menu.Item 156 + testID="changeBannerCameraBtn" 157 + label={_(msg`Upload from Camera`)} 158 + onPress={onOpenCamera}> 159 + <Menu.ItemText> 160 + <Trans>Upload from Camera</Trans> 161 + </Menu.ItemText> 162 + <Menu.ItemIcon icon={CameraIcon} /> 163 + </Menu.Item> 111 164 )} 112 - <View style={[styles.editButtonContainer, pal.btn]}> 113 - <CameraFilled height={14} width={14} style={t.atoms.text} /> 114 - </View> 115 - </Pressable> 116 - )} 117 - </Menu.Trigger> 118 - <Menu.Outer showCancel> 119 - <Menu.Group> 120 - {isNative && ( 165 + 121 166 <Menu.Item 122 - testID="changeBannerCameraBtn" 123 - label={_(msg`Upload from Camera`)} 124 - onPress={onOpenCamera}> 167 + testID="changeBannerLibraryBtn" 168 + label={_(msg`Upload from Library`)} 169 + onPress={onOpenLibrary}> 125 170 <Menu.ItemText> 126 - <Trans>Upload from Camera</Trans> 171 + {isNative ? ( 172 + <Trans>Upload from Library</Trans> 173 + ) : ( 174 + <Trans>Upload from Files</Trans> 175 + )} 127 176 </Menu.ItemText> 128 - <Menu.ItemIcon icon={Camera} /> 177 + <Menu.ItemIcon icon={LibraryIcon} /> 129 178 </Menu.Item> 179 + </Menu.Group> 180 + {!!banner && ( 181 + <> 182 + <Menu.Divider /> 183 + <Menu.Group> 184 + <Menu.Item 185 + testID="changeBannerRemoveBtn" 186 + label={_(msg`Remove Banner`)} 187 + onPress={onRemoveBanner}> 188 + <Menu.ItemText> 189 + <Trans>Remove Banner</Trans> 190 + </Menu.ItemText> 191 + <Menu.ItemIcon icon={TrashIcon} /> 192 + </Menu.Item> 193 + </Menu.Group> 194 + </> 130 195 )} 196 + </Menu.Outer> 197 + </Menu.Root> 198 + </EventStopper> 131 199 132 - <Menu.Item 133 - testID="changeBannerLibraryBtn" 134 - label={_(msg`Upload from Library`)} 135 - onPress={onOpenLibrary}> 136 - <Menu.ItemText> 137 - {isNative ? ( 138 - <Trans>Upload from Library</Trans> 139 - ) : ( 140 - <Trans>Upload from Files</Trans> 141 - )} 142 - </Menu.ItemText> 143 - <Menu.ItemIcon icon={Library} /> 144 - </Menu.Item> 145 - </Menu.Group> 146 - {!!banner && ( 147 - <> 148 - <Menu.Divider /> 149 - <Menu.Group> 150 - <Menu.Item 151 - testID="changeBannerRemoveBtn" 152 - label={_(msg`Remove Banner`)} 153 - onPress={onRemoveBanner}> 154 - <Menu.ItemText> 155 - <Trans>Remove Banner</Trans> 156 - </Menu.ItemText> 157 - <Menu.ItemIcon icon={Trash} /> 158 - </Menu.Item> 159 - </Menu.Group> 160 - </> 161 - )} 162 - </Menu.Outer> 163 - </Menu.Root> 164 - </EventStopper> 200 + <EditImageDialog 201 + control={editImageDialogControl} 202 + image={rawImage} 203 + onChange={onChangeEditImage} 204 + aspectRatio={3} 205 + /> 206 + </> 165 207 ) : banner && 166 208 !((moderation?.blur && isAndroid) /* android crashes with blur */) ? ( 167 209 <Image 168 210 testID="userBannerImage" 169 - style={[ 170 - styles.bannerImage, 171 - {backgroundColor: theme.palette.default.backgroundLight}, 172 - ]} 211 + style={[styles.bannerImage, t.atoms.bg_contrast_25]} 173 212 contentFit="cover" 174 213 source={{uri: banner}} 175 214 blurRadius={moderation?.blur ? 100 : 0} ··· 197 236 borderRadius: 12, 198 237 alignItems: 'center', 199 238 justifyContent: 'center', 200 - backgroundColor: colors.gray5, 201 239 }, 202 240 bannerImage: { 203 241 width: '100%',