my fork of the bluesky client

MobX removal take 2 (#5381)

* mobx removal take 2

* Actually rm mobx

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>

authored by

Mary
Dan Abramov
and committed by
GitHub
8ea89469 dbe1df7a

+594 -1256
-3
package.json
··· 161 161 "lodash.set": "^4.3.2", 162 162 "lodash.shuffle": "^4.2.0", 163 163 "lodash.throttle": "^4.1.1", 164 - "mobx": "^6.6.1", 165 - "mobx-react-lite": "^3.4.0", 166 - "mobx-utils": "^6.0.6", 167 164 "nanoid": "^5.0.5", 168 165 "normalize-url": "^8.0.0", 169 166 "patch-package": "^6.5.1",
+14 -39
src/lib/api/index.ts
··· 14 14 } from '@atproto/api' 15 15 16 16 import {logger} from '#/logger' 17 + import {ComposerImage, compressImage} from '#/state/gallery' 17 18 import {writePostgateRecord} from '#/state/queries/postgate' 18 19 import { 19 20 createThreadgateRecord, ··· 23 24 } from '#/state/queries/threadgate' 24 25 import {isNetworkError} from 'lib/strings/errors' 25 26 import {shortenLinks, stripInvalidMentions} from 'lib/strings/rich-text-manip' 26 - import {isNative} from 'platform/detection' 27 - import {ImageModel} from 'state/models/media/image' 28 27 import {LinkMeta} from '../link-meta/link-meta' 29 - import {safeDeleteAsync} from '../media/manip' 30 28 import {uploadBlob} from './upload-blob' 31 29 32 30 export {uploadBlob} ··· 36 34 isLoading: boolean 37 35 meta?: LinkMeta 38 36 embed?: AppBskyEmbedRecord.Main 39 - localThumb?: ImageModel 37 + localThumb?: ComposerImage 40 38 } 41 39 42 40 interface PostOpts { ··· 53 51 aspectRatio?: AppBskyEmbedDefs.AspectRatio 54 52 } 55 53 extLink?: ExternalEmbedDraft 56 - images?: ImageModel[] 54 + images?: ComposerImage[] 57 55 labels?: string[] 58 56 threadgate: ThreadgateAllowUISetting[] 59 57 postgate: AppBskyFeedPostgate.Record ··· 99 97 const images: AppBskyEmbedImages.Image[] = [] 100 98 for (const image of opts.images) { 101 99 opts.onStateChange?.(`Uploading image #${images.length + 1}...`) 100 + 102 101 logger.debug(`Compressing image`) 103 - await image.compress() 104 - const path = image.compressed?.path ?? image.path 105 - const {width, height} = image.compressed || image 102 + const {path, width, height, mime} = await compressImage(image) 103 + 106 104 logger.debug(`Uploading image`) 107 - const res = await uploadBlob(agent, path, 'image/jpeg') 108 - if (isNative) { 109 - safeDeleteAsync(path) 110 - } 105 + const res = await uploadBlob(agent, path, mime) 106 + 111 107 images.push({ 112 108 image: res.data.blob, 113 - alt: image.altText ?? '', 109 + alt: image.alt, 114 110 aspectRatio: {width, height}, 115 111 }) 116 112 } ··· 175 171 let thumb 176 172 if (opts.extLink.localThumb) { 177 173 opts.onStateChange?.('Uploading link thumbnail...') 178 - let encoding 179 - if (opts.extLink.localThumb.mime) { 180 - encoding = opts.extLink.localThumb.mime 181 - } else if (opts.extLink.localThumb.path.endsWith('.png')) { 182 - encoding = 'image/png' 183 - } else if ( 184 - opts.extLink.localThumb.path.endsWith('.jpeg') || 185 - opts.extLink.localThumb.path.endsWith('.jpg') 186 - ) { 187 - encoding = 'image/jpeg' 188 - } else { 189 - logger.warn('Unexpected image format for thumbnail, skipping', { 190 - thumbnail: opts.extLink.localThumb.path, 191 - }) 192 - } 193 - if (encoding) { 194 - const thumbUploadRes = await uploadBlob( 195 - agent, 196 - opts.extLink.localThumb.path, 197 - encoding, 198 - ) 199 - thumb = thumbUploadRes.data.blob 200 - if (isNative) { 201 - safeDeleteAsync(opts.extLink.localThumb.path) 202 - } 203 - } 174 + 175 + const {path, mime} = opts.extLink.localThumb.source 176 + const res = await uploadBlob(agent, path, mime) 177 + 178 + thumb = res.data.blob 204 179 } 205 180 206 181 if (opts.quote) {
+1 -1
src/lib/media/picker.shared.ts
··· 28 28 return false 29 29 }) 30 30 .map(image => ({ 31 - mime: 'image/jpeg', 31 + mime: image.mimeType || 'image/jpeg', 32 32 height: image.height, 33 33 width: image.width, 34 34 path: image.uri,
+299
src/state/gallery.ts
··· 1 + import { 2 + cacheDirectory, 3 + deleteAsync, 4 + makeDirectoryAsync, 5 + moveAsync, 6 + } from 'expo-file-system' 7 + import { 8 + Action, 9 + ActionCrop, 10 + manipulateAsync, 11 + SaveFormat, 12 + } from 'expo-image-manipulator' 13 + import {nanoid} from 'nanoid/non-secure' 14 + 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 {isIOS, isNative} from '#/platform/detection' 20 + 21 + export type ImageTransformation = { 22 + crop?: ActionCrop['crop'] 23 + } 24 + 25 + export type ImageMeta = { 26 + path: string 27 + width: number 28 + height: number 29 + mime: string 30 + } 31 + 32 + export type ImageSource = ImageMeta & { 33 + id: string 34 + } 35 + 36 + type ComposerImageBase = { 37 + alt: string 38 + source: ImageSource 39 + } 40 + type ComposerImageWithoutTransformation = ComposerImageBase & { 41 + transformed?: undefined 42 + manips?: undefined 43 + } 44 + type ComposerImageWithTransformation = ComposerImageBase & { 45 + transformed: ImageMeta 46 + manips?: ImageTransformation 47 + } 48 + 49 + export type ComposerImage = 50 + | ComposerImageWithoutTransformation 51 + | ComposerImageWithTransformation 52 + 53 + let _imageCacheDirectory: string 54 + 55 + function getImageCacheDirectory(): string | null { 56 + if (isNative) { 57 + return (_imageCacheDirectory ??= joinPath(cacheDirectory!, 'bsky-composer')) 58 + } 59 + 60 + return null 61 + } 62 + 63 + export async function createComposerImage( 64 + raw: ImageMeta, 65 + ): Promise<ComposerImageWithoutTransformation> { 66 + return { 67 + alt: '', 68 + source: { 69 + id: nanoid(), 70 + path: await moveIfNecessary(raw.path), 71 + width: raw.width, 72 + height: raw.height, 73 + mime: raw.mime, 74 + }, 75 + } 76 + } 77 + 78 + export type InitialImage = { 79 + uri: string 80 + width: number 81 + height: number 82 + altText?: string 83 + } 84 + 85 + export function createInitialImages( 86 + uris: InitialImage[] = [], 87 + ): ComposerImageWithoutTransformation[] { 88 + return uris.map(({uri, width, height, altText = ''}) => { 89 + return { 90 + alt: altText, 91 + source: { 92 + id: nanoid(), 93 + path: uri, 94 + width: width, 95 + height: height, 96 + mime: 'image/jpeg', 97 + }, 98 + } 99 + }) 100 + } 101 + 102 + export async function pasteImage( 103 + uri: string, 104 + ): Promise<ComposerImageWithoutTransformation> { 105 + const {width, height} = await getImageDim(uri) 106 + const match = /^data:(.+?);/.exec(uri) 107 + 108 + return { 109 + alt: '', 110 + source: { 111 + id: nanoid(), 112 + path: uri, 113 + width: width, 114 + height: height, 115 + mime: match ? match[1] : 'image/jpeg', 116 + }, 117 + } 118 + } 119 + 120 + export async function cropImage(img: ComposerImage): Promise<ComposerImage> { 121 + if (!isNative) { 122 + return img 123 + } 124 + 125 + // NOTE 126 + // on ios, react-native-image-crop-picker gives really bad quality 127 + // without specifying width and height. on android, however, the 128 + // crop stretches incorrectly if you do specify it. these are 129 + // both separate bugs in the library. we deal with that by 130 + // providing width & height for ios only 131 + // -prf 132 + 133 + const source = img.source 134 + const [w, h] = containImageRes(source.width, source.height, POST_IMG_MAX) 135 + 136 + // @todo: we're always passing the original image here, does image-cropper 137 + // allows for setting initial crop dimensions? -mary 138 + try { 139 + const cropped = await openCropper({ 140 + mediaType: 'photo', 141 + path: source.path, 142 + freeStyleCropEnabled: true, 143 + ...(isIOS ? {width: w, height: h} : {}), 144 + }) 145 + 146 + return { 147 + alt: img.alt, 148 + source: source, 149 + transformed: { 150 + path: await moveIfNecessary(cropped.path), 151 + width: cropped.width, 152 + height: cropped.height, 153 + mime: cropped.mime, 154 + }, 155 + } 156 + } catch (e) { 157 + if (e instanceof Error && e.message.includes('User cancelled')) { 158 + return img 159 + } 160 + 161 + throw e 162 + } 163 + } 164 + 165 + export async function manipulateImage( 166 + img: ComposerImage, 167 + trans: ImageTransformation, 168 + ): Promise<ComposerImage> { 169 + const rawActions: (Action | undefined)[] = [trans.crop && {crop: trans.crop}] 170 + 171 + const actions = rawActions.filter((a): a is Action => a !== undefined) 172 + 173 + if (actions.length === 0) { 174 + if (img.transformed === undefined) { 175 + return img 176 + } 177 + 178 + return {alt: img.alt, source: img.source} 179 + } 180 + 181 + const source = img.source 182 + const result = await manipulateAsync(source.path, actions, { 183 + format: SaveFormat.PNG, 184 + }) 185 + 186 + return { 187 + alt: img.alt, 188 + source: img.source, 189 + transformed: { 190 + path: await moveIfNecessary(result.uri), 191 + width: result.width, 192 + height: result.height, 193 + mime: 'image/png', 194 + }, 195 + manips: trans, 196 + } 197 + } 198 + 199 + export function resetImageManipulation( 200 + img: ComposerImage, 201 + ): ComposerImageWithoutTransformation { 202 + if (img.transformed !== undefined) { 203 + return {alt: img.alt, source: img.source} 204 + } 205 + 206 + return img 207 + } 208 + 209 + export async function compressImage(img: ComposerImage): Promise<ImageMeta> { 210 + const source = img.transformed || img.source 211 + 212 + const [w, h] = containImageRes(source.width, source.height, POST_IMG_MAX) 213 + const cacheDir = isNative && getImageCacheDirectory() 214 + 215 + for (let i = 10; i > 0; i--) { 216 + // Float precision 217 + const factor = i / 10 218 + 219 + const res = await manipulateAsync( 220 + source.path, 221 + [{resize: {width: w, height: h}}], 222 + { 223 + compress: factor, 224 + format: SaveFormat.JPEG, 225 + base64: true, 226 + }, 227 + ) 228 + 229 + const base64 = res.base64 230 + 231 + if (base64 !== undefined && getDataUriSize(base64) <= POST_IMG_MAX.size) { 232 + return { 233 + path: await moveIfNecessary(res.uri), 234 + width: res.width, 235 + height: res.height, 236 + mime: 'image/jpeg', 237 + } 238 + } 239 + 240 + if (cacheDir) { 241 + await deleteAsync(res.uri) 242 + } 243 + } 244 + 245 + throw new Error(`Unable to compress image`) 246 + } 247 + 248 + async function moveIfNecessary(from: string) { 249 + const cacheDir = isNative && getImageCacheDirectory() 250 + 251 + if (cacheDir && from.startsWith(cacheDir)) { 252 + const to = joinPath(cacheDir, nanoid(36)) 253 + 254 + await makeDirectoryAsync(cacheDir, {intermediates: true}) 255 + await moveAsync({from, to}) 256 + 257 + return to 258 + } 259 + 260 + return from 261 + } 262 + 263 + /** Purge files that were created to accomodate image manipulation */ 264 + export async function purgeTemporaryImageFiles() { 265 + const cacheDir = isNative && getImageCacheDirectory() 266 + 267 + if (cacheDir) { 268 + await deleteAsync(cacheDir, {idempotent: true}) 269 + await makeDirectoryAsync(cacheDir) 270 + } 271 + } 272 + 273 + function joinPath(a: string, b: string) { 274 + if (a.endsWith('/')) { 275 + if (b.startsWith('/')) { 276 + return a.slice(0, -1) + b 277 + } 278 + return a + b 279 + } else if (b.startsWith('/')) { 280 + return a + b 281 + } 282 + return a + '/' + b 283 + } 284 + 285 + function containImageRes( 286 + w: number, 287 + h: number, 288 + {width: maxW, height: maxH}: {width: number; height: number}, 289 + ): [width: number, height: number] { 290 + let scale = 1 291 + 292 + if (w > maxW || h > maxH) { 293 + scale = w > h ? maxW / w : maxH / h 294 + w = Math.floor(w * scale) 295 + h = Math.floor(h * scale) 296 + } 297 + 298 + return [w, h] 299 + }
+3 -10
src/state/modals/index.tsx
··· 3 3 import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api' 4 4 5 5 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 6 - import {GalleryModel} from '#/state/models/media/gallery' 7 - import {ImageModel} from '#/state/models/media/image' 6 + import {ComposerImage} from '../gallery' 8 7 9 8 export interface EditProfileModal { 10 9 name: 'edit-profile' ··· 37 36 ) => void 38 37 } 39 38 40 - export interface EditImageModal { 41 - name: 'edit-image' 42 - image: ImageModel 43 - gallery: GalleryModel 44 - } 45 - 46 39 export interface CropImageModal { 47 40 name: 'crop-image' 48 41 uri: string ··· 52 45 53 46 export interface AltTextImageModal { 54 47 name: 'alt-text-image' 55 - image: ImageModel 48 + image: ComposerImage 49 + onChange: (next: ComposerImage) => void 56 50 } 57 51 58 52 export interface DeleteAccountModal { ··· 139 133 // Posts 140 134 | AltTextImageModal 141 135 | CropImageModal 142 - | EditImageModal 143 136 | SelfLabelModal 144 137 145 138 // Bluesky access
-110
src/state/models/media/gallery.ts
··· 1 - import {makeAutoObservable, runInAction} from 'mobx' 2 - 3 - import {getImageDim} from 'lib/media/manip' 4 - import {openPicker} from 'lib/media/picker' 5 - import {ImageInitOptions, ImageModel} from './image' 6 - 7 - interface InitialImageUri { 8 - uri: string 9 - width: number 10 - height: number 11 - altText?: string 12 - } 13 - 14 - export class GalleryModel { 15 - images: ImageModel[] = [] 16 - 17 - constructor(uris?: InitialImageUri[]) { 18 - makeAutoObservable(this) 19 - 20 - if (uris) { 21 - this.addFromUris(uris) 22 - } 23 - } 24 - 25 - get isEmpty() { 26 - return this.size === 0 27 - } 28 - 29 - get size() { 30 - return this.images.length 31 - } 32 - 33 - get needsAltText() { 34 - return this.images.some(image => image.altText.trim() === '') 35 - } 36 - 37 - *add(image_: ImageInitOptions) { 38 - if (this.size >= 4) { 39 - return 40 - } 41 - 42 - // Temporarily enforce uniqueness but can eventually also use index 43 - if (!this.images.some(i => i.path === image_.path)) { 44 - const image = new ImageModel(image_) 45 - 46 - // Initial resize 47 - image.manipulate({}) 48 - this.images.push(image) 49 - } 50 - } 51 - 52 - async paste(uri: string) { 53 - if (this.size >= 4) { 54 - return 55 - } 56 - 57 - const {width, height} = await getImageDim(uri) 58 - 59 - const image = { 60 - path: uri, 61 - height, 62 - width, 63 - } 64 - 65 - runInAction(() => { 66 - this.add(image) 67 - }) 68 - } 69 - 70 - setAltText(image: ImageModel, altText: string) { 71 - image.setAltText(altText) 72 - } 73 - 74 - crop(image: ImageModel) { 75 - image.crop() 76 - } 77 - 78 - remove(image: ImageModel) { 79 - const index = this.images.findIndex(image_ => image_.path === image.path) 80 - this.images.splice(index, 1) 81 - } 82 - 83 - async previous(image: ImageModel) { 84 - image.previous() 85 - } 86 - 87 - async pick() { 88 - const images = await openPicker({ 89 - selectionLimit: 4 - this.size, 90 - allowsMultipleSelection: true, 91 - }) 92 - 93 - return await Promise.all( 94 - images.map(image => { 95 - this.add(image) 96 - }), 97 - ) 98 - } 99 - 100 - async addFromUris(uris: InitialImageUri[]) { 101 - for (const uriObj of uris) { 102 - this.add({ 103 - height: uriObj.height, 104 - width: uriObj.width, 105 - path: uriObj.uri, 106 - altText: uriObj.altText, 107 - }) 108 - } 109 - } 110 - }
-146
src/state/models/media/image.e2e.ts
··· 1 - import {Image as RNImage} from 'react-native-image-crop-picker' 2 - import {makeAutoObservable} from 'mobx' 3 - import {POST_IMG_MAX} from 'lib/constants' 4 - import {ActionCrop} from 'expo-image-manipulator' 5 - import {Position} from 'react-avatar-editor' 6 - import {Dimensions} from 'lib/media/types' 7 - 8 - export interface ImageManipulationAttributes { 9 - aspectRatio?: '4:3' | '1:1' | '3:4' | 'None' 10 - rotate?: number 11 - scale?: number 12 - position?: Position 13 - flipHorizontal?: boolean 14 - flipVertical?: boolean 15 - } 16 - 17 - export class ImageModel implements Omit<RNImage, 'size'> { 18 - path: string 19 - mime = 'image/jpeg' 20 - width: number 21 - height: number 22 - altText = '' 23 - cropped?: RNImage = undefined 24 - compressed?: RNImage = undefined 25 - 26 - // Web manipulation 27 - prev?: RNImage 28 - attributes: ImageManipulationAttributes = { 29 - aspectRatio: 'None', 30 - scale: 1, 31 - flipHorizontal: false, 32 - flipVertical: false, 33 - rotate: 0, 34 - } 35 - prevAttributes: ImageManipulationAttributes = {} 36 - 37 - constructor(image: Omit<RNImage, 'size'>) { 38 - makeAutoObservable(this) 39 - 40 - this.path = image.path 41 - this.width = image.width 42 - this.height = image.height 43 - } 44 - 45 - setRatio(aspectRatio: ImageManipulationAttributes['aspectRatio']) { 46 - this.attributes.aspectRatio = aspectRatio 47 - } 48 - 49 - setRotate(degrees: number) { 50 - this.attributes.rotate = degrees 51 - this.manipulate({}) 52 - } 53 - 54 - flipVertical() { 55 - this.attributes.flipVertical = !this.attributes.flipVertical 56 - this.manipulate({}) 57 - } 58 - 59 - flipHorizontal() { 60 - this.attributes.flipHorizontal = !this.attributes.flipHorizontal 61 - this.manipulate({}) 62 - } 63 - 64 - get ratioMultipliers() { 65 - return { 66 - '4:3': 4 / 3, 67 - '1:1': 1, 68 - '3:4': 3 / 4, 69 - None: this.width / this.height, 70 - } 71 - } 72 - 73 - getUploadDimensions( 74 - dimensions: Dimensions, 75 - maxDimensions: Dimensions = POST_IMG_MAX, 76 - as: ImageManipulationAttributes['aspectRatio'] = 'None', 77 - ) { 78 - const {width, height} = dimensions 79 - const {width: maxWidth, height: maxHeight} = maxDimensions 80 - 81 - return width < maxWidth && height < maxHeight 82 - ? { 83 - width, 84 - height, 85 - } 86 - : this.getResizedDimensions(as, POST_IMG_MAX.width) 87 - } 88 - 89 - getResizedDimensions( 90 - as: ImageManipulationAttributes['aspectRatio'] = 'None', 91 - maxSide: number, 92 - ) { 93 - const ratioMultiplier = this.ratioMultipliers[as] 94 - 95 - if (ratioMultiplier === 1) { 96 - return { 97 - height: maxSide, 98 - width: maxSide, 99 - } 100 - } 101 - 102 - if (ratioMultiplier < 1) { 103 - return { 104 - width: maxSide * ratioMultiplier, 105 - height: maxSide, 106 - } 107 - } 108 - 109 - return { 110 - width: maxSide, 111 - height: maxSide / ratioMultiplier, 112 - } 113 - } 114 - 115 - setAltText(altText: string) { 116 - this.altText = altText.trim() 117 - } 118 - 119 - // Only compress prior to upload 120 - async compress() { 121 - // do nothing 122 - } 123 - 124 - // Mobile 125 - async crop() { 126 - // do nothing 127 - } 128 - 129 - // Web manipulation 130 - async manipulate( 131 - _attributes: { 132 - crop?: ActionCrop['crop'] 133 - } & ImageManipulationAttributes, 134 - ) { 135 - // do nothing 136 - } 137 - 138 - resetCropped() { 139 - this.manipulate({}) 140 - } 141 - 142 - previous() { 143 - this.cropped = this.prev 144 - this.attributes = this.prevAttributes 145 - } 146 - }
-310
src/state/models/media/image.ts
··· 1 - import {Image as RNImage} from 'react-native-image-crop-picker' 2 - import * as ImageManipulator from 'expo-image-manipulator' 3 - import {ActionCrop, FlipType, SaveFormat} from 'expo-image-manipulator' 4 - import {makeAutoObservable, runInAction} from 'mobx' 5 - import {Position} from 'react-avatar-editor' 6 - 7 - import {logger} from '#/logger' 8 - import {POST_IMG_MAX} from 'lib/constants' 9 - import {openCropper} from 'lib/media/picker' 10 - import {Dimensions} from 'lib/media/types' 11 - import {getDataUriSize} from 'lib/media/util' 12 - import {isIOS} from 'platform/detection' 13 - 14 - export interface ImageManipulationAttributes { 15 - aspectRatio?: '4:3' | '1:1' | '3:4' | 'None' 16 - rotate?: number 17 - scale?: number 18 - position?: Position 19 - flipHorizontal?: boolean 20 - flipVertical?: boolean 21 - } 22 - 23 - export interface ImageInitOptions { 24 - path: string 25 - width: number 26 - height: number 27 - altText?: string 28 - } 29 - 30 - const MAX_IMAGE_SIZE_IN_BYTES = 976560 31 - 32 - export class ImageModel implements Omit<RNImage, 'size'> { 33 - path: string 34 - mime = 'image/jpeg' 35 - width: number 36 - height: number 37 - altText = '' 38 - cropped?: RNImage = undefined 39 - compressed?: RNImage = undefined 40 - 41 - // Web manipulation 42 - prev?: RNImage 43 - attributes: ImageManipulationAttributes = { 44 - aspectRatio: 'None', 45 - scale: 1, 46 - flipHorizontal: false, 47 - flipVertical: false, 48 - rotate: 0, 49 - } 50 - prevAttributes: ImageManipulationAttributes = {} 51 - 52 - constructor(image: ImageInitOptions) { 53 - makeAutoObservable(this) 54 - 55 - this.path = image.path 56 - this.width = image.width 57 - this.height = image.height 58 - if (image.altText !== undefined) { 59 - this.setAltText(image.altText) 60 - } 61 - } 62 - 63 - setRatio(aspectRatio: ImageManipulationAttributes['aspectRatio']) { 64 - this.attributes.aspectRatio = aspectRatio 65 - } 66 - 67 - setRotate(degrees: number) { 68 - this.attributes.rotate = degrees 69 - this.manipulate({}) 70 - } 71 - 72 - flipVertical() { 73 - this.attributes.flipVertical = !this.attributes.flipVertical 74 - this.manipulate({}) 75 - } 76 - 77 - flipHorizontal() { 78 - this.attributes.flipHorizontal = !this.attributes.flipHorizontal 79 - this.manipulate({}) 80 - } 81 - 82 - get ratioMultipliers() { 83 - return { 84 - '4:3': 4 / 3, 85 - '1:1': 1, 86 - '3:4': 3 / 4, 87 - None: this.width / this.height, 88 - } 89 - } 90 - 91 - getUploadDimensions( 92 - dimensions: Dimensions, 93 - maxDimensions: Dimensions = POST_IMG_MAX, 94 - as: ImageManipulationAttributes['aspectRatio'] = 'None', 95 - ) { 96 - const {width, height} = dimensions 97 - const {width: maxWidth, height: maxHeight} = maxDimensions 98 - 99 - return width < maxWidth && height < maxHeight 100 - ? { 101 - width, 102 - height, 103 - } 104 - : this.getResizedDimensions(as, POST_IMG_MAX.width) 105 - } 106 - 107 - getResizedDimensions( 108 - as: ImageManipulationAttributes['aspectRatio'] = 'None', 109 - maxSide: number, 110 - ) { 111 - const ratioMultiplier = this.ratioMultipliers[as] 112 - 113 - if (ratioMultiplier === 1) { 114 - return { 115 - height: maxSide, 116 - width: maxSide, 117 - } 118 - } 119 - 120 - if (ratioMultiplier < 1) { 121 - return { 122 - width: maxSide * ratioMultiplier, 123 - height: maxSide, 124 - } 125 - } 126 - 127 - return { 128 - width: maxSide, 129 - height: maxSide / ratioMultiplier, 130 - } 131 - } 132 - 133 - setAltText(altText: string) { 134 - this.altText = altText.trim() 135 - } 136 - 137 - // Only compress prior to upload 138 - async compress() { 139 - for (let i = 10; i > 0; i--) { 140 - // Float precision 141 - const factor = Math.round(i) / 10 142 - const compressed = await ImageManipulator.manipulateAsync( 143 - this.cropped?.path ?? this.path, 144 - undefined, 145 - { 146 - compress: factor, 147 - base64: true, 148 - format: SaveFormat.JPEG, 149 - }, 150 - ) 151 - 152 - if (compressed.base64 !== undefined) { 153 - const size = getDataUriSize(compressed.base64) 154 - 155 - if (size < MAX_IMAGE_SIZE_IN_BYTES) { 156 - runInAction(() => { 157 - this.compressed = { 158 - mime: 'image/jpeg', 159 - path: compressed.uri, 160 - size, 161 - ...compressed, 162 - } 163 - }) 164 - return 165 - } 166 - } 167 - } 168 - 169 - // Compression fails when removing redundant information is not possible. 170 - // This can be tested with images that have high variance in noise. 171 - throw new Error('Failed to compress image') 172 - } 173 - 174 - // Mobile 175 - async crop() { 176 - try { 177 - // NOTE 178 - // on ios, react-native-image-crop-picker gives really bad quality 179 - // without specifying width and height. on android, however, the 180 - // crop stretches incorrectly if you do specify it. these are 181 - // both separate bugs in the library. we deal with that by 182 - // providing width & height for ios only 183 - // -prf 184 - const {width, height} = this.getUploadDimensions({ 185 - width: this.width, 186 - height: this.height, 187 - }) 188 - 189 - const cropped = await openCropper({ 190 - mediaType: 'photo', 191 - path: this.path, 192 - freeStyleCropEnabled: true, 193 - ...(isIOS ? {width, height} : {}), 194 - }) 195 - 196 - runInAction(() => { 197 - this.cropped = cropped 198 - }) 199 - } catch (err) { 200 - logger.error('Failed to crop photo', {message: err}) 201 - } 202 - } 203 - 204 - // Web manipulation 205 - async manipulate( 206 - attributes: { 207 - crop?: ActionCrop['crop'] 208 - } & ImageManipulationAttributes, 209 - ) { 210 - let uploadWidth: number | undefined 211 - let uploadHeight: number | undefined 212 - 213 - const {aspectRatio, crop, position, scale} = attributes 214 - const modifiers = [] 215 - 216 - if (this.attributes.flipHorizontal) { 217 - modifiers.push({flip: FlipType.Horizontal}) 218 - } 219 - 220 - if (this.attributes.flipVertical) { 221 - modifiers.push({flip: FlipType.Vertical}) 222 - } 223 - 224 - if (this.attributes.rotate !== undefined) { 225 - modifiers.push({rotate: this.attributes.rotate}) 226 - } 227 - 228 - if (crop !== undefined) { 229 - const croppedHeight = crop.height * this.height 230 - const croppedWidth = crop.width * this.width 231 - modifiers.push({ 232 - crop: { 233 - originX: crop.originX * this.width, 234 - originY: crop.originY * this.height, 235 - height: croppedHeight, 236 - width: croppedWidth, 237 - }, 238 - }) 239 - 240 - const uploadDimensions = this.getUploadDimensions( 241 - {width: croppedWidth, height: croppedHeight}, 242 - POST_IMG_MAX, 243 - aspectRatio, 244 - ) 245 - 246 - uploadWidth = uploadDimensions.width 247 - uploadHeight = uploadDimensions.height 248 - } else { 249 - const uploadDimensions = this.getUploadDimensions( 250 - {width: this.width, height: this.height}, 251 - POST_IMG_MAX, 252 - aspectRatio, 253 - ) 254 - 255 - uploadWidth = uploadDimensions.width 256 - uploadHeight = uploadDimensions.height 257 - } 258 - 259 - if (scale !== undefined) { 260 - this.attributes.scale = scale 261 - } 262 - 263 - if (position !== undefined) { 264 - this.attributes.position = position 265 - } 266 - 267 - if (aspectRatio !== undefined) { 268 - this.attributes.aspectRatio = aspectRatio 269 - } 270 - 271 - const ratioMultiplier = 272 - this.ratioMultipliers[this.attributes.aspectRatio ?? '1:1'] 273 - 274 - const result = await ImageManipulator.manipulateAsync( 275 - this.path, 276 - [ 277 - ...modifiers, 278 - { 279 - resize: 280 - ratioMultiplier > 1 ? {width: uploadWidth} : {height: uploadHeight}, 281 - }, 282 - ], 283 - { 284 - base64: true, 285 - format: SaveFormat.JPEG, 286 - }, 287 - ) 288 - 289 - runInAction(() => { 290 - this.cropped = { 291 - mime: 'image/jpeg', 292 - path: result.uri, 293 - size: 294 - result.base64 !== undefined 295 - ? getDataUriSize(result.base64) 296 - : MAX_IMAGE_SIZE_IN_BYTES + 999, // shouldn't hit this unless manipulation fails 297 - ...result, 298 - } 299 - }) 300 - } 301 - 302 - resetCropped() { 303 - this.manipulate({}) 304 - } 305 - 306 - previous() { 307 - this.cropped = this.prev 308 - this.attributes = this.prevAttributes 309 - } 310 - }
+6 -1
src/state/shell/composer/index.tsx
··· 9 9 import {useLingui} from '@lingui/react' 10 10 11 11 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 12 + import {purgeTemporaryImageFiles} from '#/state/gallery' 12 13 import * as Toast from '#/view/com/util/Toast' 13 14 14 15 export interface ComposerOptsPostRef { ··· 77 78 78 79 const closeComposer = useNonReactiveCallback(() => { 79 80 let wasOpen = !!state 80 - setState(undefined) 81 + if (wasOpen) { 82 + setState(undefined) 83 + purgeTemporaryImageFiles() 84 + } 85 + 81 86 return wasOpen 82 87 }) 83 88
+39 -28
src/view/com/composer/Composer.tsx
··· 44 44 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 45 45 import {msg, Trans} from '@lingui/macro' 46 46 import {useLingui} from '@lingui/react' 47 - import {observer} from 'mobx-react-lite' 48 47 49 48 import {useAnalytics} from '#/lib/analytics/analytics' 50 49 import * as apilib from '#/lib/api/index' ··· 68 67 import {isAndroid, isIOS, isNative, isWeb} from '#/platform/detection' 69 68 import {useDialogStateControlContext} from '#/state/dialogs' 70 69 import {emitPostCreated} from '#/state/events' 70 + import {ComposerImage, createInitialImages, pasteImage} from '#/state/gallery' 71 71 import {useModalControls} from '#/state/modals' 72 72 import {useModals} from '#/state/modals' 73 - import {GalleryModel} from '#/state/models/media/gallery' 74 73 import {useRequireAltTextEnabled} from '#/state/preferences' 75 74 import { 76 75 toPostLanguages, ··· 122 121 import * as Prompt from '#/components/Prompt' 123 122 import {Text as NewText} from '#/components/Typography' 124 123 124 + const MAX_IMAGES = 4 125 + 125 126 type CancelRef = { 126 127 onPressCancel: () => void 127 128 } 128 129 129 130 type Props = ComposerOpts 130 - export const ComposePost = observer(function ComposePost({ 131 + export const ComposePost = ({ 131 132 replyTo, 132 133 onPost, 133 134 quote: initQuote, ··· 139 140 cancelRef, 140 141 }: Props & { 141 142 cancelRef?: React.RefObject<CancelRef> 142 - }) { 143 + }) => { 143 144 const {currentAccount} = useSession() 144 145 const agent = useAgent() 145 146 const {data: currentProfile} = useProfileQuery({did: currentAccount!.did}) ··· 212 213 ) 213 214 const [postgate, setPostgate] = useState(createPostgateRecord({post: ''})) 214 215 215 - const gallery = useMemo( 216 - () => new GalleryModel(initImageUris), 217 - [initImageUris], 216 + const [images, setImages] = useState<ComposerImage[]>(() => 217 + createInitialImages(initImageUris), 218 218 ) 219 219 const onClose = useCallback(() => { 220 220 closeComposer() ··· 233 233 const onPressCancel = useCallback(() => { 234 234 if ( 235 235 graphemeLength > 0 || 236 - !gallery.isEmpty || 236 + images.length !== 0 || 237 237 extGif || 238 238 videoUploadState.status !== 'idle' 239 239 ) { ··· 246 246 }, [ 247 247 extGif, 248 248 graphemeLength, 249 - gallery.isEmpty, 249 + images.length, 250 250 closeAllDialogs, 251 251 discardPromptControl, 252 252 onClose, ··· 299 299 [extLink, setExtLink], 300 300 ) 301 301 302 + const onImageAdd = useCallback( 303 + (next: ComposerImage[]) => { 304 + setImages(prev => prev.concat(next.slice(0, MAX_IMAGES - prev.length))) 305 + }, 306 + [setImages], 307 + ) 308 + 302 309 const onPhotoPasted = useCallback( 303 310 async (uri: string) => { 304 311 track('Composer:PastedPhotos') 305 312 if (uri.startsWith('data:video/')) { 306 313 selectVideo({uri, type: 'video', height: 0, width: 0}) 307 314 } else { 308 - await gallery.paste(uri) 315 + const res = await pasteImage(uri) 316 + onImageAdd([res]) 309 317 } 310 318 }, 311 - [gallery, track, selectVideo], 319 + [track, selectVideo, onImageAdd], 312 320 ) 313 321 314 322 const isAltTextRequiredAndMissing = useMemo(() => { 315 323 if (!requireAltTextEnabled) return false 316 324 317 - if (gallery.needsAltText) return true 325 + if (images.some(img => img.alt === '')) return true 326 + 318 327 if (extGif) { 319 328 if (!extLink?.meta?.description) return true 320 329 ··· 322 331 if (!parsedAlt.isPreferred) return true 323 332 } 324 333 return false 325 - }, [gallery.needsAltText, extLink, extGif, requireAltTextEnabled]) 334 + }, [images, extLink, extGif, requireAltTextEnabled]) 326 335 327 336 const onPressPublish = React.useCallback( 328 337 async (finishedUploading?: boolean) => { ··· 347 356 348 357 if ( 349 358 richtext.text.trim().length === 0 && 350 - gallery.isEmpty && 359 + images.length === 0 && 351 360 !extLink && 352 361 !quote && 353 362 videoUploadState.status === 'idle' ··· 368 377 await apilib.post(agent, { 369 378 rawText: richtext.text, 370 379 replyTo: replyTo?.uri, 371 - images: gallery.images, 380 + images, 372 381 quote, 373 382 extLink, 374 383 labels, ··· 405 414 } catch (e: any) { 406 415 logger.error(e, { 407 416 message: `Composer: create post failed`, 408 - hasImages: gallery.size > 0, 417 + hasImages: images.length > 0, 409 418 }) 410 419 411 420 if (extLink) { ··· 427 436 } finally { 428 437 if (postUri) { 429 438 logEvent('post:create', { 430 - imageCount: gallery.size, 439 + imageCount: images.length, 431 440 isReply: replyTo != null, 432 441 hasLink: extLink != null, 433 442 hasQuote: quote != null, ··· 436 445 }) 437 446 } 438 447 track('Create Post', { 439 - imageCount: gallery.size, 448 + imageCount: images.length, 440 449 }) 441 450 if (replyTo && replyTo.uri) track('Post:Reply') 442 451 } ··· 472 481 agent, 473 482 captions, 474 483 extLink, 475 - gallery.images, 476 - gallery.isEmpty, 477 - gallery.size, 484 + images, 478 485 graphemeLength, 479 486 isAltTextRequiredAndMissing, 480 487 isProcessing, ··· 516 523 : _(msg`What's up?`) 517 524 518 525 const canSelectImages = 519 - gallery.size < 4 && 526 + images.length < MAX_IMAGES && 520 527 !extLink && 521 528 videoUploadState.status === 'idle' && 522 529 !videoUploadState.video 523 530 const hasMedia = 524 - gallery.size > 0 || Boolean(extLink) || Boolean(videoUploadState.video) 531 + images.length > 0 || Boolean(extLink) || Boolean(videoUploadState.video) 525 532 526 533 const onEmojiButtonPress = useCallback(() => { 527 534 openEmojiPicker?.(textInput.current?.getCursorPosition()) ··· 716 723 /> 717 724 </View> 718 725 719 - <Gallery gallery={gallery} /> 720 - {gallery.isEmpty && extLink && ( 726 + <Gallery images={images} onChange={setImages} /> 727 + {images.length === 0 && extLink && ( 721 728 <View style={a.relative}> 722 729 <ExternalEmbed 723 730 link={extLink} ··· 801 808 <VideoUploadToolbar state={videoUploadState} /> 802 809 ) : ( 803 810 <ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}> 804 - <SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} /> 811 + <SelectPhotoBtn 812 + size={images.length} 813 + disabled={!canSelectImages} 814 + onAdd={onImageAdd} 815 + /> 805 816 <SelectVideoBtn 806 817 onSelectVideo={selectVideo} 807 818 disabled={!canSelectImages} 808 819 setError={setError} 809 820 /> 810 - <OpenCameraBtn gallery={gallery} disabled={!canSelectImages} /> 821 + <OpenCameraBtn disabled={!canSelectImages} onAdd={onImageAdd} /> 811 822 <SelectGifBtn 812 823 onClose={focusTextInput} 813 824 onSelectGif={onSelectGif} ··· 842 853 /> 843 854 </KeyboardAvoidingView> 844 855 ) 845 - }) 856 + } 846 857 847 858 export function useComposerCancelRef() { 848 859 return useRef<CancelRef>(null)
+1 -1
src/view/com/composer/ExternalEmbed.tsx
··· 26 26 title: link.meta?.title ?? link.uri, 27 27 uri: link.uri, 28 28 description: link.meta?.description ?? '', 29 - thumb: link.localThumb?.path, 29 + thumb: link.localThumb?.source.path, 30 30 }, 31 31 [link], 32 32 )
+1 -1
src/view/com/composer/GifAltText.tsx
··· 43 43 title: linkProp.meta?.title ?? linkProp.uri, 44 44 uri: linkProp.uri, 45 45 description: linkProp.meta?.description ?? '', 46 - thumb: linkProp.localThumb?.path, 46 + thumb: linkProp.localThumb?.source.path, 47 47 }, 48 48 params: parseEmbedPlayerFromUrl(linkProp.uri), 49 49 }
+178 -158
src/view/com/composer/photos/Gallery.tsx
··· 1 - import React, {useState} from 'react' 2 - import {ImageStyle, Keyboard, LayoutChangeEvent} from 'react-native' 3 - import {StyleSheet, TouchableOpacity, View} from 'react-native' 1 + import React from 'react' 2 + import { 3 + ImageStyle, 4 + Keyboard, 5 + LayoutChangeEvent, 6 + StyleSheet, 7 + TouchableOpacity, 8 + View, 9 + ViewStyle, 10 + } from 'react-native' 4 11 import {Image} from 'expo-image' 5 12 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 6 13 import {msg, Trans} from '@lingui/macro' 7 14 import {useLingui} from '@lingui/react' 8 - import {observer} from 'mobx-react-lite' 9 15 10 16 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 11 17 import {Dimensions} from '#/lib/media/types' 12 18 import {colors, s} from '#/lib/styles' 13 19 import {isNative} from '#/platform/detection' 20 + import {ComposerImage, cropImage} from '#/state/gallery' 14 21 import {useModalControls} from '#/state/modals' 15 - import {GalleryModel} from '#/state/models/media/gallery' 16 22 import {Text} from '#/view/com/util/text/Text' 17 23 import {useTheme} from '#/alf' 18 24 19 25 const IMAGE_GAP = 8 20 26 21 27 interface GalleryProps { 22 - gallery: GalleryModel 28 + images: ComposerImage[] 29 + onChange: (next: ComposerImage[]) => void 23 30 } 24 31 25 - export const Gallery = (props: GalleryProps) => { 26 - const [containerInfo, setContainerInfo] = useState<Dimensions | undefined>() 32 + export let Gallery = (props: GalleryProps): React.ReactNode => { 33 + const [containerInfo, setContainerInfo] = React.useState<Dimensions>() 27 34 28 35 const onLayout = (evt: LayoutChangeEvent) => { 29 36 const {width, height} = evt.nativeEvent.layout ··· 41 48 </View> 42 49 ) 43 50 } 51 + Gallery = React.memo(Gallery) 44 52 45 53 interface GalleryInnerProps extends GalleryProps { 46 54 containerInfo: Dimensions 47 55 } 48 56 49 - const GalleryInner = observer(function GalleryImpl({ 50 - gallery, 51 - containerInfo, 52 - }: GalleryInnerProps) { 53 - const {_} = useLingui() 57 + const GalleryInner = ({images, containerInfo, onChange}: GalleryInnerProps) => { 54 58 const {isMobile} = useWebMediaQueries() 55 - const {openModal} = useModalControls() 56 - const t = useTheme() 57 59 58 - let side: number 59 - 60 - if (gallery.size === 1) { 61 - side = 250 62 - } else { 63 - side = (containerInfo.width - IMAGE_GAP * (gallery.size - 1)) / gallery.size 64 - } 65 - 66 - const imageStyle = { 67 - height: side, 68 - width: side, 69 - } 60 + const {altTextControlStyle, imageControlsStyle, imageStyle} = 61 + React.useMemo(() => { 62 + const side = 63 + images.length === 1 64 + ? 250 65 + : (containerInfo.width - IMAGE_GAP * (images.length - 1)) / 66 + images.length 70 67 71 - const isOverflow = isMobile && gallery.size > 2 68 + const isOverflow = isMobile && images.length > 2 72 69 73 - const altTextControlStyle = isOverflow 74 - ? { 75 - left: 4, 76 - bottom: 4, 77 - } 78 - : !isMobile && gallery.size < 3 79 - ? { 80 - left: 8, 81 - top: 8, 82 - } 83 - : { 84 - left: 4, 85 - top: 4, 70 + return { 71 + altTextControlStyle: isOverflow 72 + ? {left: 4, bottom: 4} 73 + : !isMobile && images.length < 3 74 + ? {left: 8, top: 8} 75 + : {left: 4, top: 4}, 76 + imageControlsStyle: { 77 + display: 'flex' as const, 78 + flexDirection: 'row' as const, 79 + position: 'absolute' as const, 80 + ...(isOverflow 81 + ? {top: 4, right: 4, gap: 4} 82 + : !isMobile && images.length < 3 83 + ? {top: 8, right: 8, gap: 8} 84 + : {top: 4, right: 4, gap: 4}), 85 + zIndex: 1, 86 + }, 87 + imageStyle: { 88 + height: side, 89 + width: side, 90 + }, 86 91 } 87 - 88 - const imageControlsStyle = { 89 - display: 'flex' as const, 90 - flexDirection: 'row' as const, 91 - position: 'absolute' as const, 92 - ...(isOverflow 93 - ? { 94 - top: 4, 95 - right: 4, 96 - gap: 4, 97 - } 98 - : !isMobile && gallery.size < 3 99 - ? { 100 - top: 8, 101 - right: 8, 102 - gap: 8, 103 - } 104 - : { 105 - top: 4, 106 - right: 4, 107 - gap: 4, 108 - }), 109 - zIndex: 1, 110 - } 92 + }, [images.length, containerInfo, isMobile]) 111 93 112 - return !gallery.isEmpty ? ( 94 + return images.length !== 0 ? ( 113 95 <> 114 96 <View testID="selectedPhotosView" style={styles.gallery}> 115 - {gallery.images.map(image => ( 116 - <View key={`selected-image-${image.path}`} style={[imageStyle]}> 117 - <TouchableOpacity 118 - testID="altTextButton" 119 - accessibilityRole="button" 120 - accessibilityLabel={_(msg`Add alt text`)} 121 - accessibilityHint="" 122 - onPress={() => { 123 - Keyboard.dismiss() 124 - openModal({ 125 - name: 'alt-text-image', 126 - image, 127 - }) 128 - }} 129 - style={[styles.altTextControl, altTextControlStyle]}> 130 - {image.altText.length > 0 ? ( 131 - <FontAwesomeIcon 132 - icon="check" 133 - size={10} 134 - style={{color: t.palette.white}} 135 - /> 136 - ) : ( 137 - <FontAwesomeIcon 138 - icon="plus" 139 - size={10} 140 - style={{color: t.palette.white}} 141 - /> 142 - )} 143 - <Text style={styles.altTextControlLabel} accessible={false}> 144 - <Trans>ALT</Trans> 145 - </Text> 146 - </TouchableOpacity> 147 - <View style={imageControlsStyle}> 148 - <TouchableOpacity 149 - testID="editPhotoButton" 150 - accessibilityRole="button" 151 - accessibilityLabel={_(msg`Edit image`)} 152 - accessibilityHint="" 153 - onPress={() => { 154 - if (isNative) { 155 - gallery.crop(image) 156 - } else { 157 - openModal({ 158 - name: 'edit-image', 159 - image, 160 - gallery, 161 - }) 162 - } 163 - }} 164 - style={styles.imageControl}> 165 - <FontAwesomeIcon 166 - icon="pen" 167 - size={12} 168 - style={{color: colors.white}} 169 - /> 170 - </TouchableOpacity> 171 - <TouchableOpacity 172 - testID="removePhotoButton" 173 - accessibilityRole="button" 174 - accessibilityLabel={_(msg`Remove image`)} 175 - accessibilityHint="" 176 - onPress={() => gallery.remove(image)} 177 - style={styles.imageControl}> 178 - <FontAwesomeIcon 179 - icon="xmark" 180 - size={16} 181 - style={{color: colors.white}} 182 - /> 183 - </TouchableOpacity> 184 - </View> 185 - <TouchableOpacity 186 - accessibilityRole="button" 187 - accessibilityLabel={_(msg`Add alt text`)} 188 - accessibilityHint="" 189 - onPress={() => { 190 - Keyboard.dismiss() 191 - openModal({ 192 - name: 'alt-text-image', 193 - image, 194 - }) 97 + {images.map((image, index) => { 98 + return ( 99 + <GalleryItem 100 + key={image.source.id} 101 + image={image} 102 + altTextControlStyle={altTextControlStyle} 103 + imageControlsStyle={imageControlsStyle} 104 + imageStyle={imageStyle} 105 + onChange={next => { 106 + onChange( 107 + images.map(i => (i.source === image.source ? next : i)), 108 + ) 195 109 }} 196 - style={styles.altTextHiddenRegion} 197 - /> 110 + onRemove={() => { 111 + const next = images.slice() 112 + next.splice(index, 1) 198 113 199 - <Image 200 - testID="selectedPhotoImage" 201 - style={[styles.image, imageStyle] as ImageStyle} 202 - source={{ 203 - uri: image.cropped?.path ?? image.path, 114 + onChange(next) 204 115 }} 205 - accessible={true} 206 - accessibilityIgnoresInvertColors 207 116 /> 208 - </View> 209 - ))} 117 + ) 118 + })} 210 119 </View> 211 120 <AltTextReminder /> 212 121 </> 213 122 ) : null 214 - }) 123 + } 124 + 125 + type GalleryItemProps = { 126 + image: ComposerImage 127 + altTextControlStyle?: ViewStyle 128 + imageControlsStyle?: ViewStyle 129 + imageStyle?: ViewStyle 130 + onChange: (next: ComposerImage) => void 131 + onRemove: () => void 132 + } 133 + 134 + const GalleryItem = ({ 135 + image, 136 + altTextControlStyle, 137 + imageControlsStyle, 138 + imageStyle, 139 + onChange, 140 + onRemove, 141 + }: GalleryItemProps): React.ReactNode => { 142 + const {_} = useLingui() 143 + const t = useTheme() 144 + const {openModal} = useModalControls() 145 + 146 + const onImageEdit = () => { 147 + if (isNative) { 148 + cropImage(image).then(next => { 149 + onChange(next) 150 + }) 151 + } 152 + } 153 + 154 + const onAltTextEdit = () => { 155 + Keyboard.dismiss() 156 + openModal({name: 'alt-text-image', image, onChange}) 157 + } 158 + 159 + return ( 160 + <View style={imageStyle}> 161 + <TouchableOpacity 162 + testID="altTextButton" 163 + accessibilityRole="button" 164 + accessibilityLabel={_(msg`Add alt text`)} 165 + accessibilityHint="" 166 + onPress={onAltTextEdit} 167 + style={[styles.altTextControl, altTextControlStyle]}> 168 + {image.alt.length !== 0 ? ( 169 + <FontAwesomeIcon 170 + icon="check" 171 + size={10} 172 + style={{color: t.palette.white}} 173 + /> 174 + ) : ( 175 + <FontAwesomeIcon 176 + icon="plus" 177 + size={10} 178 + style={{color: t.palette.white}} 179 + /> 180 + )} 181 + <Text style={styles.altTextControlLabel} accessible={false}> 182 + <Trans>ALT</Trans> 183 + </Text> 184 + </TouchableOpacity> 185 + <View style={imageControlsStyle}> 186 + {isNative && ( 187 + <TouchableOpacity 188 + testID="editPhotoButton" 189 + accessibilityRole="button" 190 + accessibilityLabel={_(msg`Edit image`)} 191 + accessibilityHint="" 192 + onPress={onImageEdit} 193 + style={styles.imageControl}> 194 + <FontAwesomeIcon 195 + icon="pen" 196 + size={12} 197 + style={{color: colors.white}} 198 + /> 199 + </TouchableOpacity> 200 + )} 201 + <TouchableOpacity 202 + testID="removePhotoButton" 203 + accessibilityRole="button" 204 + accessibilityLabel={_(msg`Remove image`)} 205 + accessibilityHint="" 206 + onPress={onRemove} 207 + style={styles.imageControl}> 208 + <FontAwesomeIcon 209 + icon="xmark" 210 + size={16} 211 + style={{color: colors.white}} 212 + /> 213 + </TouchableOpacity> 214 + </View> 215 + <TouchableOpacity 216 + accessibilityRole="button" 217 + accessibilityLabel={_(msg`Add alt text`)} 218 + accessibilityHint="" 219 + onPress={onAltTextEdit} 220 + style={styles.altTextHiddenRegion} 221 + /> 222 + 223 + <Image 224 + testID="selectedPhotoImage" 225 + style={[styles.image, imageStyle] as ImageStyle} 226 + source={{ 227 + uri: (image.transformed ?? image.source).path, 228 + }} 229 + accessible={true} 230 + accessibilityIgnoresInvertColors 231 + /> 232 + </View> 233 + ) 234 + } 215 235 216 236 export function AltTextReminder() { 217 237 const t = useTheme()
+8 -5
src/view/com/composer/photos/OpenCameraBtn.tsx
··· 9 9 import {openCamera} from '#/lib/media/picker' 10 10 import {logger} from '#/logger' 11 11 import {isMobileWeb, isNative} from '#/platform/detection' 12 - import {GalleryModel} from '#/state/models/media/gallery' 12 + import {ComposerImage, createComposerImage} from '#/state/gallery' 13 13 import {atoms as a, useTheme} from '#/alf' 14 14 import {Button} from '#/components/Button' 15 15 import {Camera_Stroke2_Corner0_Rounded as Camera} from '#/components/icons/Camera' 16 16 17 17 type Props = { 18 - gallery: GalleryModel 19 18 disabled?: boolean 19 + onAdd: (next: ComposerImage[]) => void 20 20 } 21 21 22 - export function OpenCameraBtn({gallery, disabled}: Props) { 22 + export function OpenCameraBtn({disabled, onAdd}: Props) { 23 23 const {track} = useAnalytics() 24 24 const {_} = useLingui() 25 25 const {requestCameraAccessIfNeeded} = useCameraPermission() ··· 48 48 if (mediaPermissionRes) { 49 49 await MediaLibrary.createAssetAsync(img.path) 50 50 } 51 - gallery.add(img) 51 + 52 + const res = await createComposerImage(img) 53 + 54 + onAdd([res]) 52 55 } catch (err: any) { 53 56 // ignore 54 57 logger.warn('Error using camera', {error: err}) 55 58 } 56 59 }, [ 57 - gallery, 60 + onAdd, 58 61 track, 59 62 requestCameraAccessIfNeeded, 60 63 mediaPermissionRes,
+16 -5
src/view/com/composer/photos/SelectPhotoBtn.tsx
··· 5 5 6 6 import {useAnalytics} from '#/lib/analytics/analytics' 7 7 import {usePhotoLibraryPermission} from '#/lib/hooks/usePermissions' 8 + import {openPicker} from '#/lib/media/picker' 8 9 import {isNative} from '#/platform/detection' 9 - import {GalleryModel} from '#/state/models/media/gallery' 10 + import {ComposerImage, createComposerImage} from '#/state/gallery' 10 11 import {atoms as a, useTheme} from '#/alf' 11 12 import {Button} from '#/components/Button' 12 13 import {Image_Stroke2_Corner0_Rounded as Image} from '#/components/icons/Image' 13 14 14 15 type Props = { 15 - gallery: GalleryModel 16 + size: number 16 17 disabled?: boolean 18 + onAdd: (next: ComposerImage[]) => void 17 19 } 18 20 19 - export function SelectPhotoBtn({gallery, disabled}: Props) { 21 + export function SelectPhotoBtn({size, disabled, onAdd}: Props) { 20 22 const {track} = useAnalytics() 21 23 const {_} = useLingui() 22 24 const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() ··· 29 31 return 30 32 } 31 33 32 - gallery.pick() 33 - }, [track, requestPhotoAccessIfNeeded, gallery]) 34 + const images = await openPicker({ 35 + selectionLimit: 4 - size, 36 + allowsMultipleSelection: true, 37 + }) 38 + 39 + const results = await Promise.all( 40 + images.map(img => createComposerImage(img)), 41 + ) 42 + 43 + onAdd(results) 44 + }, [track, requestPhotoAccessIfNeeded, size, onAdd]) 34 45 35 46 return ( 36 47 <Button
+4 -3
src/view/com/composer/useExternalLinkFetch.ts
··· 3 3 import {useLingui} from '@lingui/react' 4 4 5 5 import {logger} from '#/logger' 6 + import {createComposerImage} from '#/state/gallery' 6 7 import {useFetchDid} from '#/state/queries/handle' 7 8 import {useGetPost} from '#/state/queries/post' 8 9 import {useAgent} from '#/state/session' ··· 26 27 isBskyStartUrl, 27 28 isShortLink, 28 29 } from 'lib/strings/url-helpers' 29 - import {ImageModel} from 'state/models/media/image' 30 30 import {ComposerOpts} from 'state/shell/composer' 31 31 32 32 export function useExternalLinkFetch({ ··· 161 161 timeout: 15e3, 162 162 }) 163 163 .catch(() => undefined) 164 - .then(localThumb => { 164 + .then(thumb => (thumb ? createComposerImage(thumb) : undefined)) 165 + .then(thumb => { 165 166 if (aborted) { 166 167 return 167 168 } 168 169 setExtLink({ 169 170 ...extLink, 170 171 isLoading: false, // done 171 - localThumb: localThumb ? new ImageModel(localThumb) : undefined, 172 + localThumb: thumb, 172 173 }) 173 174 }) 174 175 return cleanup
+16 -13
src/view/com/modals/AltImage.tsx
··· 13 13 import {msg, Trans} from '@lingui/macro' 14 14 import {useLingui} from '@lingui/react' 15 15 16 + import {ComposerImage} from '#/state/gallery' 16 17 import {useModalControls} from '#/state/modals' 17 18 import {MAX_ALT_TEXT} from 'lib/constants' 18 19 import {useIsKeyboardVisible} from 'lib/hooks/useIsKeyboardVisible' ··· 21 22 import {gradients, s} from 'lib/styles' 22 23 import {useTheme} from 'lib/ThemeContext' 23 24 import {isAndroid, isWeb} from 'platform/detection' 24 - import {ImageModel} from 'state/models/media/image' 25 25 import {Text} from '../util/text/Text' 26 26 import {ScrollView, TextInput} from './util' 27 27 28 28 export const snapPoints = ['100%'] 29 29 30 30 interface Props { 31 - image: ImageModel 31 + image: ComposerImage 32 + onChange: (next: ComposerImage) => void 32 33 } 33 34 34 - export function Component({image}: Props) { 35 + export function Component({image, onChange}: Props) { 35 36 const pal = usePalette('default') 36 37 const theme = useTheme() 37 38 const {_} = useLingui() 38 - const [altText, setAltText] = useState(image.altText) 39 + const [altText, setAltText] = useState(image.alt) 39 40 const windim = useWindowDimensions() 40 41 const {closeModal} = useModalControls() 41 42 const inputRef = React.useRef<RNTextInput>(null) ··· 60 61 61 62 const imageStyles = useMemo<ImageStyle>(() => { 62 63 const maxWidth = isWeb ? 450 : windim.width 63 - if (image.height > image.width) { 64 + const media = image.transformed ?? image.source 65 + if (media.height > media.width) { 64 66 return { 65 67 resizeMode: 'contain', 66 68 width: '100%', ··· 70 72 } 71 73 return { 72 74 width: '100%', 73 - height: (maxWidth / image.width) * image.height, 75 + height: (maxWidth / media.width) * media.height, 74 76 borderRadius: 8, 75 77 } 76 78 }, [image, windim]) ··· 79 81 (v: string) => { 80 82 v = enforceLen(v, MAX_ALT_TEXT) 81 83 setAltText(v) 82 - image.setAltText(v) 83 84 }, 84 - [setAltText, image], 85 + [setAltText], 85 86 ) 86 87 87 88 const onPressSave = useCallback(() => { 88 - image.setAltText(altText) 89 + onChange({ 90 + ...image, 91 + alt: altText, 92 + }) 93 + 89 94 closeModal() 90 - }, [closeModal, image, altText]) 95 + }, [closeModal, image, altText, onChange]) 91 96 92 97 return ( 93 98 <ScrollView ··· 101 106 <Image 102 107 testID="selectedPhotoImage" 103 108 style={imageStyles} 104 - source={{ 105 - uri: image.cropped?.path ?? image.path, 106 - }} 109 + source={{uri: (image.transformed ?? image.source).path}} 107 110 contentFit="contain" 108 111 accessible={true} 109 112 accessibilityIgnoresInvertColors
-380
src/view/com/modals/EditImage.tsx
··· 1 - import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react' 2 - import {Pressable, StyleSheet, View} from 'react-native' 3 - import {useWindowDimensions} from 'react-native' 4 - import {LinearGradient} from 'expo-linear-gradient' 5 - import {msg, Trans} from '@lingui/macro' 6 - import {useLingui} from '@lingui/react' 7 - import {Slider} from '@miblanchard/react-native-slider' 8 - import {observer} from 'mobx-react-lite' 9 - import ImageEditor, {Position} from 'react-avatar-editor' 10 - 11 - import {MAX_ALT_TEXT} from '#/lib/constants' 12 - import {usePalette} from '#/lib/hooks/usePalette' 13 - import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 14 - import {enforceLen} from '#/lib/strings/helpers' 15 - import {gradients, s} from '#/lib/styles' 16 - import {useTheme} from '#/lib/ThemeContext' 17 - import {getKeys} from '#/lib/type-assertions' 18 - import {useModalControls} from '#/state/modals' 19 - import {GalleryModel} from '#/state/models/media/gallery' 20 - import {ImageModel} from '#/state/models/media/image' 21 - import {atoms as a} from '#/alf' 22 - import {Button, ButtonIcon, ButtonText} from '#/components/Button' 23 - import { 24 - AspectRatio11_Stroke2_Corner0_Rounded as A11, 25 - AspectRatio34_Stroke2_Corner0_Rounded as A34, 26 - AspectRatio43_Stroke2_Corner0_Rounded as A43, 27 - } from '#/components/icons/AspectRatio' 28 - import {CircleBanSign_Stroke2_Corner0_Rounded as Ban} from '#/components/icons/CircleBanSign' 29 - import { 30 - FlipHorizontal_Stroke2_Corner0_Rounded as FlipHorizontal, 31 - FlipVertical_Stroke2_Corner0_Rounded as FlipVertical, 32 - } from '#/components/icons/FlipImage' 33 - import {Text} from '../util/text/Text' 34 - import {TextInput} from './util' 35 - 36 - export const snapPoints = ['80%'] 37 - 38 - const RATIOS = { 39 - '4:3': { 40 - icon: A43, 41 - }, 42 - '1:1': { 43 - icon: A11, 44 - }, 45 - '3:4': { 46 - icon: A34, 47 - }, 48 - None: { 49 - icon: Ban, 50 - }, 51 - } as const 52 - 53 - type AspectRatio = keyof typeof RATIOS 54 - 55 - interface Props { 56 - image: ImageModel 57 - gallery: GalleryModel 58 - } 59 - 60 - export const Component = observer(function EditImageImpl({ 61 - image, 62 - gallery, 63 - }: Props) { 64 - const pal = usePalette('default') 65 - const theme = useTheme() 66 - const {_} = useLingui() 67 - const windowDimensions = useWindowDimensions() 68 - const {isMobile} = useWebMediaQueries() 69 - const {closeModal} = useModalControls() 70 - 71 - const { 72 - aspectRatio, 73 - // rotate = 0 74 - } = image.attributes 75 - 76 - const editorRef = useRef<ImageEditor>(null) 77 - const [scale, setScale] = useState<number>(image.attributes.scale ?? 1) 78 - const [position, setPosition] = useState<Position | undefined>( 79 - image.attributes.position, 80 - ) 81 - const [altText, setAltText] = useState(image?.altText ?? '') 82 - 83 - const onFlipHorizontal = useCallback(() => { 84 - image.flipHorizontal() 85 - }, [image]) 86 - 87 - const onFlipVertical = useCallback(() => { 88 - image.flipVertical() 89 - }, [image]) 90 - 91 - // const onSetRotate = useCallback( 92 - // (direction: 'left' | 'right') => { 93 - // const rotation = (rotate + 90 * (direction === 'left' ? -1 : 1)) % 360 94 - // image.setRotate(rotation) 95 - // }, 96 - // [rotate, image], 97 - // ) 98 - 99 - const onSetRatio = useCallback( 100 - (ratio: AspectRatio) => { 101 - image.setRatio(ratio) 102 - }, 103 - [image], 104 - ) 105 - 106 - const adjustments = useMemo( 107 - () => [ 108 - // { 109 - // name: 'rotate-left' as const, 110 - // label: 'Rotate left', 111 - // onPress: () => { 112 - // onSetRotate('left') 113 - // }, 114 - // }, 115 - // { 116 - // name: 'rotate-right' as const, 117 - // label: 'Rotate right', 118 - // onPress: () => { 119 - // onSetRotate('right') 120 - // }, 121 - // }, 122 - { 123 - icon: FlipHorizontal, 124 - label: _(msg`Flip horizontal`), 125 - onPress: onFlipHorizontal, 126 - }, 127 - { 128 - icon: FlipVertical, 129 - label: _(msg`Flip vertically`), 130 - onPress: onFlipVertical, 131 - }, 132 - ], 133 - [onFlipHorizontal, onFlipVertical, _], 134 - ) 135 - 136 - useEffect(() => { 137 - image.prev = image.cropped 138 - image.prevAttributes = image.attributes 139 - image.resetCropped() 140 - }, [image]) 141 - 142 - const onCloseModal = useCallback(() => { 143 - closeModal() 144 - }, [closeModal]) 145 - 146 - const onPressCancel = useCallback(async () => { 147 - await gallery.previous(image) 148 - onCloseModal() 149 - }, [onCloseModal, gallery, image]) 150 - 151 - const onPressSave = useCallback(async () => { 152 - image.setAltText(altText) 153 - 154 - const crop = editorRef.current?.getCroppingRect() 155 - 156 - await image.manipulate({ 157 - ...(crop !== undefined 158 - ? { 159 - crop: { 160 - originX: crop.x, 161 - originY: crop.y, 162 - width: crop.width, 163 - height: crop.height, 164 - }, 165 - ...(scale !== 1 ? {scale} : {}), 166 - ...(position !== undefined ? {position} : {}), 167 - } 168 - : {}), 169 - }) 170 - 171 - image.prev = image.cropped 172 - image.prevAttributes = image.attributes 173 - onCloseModal() 174 - }, [altText, image, position, scale, onCloseModal]) 175 - 176 - if (image.cropped === undefined) { 177 - return null 178 - } 179 - 180 - const computedWidth = 181 - windowDimensions.width > 500 ? 410 : windowDimensions.width - 80 182 - const sideLength = isMobile ? computedWidth : 300 183 - 184 - const dimensions = image.getResizedDimensions(aspectRatio, sideLength) 185 - const imgContainerStyles = {width: sideLength, height: sideLength} 186 - 187 - const imgControlStyles = { 188 - alignItems: 'center' as const, 189 - flexDirection: isMobile ? ('column' as const) : ('row' as const), 190 - gap: isMobile ? 0 : 5, 191 - } 192 - 193 - return ( 194 - <View 195 - testID="editImageModal" 196 - style={[ 197 - pal.view, 198 - styles.container, 199 - s.flex1, 200 - { 201 - paddingHorizontal: isMobile ? 16 : undefined, 202 - }, 203 - ]}> 204 - <Text style={[styles.title, pal.text]}> 205 - <Trans>Edit image</Trans> 206 - </Text> 207 - <View style={[styles.gap18, s.flexRow]}> 208 - <View> 209 - <View 210 - style={[styles.imgContainer, pal.borderDark, imgContainerStyles]}> 211 - <ImageEditor 212 - ref={editorRef} 213 - style={styles.imgEditor} 214 - image={image.cropped.path} 215 - scale={scale} 216 - border={0} 217 - position={position} 218 - onPositionChange={setPosition} 219 - {...dimensions} 220 - /> 221 - </View> 222 - <Slider 223 - value={scale} 224 - onValueChange={(v: number | number[]) => 225 - setScale(Array.isArray(v) ? v[0] : v) 226 - } 227 - minimumValue={1} 228 - maximumValue={3} 229 - /> 230 - </View> 231 - <View style={[a.gap_sm]}> 232 - {!isMobile ? ( 233 - <Text type="sm-bold" style={pal.text}> 234 - <Trans>Ratios</Trans> 235 - </Text> 236 - ) : null} 237 - <View style={imgControlStyles}> 238 - {getKeys(RATIOS).map(ratio => { 239 - const {icon} = RATIOS[ratio] 240 - const isSelected = aspectRatio === ratio 241 - 242 - return ( 243 - <Button 244 - key={ratio} 245 - label={ratio} 246 - size="large" 247 - shape="square" 248 - variant="outline" 249 - color={isSelected ? 'primary' : 'secondary'} 250 - onPress={() => { 251 - onSetRatio(ratio) 252 - }}> 253 - <View style={[a.align_center, a.gap_2xs]}> 254 - <ButtonIcon icon={icon} /> 255 - <ButtonText style={[a.text_xs]}>{ratio}</ButtonText> 256 - </View> 257 - </Button> 258 - ) 259 - })} 260 - </View> 261 - {!isMobile ? ( 262 - <Text type="sm-bold" style={[pal.text, styles.subsection]}> 263 - <Trans>Transformations</Trans> 264 - </Text> 265 - ) : null} 266 - <View style={imgControlStyles}> 267 - {adjustments.map(({label, icon, onPress}) => ( 268 - <Button 269 - key={label} 270 - label={label} 271 - size="large" 272 - shape="square" 273 - variant="outline" 274 - color="secondary" 275 - onPress={onPress}> 276 - <ButtonIcon icon={icon} /> 277 - </Button> 278 - ))} 279 - </View> 280 - </View> 281 - </View> 282 - <View style={[styles.gap18, styles.bottomSection, pal.border]}> 283 - <Text type="sm-bold" style={pal.text} nativeID="alt-text"> 284 - <Trans>Accessibility</Trans> 285 - </Text> 286 - <TextInput 287 - testID="altTextImageInput" 288 - style={[ 289 - styles.textArea, 290 - pal.border, 291 - pal.text, 292 - { 293 - maxHeight: isMobile ? 50 : undefined, 294 - }, 295 - ]} 296 - keyboardAppearance={theme.colorScheme} 297 - multiline 298 - value={altText} 299 - onChangeText={text => setAltText(enforceLen(text, MAX_ALT_TEXT))} 300 - accessibilityLabel={_(msg`Alt text`)} 301 - accessibilityHint="" 302 - accessibilityLabelledBy="alt-text" 303 - /> 304 - </View> 305 - <View style={styles.btns}> 306 - <Pressable onPress={onPressCancel} accessibilityRole="button"> 307 - <Text type="xl" style={pal.link}> 308 - <Trans>Cancel</Trans> 309 - </Text> 310 - </Pressable> 311 - <Pressable onPress={onPressSave} accessibilityRole="button"> 312 - <LinearGradient 313 - colors={[gradients.blueLight.start, gradients.blueLight.end]} 314 - start={{x: 0, y: 0}} 315 - end={{x: 1, y: 1}} 316 - style={[styles.btn]}> 317 - <Text type="xl-medium" style={s.white}> 318 - <Trans context="action">Done</Trans> 319 - </Text> 320 - </LinearGradient> 321 - </Pressable> 322 - </View> 323 - </View> 324 - ) 325 - }) 326 - 327 - const styles = StyleSheet.create({ 328 - container: { 329 - gap: 18, 330 - height: '100%', 331 - width: '100%', 332 - }, 333 - subsection: {marginTop: 12}, 334 - gap18: {gap: 18}, 335 - title: { 336 - fontWeight: '600', 337 - fontSize: 24, 338 - }, 339 - btns: { 340 - flexDirection: 'row', 341 - alignItems: 'center', 342 - justifyContent: 'space-between', 343 - }, 344 - btn: { 345 - borderRadius: 4, 346 - paddingVertical: 8, 347 - paddingHorizontal: 24, 348 - }, 349 - imgEditor: { 350 - maxWidth: '100%', 351 - }, 352 - imgContainer: { 353 - display: 'flex', 354 - alignItems: 'center', 355 - justifyContent: 'center', 356 - borderWidth: 1, 357 - borderStyle: 'solid', 358 - marginBottom: 4, 359 - }, 360 - flipVertical: { 361 - transform: [{rotate: '90deg'}], 362 - }, 363 - flipBtn: { 364 - paddingHorizontal: 4, 365 - paddingVertical: 8, 366 - }, 367 - textArea: { 368 - borderWidth: 1, 369 - borderRadius: 6, 370 - paddingTop: 10, 371 - paddingHorizontal: 12, 372 - fontSize: 16, 373 - height: 100, 374 - textAlignVertical: 'top', 375 - }, 376 - bottomSection: { 377 - borderTopWidth: 1, 378 - paddingTop: 18, 379 - }, 380 - })
-4
src/view/com/modals/Modal.tsx
··· 9 9 import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop' 10 10 import * as AddAppPassword from './AddAppPasswords' 11 11 import * as AltImageModal from './AltImage' 12 - import * as EditImageModal from './AltImage' 13 12 import * as ChangeEmailModal from './ChangeEmail' 14 13 import * as ChangeHandleModal from './ChangeHandle' 15 14 import * as ChangePasswordModal from './ChangePassword' ··· 78 77 } else if (activeModal?.name === 'alt-text-image') { 79 78 snapPoints = AltImageModal.snapPoints 80 79 element = <AltImageModal.Component {...activeModal} /> 81 - } else if (activeModal?.name === 'edit-image') { 82 - snapPoints = AltImageModal.snapPoints 83 - element = <EditImageModal.Component {...activeModal} /> 84 80 } else if (activeModal?.name === 'change-handle') { 85 81 snapPoints = ChangeHandleModal.snapPoints 86 82 element = <ChangeHandleModal.Component {...activeModal} />
+1 -8
src/view/com/modals/Modal.web.tsx
··· 15 15 import * as CreateOrEditListModal from './CreateOrEditList' 16 16 import * as CropImageModal from './crop-image/CropImage.web' 17 17 import * as DeleteAccountModal from './DeleteAccount' 18 - import * as EditImageModal from './EditImage' 19 18 import * as EditProfileModal from './EditProfile' 20 19 import * as InviteCodesModal from './InviteCodes' 21 20 import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings' ··· 54 53 } 55 54 56 55 const onPressMask = () => { 57 - if ( 58 - modal.name === 'crop-image' || 59 - modal.name === 'edit-image' || 60 - modal.name === 'alt-text-image' 61 - ) { 56 + if (modal.name === 'crop-image' || modal.name === 'alt-text-image') { 62 57 return // dont close on mask presses during crop 63 58 } 64 59 closeModal() ··· 95 90 element = <PostLanguagesSettingsModal.Component /> 96 91 } else if (modal.name === 'alt-text-image') { 97 92 element = <AltTextImageModal.Component {...modal} /> 98 - } else if (modal.name === 'edit-image') { 99 - element = <EditImageModal.Component {...modal} /> 100 93 } else if (modal.name === 'verify-email') { 101 94 element = <VerifyEmailModal.Component {...modal} /> 102 95 } else if (modal.name === 'change-email') {
+2 -5
src/view/shell/Composer.ios.tsx
··· 2 2 import {Modal, View} from 'react-native' 3 3 import {StatusBar} from 'expo-status-bar' 4 4 import * as SystemUI from 'expo-system-ui' 5 - import {observer} from 'mobx-react-lite' 6 5 7 6 import {useComposerState} from '#/state/shell/composer' 8 7 import {atoms as a, useTheme} from '#/alf' 9 8 import {getBackgroundColor, useThemeName} from '#/alf/util/useColorModeTheme' 10 9 import {ComposePost, useComposerCancelRef} from '../com/composer/Composer' 11 10 12 - export const Composer = observer(function ComposerImpl({}: { 13 - winHeight: number 14 - }) { 11 + export function Composer({}: {winHeight: number}) { 15 12 const t = useTheme() 16 13 const state = useComposerState() 17 14 const ref = useComposerCancelRef() ··· 42 39 </View> 43 40 </Modal> 44 41 ) 45 - }) 42 + } 46 43 47 44 function Providers({ 48 45 children,
+5 -10
src/view/shell/Composer.tsx
··· 1 1 import React, {useEffect} from 'react' 2 2 import {Animated, Easing, StyleSheet, View} from 'react-native' 3 - import {observer} from 'mobx-react-lite' 4 3 5 - import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' 6 - import {usePalette} from 'lib/hooks/usePalette' 7 - import {useComposerState} from 'state/shell/composer' 4 + import {useAnimatedValue} from '#/lib/hooks/useAnimatedValue' 5 + import {usePalette} from '#/lib/hooks/usePalette' 6 + import {useComposerState} from '#/state/shell/composer' 8 7 import {ComposePost} from '../com/composer/Composer' 9 8 10 - export const Composer = observer(function ComposerImpl({ 11 - winHeight, 12 - }: { 13 - winHeight: number 14 - }) { 9 + export function Composer({winHeight}: {winHeight: number}) { 15 10 const state = useComposerState() 16 11 const pal = usePalette('default') 17 12 const initInterp = useAnimatedValue(0) ··· 62 57 /> 63 58 </Animated.View> 64 59 ) 65 - }) 60 + } 66 61 67 62 const styles = StyleSheet.create({ 68 63 wrapper: {
-15
yarn.lock
··· 16760 16760 resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" 16761 16761 integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== 16762 16762 16763 - mobx-react-lite@^3.4.0: 16764 - version "3.4.3" 16765 - resolved "https://registry.yarnpkg.com/mobx-react-lite/-/mobx-react-lite-3.4.3.tgz#3a4c22c30bfaa8b1b2aa48d12b2ba811c0947ab7" 16766 - integrity sha512-NkJREyFTSUXR772Qaai51BnE1voWx56LOL80xG7qkZr6vo8vEaLF3sz1JNUVh+rxmUzxYaqOhfuxTfqUh0FXUg== 16767 - 16768 - mobx-utils@^6.0.6: 16769 - version "6.0.8" 16770 - resolved "https://registry.yarnpkg.com/mobx-utils/-/mobx-utils-6.0.8.tgz#843e222c7694050c2e42842682fd24a84fdb7024" 16771 - integrity sha512-fPNt0vJnHwbQx9MojJFEnJLfM3EMGTtpy4/qOOW6xueh1mPofMajrbYAUvByMYAvCJnpy1A5L0t+ZVB5niKO4g== 16772 - 16773 - mobx@^6.6.1: 16774 - version "6.10.0" 16775 - resolved "https://registry.yarnpkg.com/mobx/-/mobx-6.10.0.tgz#3537680fe98d45232cc19cc8f76280bd8bb6b0b7" 16776 - integrity sha512-WMbVpCMFtolbB8swQ5E2YRrU+Yu8iLozCVx3CdGjbBKlP7dFiCSuiG06uea3JCFN5DnvtAX7+G5Bp82e2xu0ww== 16777 - 16778 16763 moo@^0.5.1: 16779 16764 version "0.5.2" 16780 16765 resolved "https://registry.yarnpkg.com/moo/-/moo-0.5.2.tgz#f9fe82473bc7c184b0d32e2215d3f6e67278733c"