Bluesky app fork with some witchin' additions 💫 witchsky.app
bluesky fork client
122
fork

Configure Feed

Select the types of activity you want to include in your feed.

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

authored by

Samuel Newman 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%',