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