···2425export async function compressIfNeeded(
26 img: PickerImage,
27- maxSize: number = 1000000,
28): Promise<PickerImage> {
29 if (img.size < maxSize) {
30 return img
···2425export 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
···001import {type PickerImage} from './picker.shared'
2import {type Dimensions} from './types'
3import {blobToDataUri, getDataUriSize} from './util'
···1+/// <reference lib="dom" />
2+3import {type PickerImage} from './picker.shared'
4import {type Dimensions} from './types'
5import {blobToDataUri, getDataUriSize} from './util'
+3-7
src/lib/media/picker.shared.ts
···1import {
2 type ImagePickerOptions,
3 launchImageLibraryAsync,
4- MediaTypeOptions,
5} from 'expo-image-picker'
6import {t} from '@lingui/macro'
708import * as Toast from '#/view/com/util/Toast'
9import {getDataUriSize} from './util'
1011-export type PickerImage = {
12- mime: string
13- height: number
14- width: number
15- path: string
16 size: number
17}
1819export 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,
···1import {
2 type ImagePickerOptions,
3 launchImageLibraryAsync,
04} from 'expo-image-picker'
5import {t} from '@lingui/macro'
67+import {type ImageMeta} from '#/state/gallery'
8import * as Toast from '#/view/com/util/Toast'
9import {getDataUriSize} from './util'
1011+export type PickerImage = ImageMeta & {
000012 size: number
13}
1415export 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-3import {type OpenCropperOptions} from 'expo-image-crop-tool'
45-import {unstable__openModal} from '#/state/modals'
6import {type PickerImage} from './picker.shared'
7import {type CameraOpts} from './types'
89-export {openPicker, type PickerImage as RNImage} from './picker.shared'
1011export async function openCamera(_opts: CameraOpts): Promise<PickerImage> {
12- // const mediaType = opts.mediaType || 'photo' TODO
13- throw new Error('TODO')
14}
1516export 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}
···001import {type OpenCropperOptions} from 'expo-image-crop-tool'
203import {type PickerImage} from './picker.shared'
4import {type CameraOpts} from './types'
56+export {openPicker} from './picker.shared'
78export async function openCamera(_opts: CameraOpts): Promise<PickerImage> {
9+ throw new Error('openCamera is not supported on web')
010}
1112export async function openCropper(
13+ _opts: OpenCropperOptions,
14): Promise<PickerImage> {
15+ throw new Error(
16+ 'openCropper is not supported on web. Use EditImageDialog instead.',
17+ )
000000000000018}
+12-13
src/screens/Profile/Header/EditProfileDialog.tsx
···5import {useLingui} from '@lingui/react'
67import {urls} from '#/lib/constants'
8-import {compressIfNeeded} from '#/lib/media/manip'
9-import {type PickerImage} from '#/lib/media/picker.shared'
10import {cleanError} from '#/lib/strings/errors'
11import {useWarnMaxGraphemeCount} from '#/lib/strings/helpers'
12import {logger} from '#/logger'
13import {isWeb} from '#/platform/detection'
014import {useProfileUpdateMutation} from '#/state/queries/profile'
15import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
16import * as Toast from '#/view/com/util/Toast'
···18import {UserBanner} from '#/view/com/util/UserBanner'
19import {atoms as a, useTheme} from '#/alf'
20import {Admonition} from '#/components/Admonition'
21-import {Button, ButtonText} from '#/components/Button'
22import * as Dialog from '#/components/Dialog'
23import * as TextField from '#/components/forms/TextField'
24import {InlineLinkText} from '#/components/Link'
025import * as Prompt from '#/components/Prompt'
26import {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 >()
135136 const dirty =
···144 }, [dirty, setDirty])
145146 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 )
164165 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>
0261 </Button>
262 ),
263 [
···5import {useLingui} from '@lingui/react'
67import {urls} from '#/lib/constants'
008import {cleanError} from '#/lib/strings/errors'
9import {useWarnMaxGraphemeCount} from '#/lib/strings/helpers'
10import {logger} from '#/logger'
11import {isWeb} from '#/platform/detection'
12+import {type ImageMeta} from '#/state/gallery'
13import {useProfileUpdateMutation} from '#/state/queries/profile'
14import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
15import * as Toast from '#/view/com/util/Toast'
···17import {UserBanner} from '#/view/com/util/UserBanner'
18import {atoms as a, useTheme} from '#/alf'
19import {Admonition} from '#/components/Admonition'
20+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
21import * as Dialog from '#/components/Dialog'
22import * as TextField from '#/components/forms/TextField'
23import {InlineLinkText} from '#/components/Link'
24+import {Loader} from '#/components/Loader'
25import * as Prompt from '#/components/Prompt'
26import {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 >()
135136 const dirty =
···144 }, [dirty, setDirty])
145146 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)
0157 } catch (e: any) {
158 setImageError(cleanError(e))
159 }
···162 )
163164 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)
0175 } 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 [
···1516import {usePalette} from '#/lib/hooks/usePalette'
17import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
18-import {compressIfNeeded} from '#/lib/media/manip'
19-import {type PickerImage} from '#/lib/media/picker.shared'
20import {cleanError, isNetworkError} from '#/lib/strings/errors'
21import {enforceLen} from '#/lib/strings/helpers'
22import {richTextToString} from '#/lib/strings/rich-text-helpers'
23import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
24import {colors, gradients, s} from '#/lib/styles'
25import {useTheme} from '#/lib/ThemeContext'
026import {useModalControls} from '#/state/modals'
27import {
28 useListCreateMutation,
29 useListMetadataMutation,
30} from '#/state/queries/list'
31import {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'
3637const MAX_NAME = 64 // todo
38const MAX_DESCRIPTION = 300 // todo
···95 const isDescriptionOver = graphemeLength > MAX_DESCRIPTION
9697 const [avatar, setAvatar] = useState<string | undefined>(list?.avatar)
98- const [newAvatar, setNewAvatar] = useState<PickerImage | undefined | null>()
99100 const onDescriptionChange = useCallback(
101 (newText: string) => {
···112 }, [closeModal])
113114 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 }
···1516import {usePalette} from '#/lib/hooks/usePalette'
17import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
0018import {cleanError, isNetworkError} from '#/lib/strings/errors'
19import {enforceLen} from '#/lib/strings/helpers'
20import {richTextToString} from '#/lib/strings/rich-text-helpers'
21import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
22import {colors, gradients, s} from '#/lib/styles'
23import {useTheme} from '#/lib/ThemeContext'
24+import {type ImageMeta} from '#/state/gallery'
25import {useModalControls} from '#/state/modals'
26import {
27 useListCreateMutation,
28 useListMetadataMutation,
29} from '#/state/queries/list'
30import {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'
3536const MAX_NAME = 64 // todo
37const MAX_DESCRIPTION = 300 // todo
···94 const isDescriptionOver = graphemeLength > MAX_DESCRIPTION
9596 const [avatar, setAvatar] = useState<string | undefined>(list?.avatar)
97+ const [newAvatar, setNewAvatar] = useState<ImageMeta | undefined | null>()
9899 const onDescriptionChange = useCallback(
100 (newText: string) => {
···111 }, [closeModal])
112113 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)
0123 } catch (e: any) {
124 setError(cleanError(e))
125 }
+1-5
src/view/com/modals/Modal.tsx
···10import * as ChangePasswordModal from './ChangePassword'
11import * as CreateOrEditListModal from './CreateOrEditList'
12import * as DeleteAccountModal from './DeleteAccount'
13-import * as EditProfileModal from './EditProfile'
14import * as InviteCodesModal from './InviteCodes'
15import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
16import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
···4849 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') {
···10import * as ChangePasswordModal from './ChangePassword'
11import * as CreateOrEditListModal from './CreateOrEditList'
12import * as DeleteAccountModal from './DeleteAccount'
013import * as InviteCodesModal from './InviteCodes'
14import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
15import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
···4748 let snapPoints: (string | number)[] = DEFAULT_SNAPPOINTS
49 let element
50+ if (activeModal?.name === 'create-or-edit-list') {
00051 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
···8import {useModalControls, useModals} from '#/state/modals'
9import * as ChangePasswordModal from './ChangePassword'
10import * as CreateOrEditListModal from './CreateOrEditList'
11-import * as CropImageModal from './CropImage.web'
12import * as DeleteAccountModal from './DeleteAccount'
13-import * as EditProfileModal from './EditProfile'
14import * as InviteCodesModal from './InviteCodes'
15import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
16import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
···45 }
4647 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 }
5758 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') {
···8import {useModalControls, useModals} from '#/state/modals'
9import * as ChangePasswordModal from './ChangePassword'
10import * as CreateOrEditListModal from './CreateOrEditList'
011import * as DeleteAccountModal from './DeleteAccount'
012import * as InviteCodesModal from './InviteCodes'
13import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
14import * as PostLanguagesSettingsModal from './lang-settings/PostLanguagesSettings'
···43 }
4445 const onPressMask = () => {
00046 closeModal()
47 }
48 const onInnerPress = () => {
···51 }
5253 let element
54+ if (modal.name === 'create-or-edit-list') {
0055 element = <CreateOrEditListModal.Component {...modal} />
56 } else if (modal.name === 'user-add-remove-lists') {
57 element = <UserAddRemoveLists.Component {...modal} />
0058 } 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'
34/**
5 * This utility function captures events and stops
···1+import {View, type ViewStyle} from 'react-native'
2+import type React from 'react'
34/**
5 * This utility function captures events and stops