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