MobX removal take 2 (#5381)

* mobx removal take 2

* Actually rm mobx

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
This commit is contained in:
Mary
2024-09-24 23:14:15 +07:00
committed by GitHub
parent dbe1df7ac7
commit 8ea89469ef
23 changed files with 594 additions and 1256 deletions
-3
View File
@@ -161,9 +161,6 @@
"lodash.set": "^4.3.2",
"lodash.shuffle": "^4.2.0",
"lodash.throttle": "^4.1.1",
"mobx": "^6.6.1",
"mobx-react-lite": "^3.4.0",
"mobx-utils": "^6.0.6",
"nanoid": "^5.0.5",
"normalize-url": "^8.0.0",
"patch-package": "^6.5.1",
+14 -39
View File
@@ -14,6 +14,7 @@ import {
} from '@atproto/api'
import {logger} from '#/logger'
import {ComposerImage, compressImage} from '#/state/gallery'
import {writePostgateRecord} from '#/state/queries/postgate'
import {
createThreadgateRecord,
@@ -23,10 +24,7 @@ import {
} from '#/state/queries/threadgate'
import {isNetworkError} from 'lib/strings/errors'
import {shortenLinks, stripInvalidMentions} from 'lib/strings/rich-text-manip'
import {isNative} from 'platform/detection'
import {ImageModel} from 'state/models/media/image'
import {LinkMeta} from '../link-meta/link-meta'
import {safeDeleteAsync} from '../media/manip'
import {uploadBlob} from './upload-blob'
export {uploadBlob}
@@ -36,7 +34,7 @@ export interface ExternalEmbedDraft {
isLoading: boolean
meta?: LinkMeta
embed?: AppBskyEmbedRecord.Main
localThumb?: ImageModel
localThumb?: ComposerImage
}
interface PostOpts {
@@ -53,7 +51,7 @@ interface PostOpts {
aspectRatio?: AppBskyEmbedDefs.AspectRatio
}
extLink?: ExternalEmbedDraft
images?: ImageModel[]
images?: ComposerImage[]
labels?: string[]
threadgate: ThreadgateAllowUISetting[]
postgate: AppBskyFeedPostgate.Record
@@ -99,18 +97,16 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
const images: AppBskyEmbedImages.Image[] = []
for (const image of opts.images) {
opts.onStateChange?.(`Uploading image #${images.length + 1}...`)
logger.debug(`Compressing image`)
await image.compress()
const path = image.compressed?.path ?? image.path
const {width, height} = image.compressed || image
const {path, width, height, mime} = await compressImage(image)
logger.debug(`Uploading image`)
const res = await uploadBlob(agent, path, 'image/jpeg')
if (isNative) {
safeDeleteAsync(path)
}
const res = await uploadBlob(agent, path, mime)
images.push({
image: res.data.blob,
alt: image.altText ?? '',
alt: image.alt,
aspectRatio: {width, height},
})
}
@@ -175,32 +171,11 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
let thumb
if (opts.extLink.localThumb) {
opts.onStateChange?.('Uploading link thumbnail...')
let encoding
if (opts.extLink.localThumb.mime) {
encoding = opts.extLink.localThumb.mime
} else if (opts.extLink.localThumb.path.endsWith('.png')) {
encoding = 'image/png'
} else if (
opts.extLink.localThumb.path.endsWith('.jpeg') ||
opts.extLink.localThumb.path.endsWith('.jpg')
) {
encoding = 'image/jpeg'
} else {
logger.warn('Unexpected image format for thumbnail, skipping', {
thumbnail: opts.extLink.localThumb.path,
})
}
if (encoding) {
const thumbUploadRes = await uploadBlob(
agent,
opts.extLink.localThumb.path,
encoding,
)
thumb = thumbUploadRes.data.blob
if (isNative) {
safeDeleteAsync(opts.extLink.localThumb.path)
}
}
const {path, mime} = opts.extLink.localThumb.source
const res = await uploadBlob(agent, path, mime)
thumb = res.data.blob
}
if (opts.quote) {
+1 -1
View File
@@ -28,7 +28,7 @@ export async function openPicker(opts?: ImagePickerOptions) {
return false
})
.map(image => ({
mime: 'image/jpeg',
mime: image.mimeType || 'image/jpeg',
height: image.height,
width: image.width,
path: image.uri,
+299
View File
@@ -0,0 +1,299 @@
import {
cacheDirectory,
deleteAsync,
makeDirectoryAsync,
moveAsync,
} from 'expo-file-system'
import {
Action,
ActionCrop,
manipulateAsync,
SaveFormat,
} from 'expo-image-manipulator'
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 {getDataUriSize} from '#/lib/media/util'
import {isIOS, isNative} from '#/platform/detection'
export type ImageTransformation = {
crop?: ActionCrop['crop']
}
export type ImageMeta = {
path: string
width: number
height: number
mime: string
}
export type ImageSource = ImageMeta & {
id: string
}
type ComposerImageBase = {
alt: string
source: ImageSource
}
type ComposerImageWithoutTransformation = ComposerImageBase & {
transformed?: undefined
manips?: undefined
}
type ComposerImageWithTransformation = ComposerImageBase & {
transformed: ImageMeta
manips?: ImageTransformation
}
export type ComposerImage =
| ComposerImageWithoutTransformation
| ComposerImageWithTransformation
let _imageCacheDirectory: string
function getImageCacheDirectory(): string | null {
if (isNative) {
return (_imageCacheDirectory ??= joinPath(cacheDirectory!, 'bsky-composer'))
}
return null
}
export async function createComposerImage(
raw: ImageMeta,
): Promise<ComposerImageWithoutTransformation> {
return {
alt: '',
source: {
id: nanoid(),
path: await moveIfNecessary(raw.path),
width: raw.width,
height: raw.height,
mime: raw.mime,
},
}
}
export type InitialImage = {
uri: string
width: number
height: number
altText?: string
}
export function createInitialImages(
uris: InitialImage[] = [],
): ComposerImageWithoutTransformation[] {
return uris.map(({uri, width, height, altText = ''}) => {
return {
alt: altText,
source: {
id: nanoid(),
path: uri,
width: width,
height: height,
mime: 'image/jpeg',
},
}
})
}
export async function pasteImage(
uri: string,
): Promise<ComposerImageWithoutTransformation> {
const {width, height} = await getImageDim(uri)
const match = /^data:(.+?);/.exec(uri)
return {
alt: '',
source: {
id: nanoid(),
path: uri,
width: width,
height: height,
mime: match ? match[1] : 'image/jpeg',
},
}
}
export async function cropImage(img: ComposerImage): Promise<ComposerImage> {
if (!isNative) {
return img
}
// NOTE
// on ios, react-native-image-crop-picker gives really bad quality
// without specifying width and height. on android, however, the
// crop stretches incorrectly if you do specify it. these are
// both separate bugs in the library. we deal with that by
// providing width & height for ios only
// -prf
const source = img.source
const [w, h] = containImageRes(source.width, source.height, POST_IMG_MAX)
// @todo: we're always passing the original image here, does image-cropper
// allows for setting initial crop dimensions? -mary
try {
const cropped = await openCropper({
mediaType: 'photo',
path: source.path,
freeStyleCropEnabled: true,
...(isIOS ? {width: w, height: h} : {}),
})
return {
alt: img.alt,
source: source,
transformed: {
path: await moveIfNecessary(cropped.path),
width: cropped.width,
height: cropped.height,
mime: cropped.mime,
},
}
} catch (e) {
if (e instanceof Error && e.message.includes('User cancelled')) {
return img
}
throw e
}
}
export async function manipulateImage(
img: ComposerImage,
trans: ImageTransformation,
): Promise<ComposerImage> {
const rawActions: (Action | undefined)[] = [trans.crop && {crop: trans.crop}]
const actions = rawActions.filter((a): a is Action => a !== undefined)
if (actions.length === 0) {
if (img.transformed === undefined) {
return img
}
return {alt: img.alt, source: img.source}
}
const source = img.source
const result = await manipulateAsync(source.path, actions, {
format: SaveFormat.PNG,
})
return {
alt: img.alt,
source: img.source,
transformed: {
path: await moveIfNecessary(result.uri),
width: result.width,
height: result.height,
mime: 'image/png',
},
manips: trans,
}
}
export function resetImageManipulation(
img: ComposerImage,
): ComposerImageWithoutTransformation {
if (img.transformed !== undefined) {
return {alt: img.alt, source: img.source}
}
return img
}
export async function compressImage(img: ComposerImage): Promise<ImageMeta> {
const source = img.transformed || img.source
const [w, h] = containImageRes(source.width, source.height, POST_IMG_MAX)
const cacheDir = isNative && getImageCacheDirectory()
for (let i = 10; i > 0; i--) {
// Float precision
const factor = i / 10
const res = await manipulateAsync(
source.path,
[{resize: {width: w, height: h}}],
{
compress: factor,
format: SaveFormat.JPEG,
base64: true,
},
)
const base64 = res.base64
if (base64 !== undefined && getDataUriSize(base64) <= POST_IMG_MAX.size) {
return {
path: await moveIfNecessary(res.uri),
width: res.width,
height: res.height,
mime: 'image/jpeg',
}
}
if (cacheDir) {
await deleteAsync(res.uri)
}
}
throw new Error(`Unable to compress image`)
}
async function moveIfNecessary(from: string) {
const cacheDir = isNative && getImageCacheDirectory()
if (cacheDir && from.startsWith(cacheDir)) {
const to = joinPath(cacheDir, nanoid(36))
await makeDirectoryAsync(cacheDir, {intermediates: true})
await moveAsync({from, to})
return to
}
return from
}
/** Purge files that were created to accomodate image manipulation */
export async function purgeTemporaryImageFiles() {
const cacheDir = isNative && getImageCacheDirectory()
if (cacheDir) {
await deleteAsync(cacheDir, {idempotent: true})
await makeDirectoryAsync(cacheDir)
}
}
function joinPath(a: string, b: string) {
if (a.endsWith('/')) {
if (b.startsWith('/')) {
return a.slice(0, -1) + b
}
return a + b
} else if (b.startsWith('/')) {
return a + b
}
return a + '/' + b
}
function containImageRes(
w: number,
h: number,
{width: maxW, height: maxH}: {width: number; height: number},
): [width: number, height: number] {
let scale = 1
if (w > maxW || h > maxH) {
scale = w > h ? maxW / w : maxH / h
w = Math.floor(w * scale)
h = Math.floor(h * scale)
}
return [w, h]
}
+3 -10
View File
@@ -3,8 +3,7 @@ import {Image as RNImage} from 'react-native-image-crop-picker'
import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {GalleryModel} from '#/state/models/media/gallery'
import {ImageModel} from '#/state/models/media/image'
import {ComposerImage} from '../gallery'
export interface EditProfileModal {
name: 'edit-profile'
@@ -37,12 +36,6 @@ export interface ListAddRemoveUsersModal {
) => void
}
export interface EditImageModal {
name: 'edit-image'
image: ImageModel
gallery: GalleryModel
}
export interface CropImageModal {
name: 'crop-image'
uri: string
@@ -52,7 +45,8 @@ export interface CropImageModal {
export interface AltTextImageModal {
name: 'alt-text-image'
image: ImageModel
image: ComposerImage
onChange: (next: ComposerImage) => void
}
export interface DeleteAccountModal {
@@ -139,7 +133,6 @@ export type Modal =
// Posts
| AltTextImageModal
| CropImageModal
| EditImageModal
| SelfLabelModal
// Bluesky access
-110
View File
@@ -1,110 +0,0 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {getImageDim} from 'lib/media/manip'
import {openPicker} from 'lib/media/picker'
import {ImageInitOptions, ImageModel} from './image'
interface InitialImageUri {
uri: string
width: number
height: number
altText?: string
}
export class GalleryModel {
images: ImageModel[] = []
constructor(uris?: InitialImageUri[]) {
makeAutoObservable(this)
if (uris) {
this.addFromUris(uris)
}
}
get isEmpty() {
return this.size === 0
}
get size() {
return this.images.length
}
get needsAltText() {
return this.images.some(image => image.altText.trim() === '')
}
*add(image_: ImageInitOptions) {
if (this.size >= 4) {
return
}
// Temporarily enforce uniqueness but can eventually also use index
if (!this.images.some(i => i.path === image_.path)) {
const image = new ImageModel(image_)
// Initial resize
image.manipulate({})
this.images.push(image)
}
}
async paste(uri: string) {
if (this.size >= 4) {
return
}
const {width, height} = await getImageDim(uri)
const image = {
path: uri,
height,
width,
}
runInAction(() => {
this.add(image)
})
}
setAltText(image: ImageModel, altText: string) {
image.setAltText(altText)
}
crop(image: ImageModel) {
image.crop()
}
remove(image: ImageModel) {
const index = this.images.findIndex(image_ => image_.path === image.path)
this.images.splice(index, 1)
}
async previous(image: ImageModel) {
image.previous()
}
async pick() {
const images = await openPicker({
selectionLimit: 4 - this.size,
allowsMultipleSelection: true,
})
return await Promise.all(
images.map(image => {
this.add(image)
}),
)
}
async addFromUris(uris: InitialImageUri[]) {
for (const uriObj of uris) {
this.add({
height: uriObj.height,
width: uriObj.width,
path: uriObj.uri,
altText: uriObj.altText,
})
}
}
}
-146
View File
@@ -1,146 +0,0 @@
import {Image as RNImage} from 'react-native-image-crop-picker'
import {makeAutoObservable} from 'mobx'
import {POST_IMG_MAX} from 'lib/constants'
import {ActionCrop} from 'expo-image-manipulator'
import {Position} from 'react-avatar-editor'
import {Dimensions} from 'lib/media/types'
export interface ImageManipulationAttributes {
aspectRatio?: '4:3' | '1:1' | '3:4' | 'None'
rotate?: number
scale?: number
position?: Position
flipHorizontal?: boolean
flipVertical?: boolean
}
export class ImageModel implements Omit<RNImage, 'size'> {
path: string
mime = 'image/jpeg'
width: number
height: number
altText = ''
cropped?: RNImage = undefined
compressed?: RNImage = undefined
// Web manipulation
prev?: RNImage
attributes: ImageManipulationAttributes = {
aspectRatio: 'None',
scale: 1,
flipHorizontal: false,
flipVertical: false,
rotate: 0,
}
prevAttributes: ImageManipulationAttributes = {}
constructor(image: Omit<RNImage, 'size'>) {
makeAutoObservable(this)
this.path = image.path
this.width = image.width
this.height = image.height
}
setRatio(aspectRatio: ImageManipulationAttributes['aspectRatio']) {
this.attributes.aspectRatio = aspectRatio
}
setRotate(degrees: number) {
this.attributes.rotate = degrees
this.manipulate({})
}
flipVertical() {
this.attributes.flipVertical = !this.attributes.flipVertical
this.manipulate({})
}
flipHorizontal() {
this.attributes.flipHorizontal = !this.attributes.flipHorizontal
this.manipulate({})
}
get ratioMultipliers() {
return {
'4:3': 4 / 3,
'1:1': 1,
'3:4': 3 / 4,
None: this.width / this.height,
}
}
getUploadDimensions(
dimensions: Dimensions,
maxDimensions: Dimensions = POST_IMG_MAX,
as: ImageManipulationAttributes['aspectRatio'] = 'None',
) {
const {width, height} = dimensions
const {width: maxWidth, height: maxHeight} = maxDimensions
return width < maxWidth && height < maxHeight
? {
width,
height,
}
: this.getResizedDimensions(as, POST_IMG_MAX.width)
}
getResizedDimensions(
as: ImageManipulationAttributes['aspectRatio'] = 'None',
maxSide: number,
) {
const ratioMultiplier = this.ratioMultipliers[as]
if (ratioMultiplier === 1) {
return {
height: maxSide,
width: maxSide,
}
}
if (ratioMultiplier < 1) {
return {
width: maxSide * ratioMultiplier,
height: maxSide,
}
}
return {
width: maxSide,
height: maxSide / ratioMultiplier,
}
}
setAltText(altText: string) {
this.altText = altText.trim()
}
// Only compress prior to upload
async compress() {
// do nothing
}
// Mobile
async crop() {
// do nothing
}
// Web manipulation
async manipulate(
_attributes: {
crop?: ActionCrop['crop']
} & ImageManipulationAttributes,
) {
// do nothing
}
resetCropped() {
this.manipulate({})
}
previous() {
this.cropped = this.prev
this.attributes = this.prevAttributes
}
}
-310
View File
@@ -1,310 +0,0 @@
import {Image as RNImage} from 'react-native-image-crop-picker'
import * as ImageManipulator from 'expo-image-manipulator'
import {ActionCrop, FlipType, SaveFormat} from 'expo-image-manipulator'
import {makeAutoObservable, runInAction} from 'mobx'
import {Position} from 'react-avatar-editor'
import {logger} from '#/logger'
import {POST_IMG_MAX} from 'lib/constants'
import {openCropper} from 'lib/media/picker'
import {Dimensions} from 'lib/media/types'
import {getDataUriSize} from 'lib/media/util'
import {isIOS} from 'platform/detection'
export interface ImageManipulationAttributes {
aspectRatio?: '4:3' | '1:1' | '3:4' | 'None'
rotate?: number
scale?: number
position?: Position
flipHorizontal?: boolean
flipVertical?: boolean
}
export interface ImageInitOptions {
path: string
width: number
height: number
altText?: string
}
const MAX_IMAGE_SIZE_IN_BYTES = 976560
export class ImageModel implements Omit<RNImage, 'size'> {
path: string
mime = 'image/jpeg'
width: number
height: number
altText = ''
cropped?: RNImage = undefined
compressed?: RNImage = undefined
// Web manipulation
prev?: RNImage
attributes: ImageManipulationAttributes = {
aspectRatio: 'None',
scale: 1,
flipHorizontal: false,
flipVertical: false,
rotate: 0,
}
prevAttributes: ImageManipulationAttributes = {}
constructor(image: ImageInitOptions) {
makeAutoObservable(this)
this.path = image.path
this.width = image.width
this.height = image.height
if (image.altText !== undefined) {
this.setAltText(image.altText)
}
}
setRatio(aspectRatio: ImageManipulationAttributes['aspectRatio']) {
this.attributes.aspectRatio = aspectRatio
}
setRotate(degrees: number) {
this.attributes.rotate = degrees
this.manipulate({})
}
flipVertical() {
this.attributes.flipVertical = !this.attributes.flipVertical
this.manipulate({})
}
flipHorizontal() {
this.attributes.flipHorizontal = !this.attributes.flipHorizontal
this.manipulate({})
}
get ratioMultipliers() {
return {
'4:3': 4 / 3,
'1:1': 1,
'3:4': 3 / 4,
None: this.width / this.height,
}
}
getUploadDimensions(
dimensions: Dimensions,
maxDimensions: Dimensions = POST_IMG_MAX,
as: ImageManipulationAttributes['aspectRatio'] = 'None',
) {
const {width, height} = dimensions
const {width: maxWidth, height: maxHeight} = maxDimensions
return width < maxWidth && height < maxHeight
? {
width,
height,
}
: this.getResizedDimensions(as, POST_IMG_MAX.width)
}
getResizedDimensions(
as: ImageManipulationAttributes['aspectRatio'] = 'None',
maxSide: number,
) {
const ratioMultiplier = this.ratioMultipliers[as]
if (ratioMultiplier === 1) {
return {
height: maxSide,
width: maxSide,
}
}
if (ratioMultiplier < 1) {
return {
width: maxSide * ratioMultiplier,
height: maxSide,
}
}
return {
width: maxSide,
height: maxSide / ratioMultiplier,
}
}
setAltText(altText: string) {
this.altText = altText.trim()
}
// Only compress prior to upload
async compress() {
for (let i = 10; i > 0; i--) {
// Float precision
const factor = Math.round(i) / 10
const compressed = await ImageManipulator.manipulateAsync(
this.cropped?.path ?? this.path,
undefined,
{
compress: factor,
base64: true,
format: SaveFormat.JPEG,
},
)
if (compressed.base64 !== undefined) {
const size = getDataUriSize(compressed.base64)
if (size < MAX_IMAGE_SIZE_IN_BYTES) {
runInAction(() => {
this.compressed = {
mime: 'image/jpeg',
path: compressed.uri,
size,
...compressed,
}
})
return
}
}
}
// Compression fails when removing redundant information is not possible.
// This can be tested with images that have high variance in noise.
throw new Error('Failed to compress image')
}
// Mobile
async crop() {
try {
// NOTE
// on ios, react-native-image-crop-picker gives really bad quality
// without specifying width and height. on android, however, the
// crop stretches incorrectly if you do specify it. these are
// both separate bugs in the library. we deal with that by
// providing width & height for ios only
// -prf
const {width, height} = this.getUploadDimensions({
width: this.width,
height: this.height,
})
const cropped = await openCropper({
mediaType: 'photo',
path: this.path,
freeStyleCropEnabled: true,
...(isIOS ? {width, height} : {}),
})
runInAction(() => {
this.cropped = cropped
})
} catch (err) {
logger.error('Failed to crop photo', {message: err})
}
}
// Web manipulation
async manipulate(
attributes: {
crop?: ActionCrop['crop']
} & ImageManipulationAttributes,
) {
let uploadWidth: number | undefined
let uploadHeight: number | undefined
const {aspectRatio, crop, position, scale} = attributes
const modifiers = []
if (this.attributes.flipHorizontal) {
modifiers.push({flip: FlipType.Horizontal})
}
if (this.attributes.flipVertical) {
modifiers.push({flip: FlipType.Vertical})
}
if (this.attributes.rotate !== undefined) {
modifiers.push({rotate: this.attributes.rotate})
}
if (crop !== undefined) {
const croppedHeight = crop.height * this.height
const croppedWidth = crop.width * this.width
modifiers.push({
crop: {
originX: crop.originX * this.width,
originY: crop.originY * this.height,
height: croppedHeight,
width: croppedWidth,
},
})
const uploadDimensions = this.getUploadDimensions(
{width: croppedWidth, height: croppedHeight},
POST_IMG_MAX,
aspectRatio,
)
uploadWidth = uploadDimensions.width
uploadHeight = uploadDimensions.height
} else {
const uploadDimensions = this.getUploadDimensions(
{width: this.width, height: this.height},
POST_IMG_MAX,
aspectRatio,
)
uploadWidth = uploadDimensions.width
uploadHeight = uploadDimensions.height
}
if (scale !== undefined) {
this.attributes.scale = scale
}
if (position !== undefined) {
this.attributes.position = position
}
if (aspectRatio !== undefined) {
this.attributes.aspectRatio = aspectRatio
}
const ratioMultiplier =
this.ratioMultipliers[this.attributes.aspectRatio ?? '1:1']
const result = await ImageManipulator.manipulateAsync(
this.path,
[
...modifiers,
{
resize:
ratioMultiplier > 1 ? {width: uploadWidth} : {height: uploadHeight},
},
],
{
base64: true,
format: SaveFormat.JPEG,
},
)
runInAction(() => {
this.cropped = {
mime: 'image/jpeg',
path: result.uri,
size:
result.base64 !== undefined
? getDataUriSize(result.base64)
: MAX_IMAGE_SIZE_IN_BYTES + 999, // shouldn't hit this unless manipulation fails
...result,
}
})
}
resetCropped() {
this.manipulate({})
}
previous() {
this.cropped = this.prev
this.attributes = this.prevAttributes
}
}
+6 -1
View File
@@ -9,6 +9,7 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {purgeTemporaryImageFiles} from '#/state/gallery'
import * as Toast from '#/view/com/util/Toast'
export interface ComposerOptsPostRef {
@@ -77,7 +78,11 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const closeComposer = useNonReactiveCallback(() => {
let wasOpen = !!state
setState(undefined)
if (wasOpen) {
setState(undefined)
purgeTemporaryImageFiles()
}
return wasOpen
})
+39 -28
View File
@@ -44,7 +44,6 @@ import {RichText} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {observer} from 'mobx-react-lite'
import {useAnalytics} from '#/lib/analytics/analytics'
import * as apilib from '#/lib/api/index'
@@ -68,9 +67,9 @@ import {logger} from '#/logger'
import {isAndroid, isIOS, isNative, isWeb} from '#/platform/detection'
import {useDialogStateControlContext} from '#/state/dialogs'
import {emitPostCreated} from '#/state/events'
import {ComposerImage, createInitialImages, pasteImage} from '#/state/gallery'
import {useModalControls} from '#/state/modals'
import {useModals} from '#/state/modals'
import {GalleryModel} from '#/state/models/media/gallery'
import {useRequireAltTextEnabled} from '#/state/preferences'
import {
toPostLanguages,
@@ -122,12 +121,14 @@ import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import * as Prompt from '#/components/Prompt'
import {Text as NewText} from '#/components/Typography'
const MAX_IMAGES = 4
type CancelRef = {
onPressCancel: () => void
}
type Props = ComposerOpts
export const ComposePost = observer(function ComposePost({
export const ComposePost = ({
replyTo,
onPost,
quote: initQuote,
@@ -139,7 +140,7 @@ export const ComposePost = observer(function ComposePost({
cancelRef,
}: Props & {
cancelRef?: React.RefObject<CancelRef>
}) {
}) => {
const {currentAccount} = useSession()
const agent = useAgent()
const {data: currentProfile} = useProfileQuery({did: currentAccount!.did})
@@ -212,9 +213,8 @@ export const ComposePost = observer(function ComposePost({
)
const [postgate, setPostgate] = useState(createPostgateRecord({post: ''}))
const gallery = useMemo(
() => new GalleryModel(initImageUris),
[initImageUris],
const [images, setImages] = useState<ComposerImage[]>(() =>
createInitialImages(initImageUris),
)
const onClose = useCallback(() => {
closeComposer()
@@ -233,7 +233,7 @@ export const ComposePost = observer(function ComposePost({
const onPressCancel = useCallback(() => {
if (
graphemeLength > 0 ||
!gallery.isEmpty ||
images.length !== 0 ||
extGif ||
videoUploadState.status !== 'idle'
) {
@@ -246,7 +246,7 @@ export const ComposePost = observer(function ComposePost({
}, [
extGif,
graphemeLength,
gallery.isEmpty,
images.length,
closeAllDialogs,
discardPromptControl,
onClose,
@@ -299,22 +299,31 @@ export const ComposePost = observer(function ComposePost({
[extLink, setExtLink],
)
const onImageAdd = useCallback(
(next: ComposerImage[]) => {
setImages(prev => prev.concat(next.slice(0, MAX_IMAGES - prev.length)))
},
[setImages],
)
const onPhotoPasted = useCallback(
async (uri: string) => {
track('Composer:PastedPhotos')
if (uri.startsWith('data:video/')) {
selectVideo({uri, type: 'video', height: 0, width: 0})
} else {
await gallery.paste(uri)
const res = await pasteImage(uri)
onImageAdd([res])
}
},
[gallery, track, selectVideo],
[track, selectVideo, onImageAdd],
)
const isAltTextRequiredAndMissing = useMemo(() => {
if (!requireAltTextEnabled) return false
if (gallery.needsAltText) return true
if (images.some(img => img.alt === '')) return true
if (extGif) {
if (!extLink?.meta?.description) return true
@@ -322,7 +331,7 @@ export const ComposePost = observer(function ComposePost({
if (!parsedAlt.isPreferred) return true
}
return false
}, [gallery.needsAltText, extLink, extGif, requireAltTextEnabled])
}, [images, extLink, extGif, requireAltTextEnabled])
const onPressPublish = React.useCallback(
async (finishedUploading?: boolean) => {
@@ -347,7 +356,7 @@ export const ComposePost = observer(function ComposePost({
if (
richtext.text.trim().length === 0 &&
gallery.isEmpty &&
images.length === 0 &&
!extLink &&
!quote &&
videoUploadState.status === 'idle'
@@ -368,7 +377,7 @@ export const ComposePost = observer(function ComposePost({
await apilib.post(agent, {
rawText: richtext.text,
replyTo: replyTo?.uri,
images: gallery.images,
images,
quote,
extLink,
labels,
@@ -405,7 +414,7 @@ export const ComposePost = observer(function ComposePost({
} catch (e: any) {
logger.error(e, {
message: `Composer: create post failed`,
hasImages: gallery.size > 0,
hasImages: images.length > 0,
})
if (extLink) {
@@ -427,7 +436,7 @@ export const ComposePost = observer(function ComposePost({
} finally {
if (postUri) {
logEvent('post:create', {
imageCount: gallery.size,
imageCount: images.length,
isReply: replyTo != null,
hasLink: extLink != null,
hasQuote: quote != null,
@@ -436,7 +445,7 @@ export const ComposePost = observer(function ComposePost({
})
}
track('Create Post', {
imageCount: gallery.size,
imageCount: images.length,
})
if (replyTo && replyTo.uri) track('Post:Reply')
}
@@ -472,9 +481,7 @@ export const ComposePost = observer(function ComposePost({
agent,
captions,
extLink,
gallery.images,
gallery.isEmpty,
gallery.size,
images,
graphemeLength,
isAltTextRequiredAndMissing,
isProcessing,
@@ -516,12 +523,12 @@ export const ComposePost = observer(function ComposePost({
: _(msg`What's up?`)
const canSelectImages =
gallery.size < 4 &&
images.length < MAX_IMAGES &&
!extLink &&
videoUploadState.status === 'idle' &&
!videoUploadState.video
const hasMedia =
gallery.size > 0 || Boolean(extLink) || Boolean(videoUploadState.video)
images.length > 0 || Boolean(extLink) || Boolean(videoUploadState.video)
const onEmojiButtonPress = useCallback(() => {
openEmojiPicker?.(textInput.current?.getCursorPosition())
@@ -716,8 +723,8 @@ export const ComposePost = observer(function ComposePost({
/>
</View>
<Gallery gallery={gallery} />
{gallery.isEmpty && extLink && (
<Gallery images={images} onChange={setImages} />
{images.length === 0 && extLink && (
<View style={a.relative}>
<ExternalEmbed
link={extLink}
@@ -801,13 +808,17 @@ export const ComposePost = observer(function ComposePost({
<VideoUploadToolbar state={videoUploadState} />
) : (
<ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}>
<SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} />
<SelectPhotoBtn
size={images.length}
disabled={!canSelectImages}
onAdd={onImageAdd}
/>
<SelectVideoBtn
onSelectVideo={selectVideo}
disabled={!canSelectImages}
setError={setError}
/>
<OpenCameraBtn gallery={gallery} disabled={!canSelectImages} />
<OpenCameraBtn disabled={!canSelectImages} onAdd={onImageAdd} />
<SelectGifBtn
onClose={focusTextInput}
onSelectGif={onSelectGif}
@@ -842,7 +853,7 @@ export const ComposePost = observer(function ComposePost({
/>
</KeyboardAvoidingView>
)
})
}
export function useComposerCancelRef() {
return useRef<CancelRef>(null)
+1 -1
View File
@@ -26,7 +26,7 @@ export const ExternalEmbed = ({
title: link.meta?.title ?? link.uri,
uri: link.uri,
description: link.meta?.description ?? '',
thumb: link.localThumb?.path,
thumb: link.localThumb?.source.path,
},
[link],
)
+1 -1
View File
@@ -43,7 +43,7 @@ export function GifAltText({
title: linkProp.meta?.title ?? linkProp.uri,
uri: linkProp.uri,
description: linkProp.meta?.description ?? '',
thumb: linkProp.localThumb?.path,
thumb: linkProp.localThumb?.source.path,
},
params: parseEmbedPlayerFromUrl(linkProp.uri),
}
+178 -158
View File
@@ -1,29 +1,36 @@
import React, {useState} from 'react'
import {ImageStyle, Keyboard, LayoutChangeEvent} from 'react-native'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
import React from 'react'
import {
ImageStyle,
Keyboard,
LayoutChangeEvent,
StyleSheet,
TouchableOpacity,
View,
ViewStyle,
} from 'react-native'
import {Image} from 'expo-image'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {observer} from 'mobx-react-lite'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {Dimensions} from '#/lib/media/types'
import {colors, s} from '#/lib/styles'
import {isNative} from '#/platform/detection'
import {ComposerImage, cropImage} from '#/state/gallery'
import {useModalControls} from '#/state/modals'
import {GalleryModel} from '#/state/models/media/gallery'
import {Text} from '#/view/com/util/text/Text'
import {useTheme} from '#/alf'
const IMAGE_GAP = 8
interface GalleryProps {
gallery: GalleryModel
images: ComposerImage[]
onChange: (next: ComposerImage[]) => void
}
export const Gallery = (props: GalleryProps) => {
const [containerInfo, setContainerInfo] = useState<Dimensions | undefined>()
export let Gallery = (props: GalleryProps): React.ReactNode => {
const [containerInfo, setContainerInfo] = React.useState<Dimensions>()
const onLayout = (evt: LayoutChangeEvent) => {
const {width, height} = evt.nativeEvent.layout
@@ -41,177 +48,190 @@ export const Gallery = (props: GalleryProps) => {
</View>
)
}
Gallery = React.memo(Gallery)
interface GalleryInnerProps extends GalleryProps {
containerInfo: Dimensions
}
const GalleryInner = observer(function GalleryImpl({
gallery,
containerInfo,
}: GalleryInnerProps) {
const {_} = useLingui()
const GalleryInner = ({images, containerInfo, onChange}: GalleryInnerProps) => {
const {isMobile} = useWebMediaQueries()
const {openModal} = useModalControls()
const t = useTheme()
let side: number
const {altTextControlStyle, imageControlsStyle, imageStyle} =
React.useMemo(() => {
const side =
images.length === 1
? 250
: (containerInfo.width - IMAGE_GAP * (images.length - 1)) /
images.length
if (gallery.size === 1) {
side = 250
} else {
side = (containerInfo.width - IMAGE_GAP * (gallery.size - 1)) / gallery.size
}
const isOverflow = isMobile && images.length > 2
const imageStyle = {
height: side,
width: side,
}
const isOverflow = isMobile && gallery.size > 2
const altTextControlStyle = isOverflow
? {
left: 4,
bottom: 4,
}
: !isMobile && gallery.size < 3
? {
left: 8,
top: 8,
}
: {
left: 4,
top: 4,
return {
altTextControlStyle: isOverflow
? {left: 4, bottom: 4}
: !isMobile && images.length < 3
? {left: 8, top: 8}
: {left: 4, top: 4},
imageControlsStyle: {
display: 'flex' as const,
flexDirection: 'row' as const,
position: 'absolute' as const,
...(isOverflow
? {top: 4, right: 4, gap: 4}
: !isMobile && images.length < 3
? {top: 8, right: 8, gap: 8}
: {top: 4, right: 4, gap: 4}),
zIndex: 1,
},
imageStyle: {
height: side,
width: side,
},
}
}, [images.length, containerInfo, isMobile])
const imageControlsStyle = {
display: 'flex' as const,
flexDirection: 'row' as const,
position: 'absolute' as const,
...(isOverflow
? {
top: 4,
right: 4,
gap: 4,
}
: !isMobile && gallery.size < 3
? {
top: 8,
right: 8,
gap: 8,
}
: {
top: 4,
right: 4,
gap: 4,
}),
zIndex: 1,
}
return !gallery.isEmpty ? (
return images.length !== 0 ? (
<>
<View testID="selectedPhotosView" style={styles.gallery}>
{gallery.images.map(image => (
<View key={`selected-image-${image.path}`} style={[imageStyle]}>
<TouchableOpacity
testID="altTextButton"
accessibilityRole="button"
accessibilityLabel={_(msg`Add alt text`)}
accessibilityHint=""
onPress={() => {
Keyboard.dismiss()
openModal({
name: 'alt-text-image',
image,
})
{images.map((image, index) => {
return (
<GalleryItem
key={image.source.id}
image={image}
altTextControlStyle={altTextControlStyle}
imageControlsStyle={imageControlsStyle}
imageStyle={imageStyle}
onChange={next => {
onChange(
images.map(i => (i.source === image.source ? next : i)),
)
}}
style={[styles.altTextControl, altTextControlStyle]}>
{image.altText.length > 0 ? (
<FontAwesomeIcon
icon="check"
size={10}
style={{color: t.palette.white}}
/>
) : (
<FontAwesomeIcon
icon="plus"
size={10}
style={{color: t.palette.white}}
/>
)}
<Text style={styles.altTextControlLabel} accessible={false}>
<Trans>ALT</Trans>
</Text>
</TouchableOpacity>
<View style={imageControlsStyle}>
<TouchableOpacity
testID="editPhotoButton"
accessibilityRole="button"
accessibilityLabel={_(msg`Edit image`)}
accessibilityHint=""
onPress={() => {
if (isNative) {
gallery.crop(image)
} else {
openModal({
name: 'edit-image',
image,
gallery,
})
}
}}
style={styles.imageControl}>
<FontAwesomeIcon
icon="pen"
size={12}
style={{color: colors.white}}
/>
</TouchableOpacity>
<TouchableOpacity
testID="removePhotoButton"
accessibilityRole="button"
accessibilityLabel={_(msg`Remove image`)}
accessibilityHint=""
onPress={() => gallery.remove(image)}
style={styles.imageControl}>
<FontAwesomeIcon
icon="xmark"
size={16}
style={{color: colors.white}}
/>
</TouchableOpacity>
</View>
<TouchableOpacity
accessibilityRole="button"
accessibilityLabel={_(msg`Add alt text`)}
accessibilityHint=""
onPress={() => {
Keyboard.dismiss()
openModal({
name: 'alt-text-image',
image,
})
}}
style={styles.altTextHiddenRegion}
/>
onRemove={() => {
const next = images.slice()
next.splice(index, 1)
<Image
testID="selectedPhotoImage"
style={[styles.image, imageStyle] as ImageStyle}
source={{
uri: image.cropped?.path ?? image.path,
onChange(next)
}}
accessible={true}
accessibilityIgnoresInvertColors
/>
</View>
))}
)
})}
</View>
<AltTextReminder />
</>
) : null
})
}
type GalleryItemProps = {
image: ComposerImage
altTextControlStyle?: ViewStyle
imageControlsStyle?: ViewStyle
imageStyle?: ViewStyle
onChange: (next: ComposerImage) => void
onRemove: () => void
}
const GalleryItem = ({
image,
altTextControlStyle,
imageControlsStyle,
imageStyle,
onChange,
onRemove,
}: GalleryItemProps): React.ReactNode => {
const {_} = useLingui()
const t = useTheme()
const {openModal} = useModalControls()
const onImageEdit = () => {
if (isNative) {
cropImage(image).then(next => {
onChange(next)
})
}
}
const onAltTextEdit = () => {
Keyboard.dismiss()
openModal({name: 'alt-text-image', image, onChange})
}
return (
<View style={imageStyle}>
<TouchableOpacity
testID="altTextButton"
accessibilityRole="button"
accessibilityLabel={_(msg`Add alt text`)}
accessibilityHint=""
onPress={onAltTextEdit}
style={[styles.altTextControl, altTextControlStyle]}>
{image.alt.length !== 0 ? (
<FontAwesomeIcon
icon="check"
size={10}
style={{color: t.palette.white}}
/>
) : (
<FontAwesomeIcon
icon="plus"
size={10}
style={{color: t.palette.white}}
/>
)}
<Text style={styles.altTextControlLabel} accessible={false}>
<Trans>ALT</Trans>
</Text>
</TouchableOpacity>
<View style={imageControlsStyle}>
{isNative && (
<TouchableOpacity
testID="editPhotoButton"
accessibilityRole="button"
accessibilityLabel={_(msg`Edit image`)}
accessibilityHint=""
onPress={onImageEdit}
style={styles.imageControl}>
<FontAwesomeIcon
icon="pen"
size={12}
style={{color: colors.white}}
/>
</TouchableOpacity>
)}
<TouchableOpacity
testID="removePhotoButton"
accessibilityRole="button"
accessibilityLabel={_(msg`Remove image`)}
accessibilityHint=""
onPress={onRemove}
style={styles.imageControl}>
<FontAwesomeIcon
icon="xmark"
size={16}
style={{color: colors.white}}
/>
</TouchableOpacity>
</View>
<TouchableOpacity
accessibilityRole="button"
accessibilityLabel={_(msg`Add alt text`)}
accessibilityHint=""
onPress={onAltTextEdit}
style={styles.altTextHiddenRegion}
/>
<Image
testID="selectedPhotoImage"
style={[styles.image, imageStyle] as ImageStyle}
source={{
uri: (image.transformed ?? image.source).path,
}}
accessible={true}
accessibilityIgnoresInvertColors
/>
</View>
)
}
export function AltTextReminder() {
const t = useTheme()
@@ -9,17 +9,17 @@ import {useCameraPermission} from '#/lib/hooks/usePermissions'
import {openCamera} from '#/lib/media/picker'
import {logger} from '#/logger'
import {isMobileWeb, isNative} from '#/platform/detection'
import {GalleryModel} from '#/state/models/media/gallery'
import {ComposerImage, createComposerImage} from '#/state/gallery'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {Camera_Stroke2_Corner0_Rounded as Camera} from '#/components/icons/Camera'
type Props = {
gallery: GalleryModel
disabled?: boolean
onAdd: (next: ComposerImage[]) => void
}
export function OpenCameraBtn({gallery, disabled}: Props) {
export function OpenCameraBtn({disabled, onAdd}: Props) {
const {track} = useAnalytics()
const {_} = useLingui()
const {requestCameraAccessIfNeeded} = useCameraPermission()
@@ -48,13 +48,16 @@ export function OpenCameraBtn({gallery, disabled}: Props) {
if (mediaPermissionRes) {
await MediaLibrary.createAssetAsync(img.path)
}
gallery.add(img)
const res = await createComposerImage(img)
onAdd([res])
} catch (err: any) {
// ignore
logger.warn('Error using camera', {error: err})
}
}, [
gallery,
onAdd,
track,
requestCameraAccessIfNeeded,
mediaPermissionRes,
@@ -5,18 +5,20 @@ import {useLingui} from '@lingui/react'
import {useAnalytics} from '#/lib/analytics/analytics'
import {usePhotoLibraryPermission} from '#/lib/hooks/usePermissions'
import {openPicker} from '#/lib/media/picker'
import {isNative} from '#/platform/detection'
import {GalleryModel} from '#/state/models/media/gallery'
import {ComposerImage, createComposerImage} from '#/state/gallery'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {Image_Stroke2_Corner0_Rounded as Image} from '#/components/icons/Image'
type Props = {
gallery: GalleryModel
size: number
disabled?: boolean
onAdd: (next: ComposerImage[]) => void
}
export function SelectPhotoBtn({gallery, disabled}: Props) {
export function SelectPhotoBtn({size, disabled, onAdd}: Props) {
const {track} = useAnalytics()
const {_} = useLingui()
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
@@ -29,8 +31,17 @@ export function SelectPhotoBtn({gallery, disabled}: Props) {
return
}
gallery.pick()
}, [track, requestPhotoAccessIfNeeded, gallery])
const images = await openPicker({
selectionLimit: 4 - size,
allowsMultipleSelection: true,
})
const results = await Promise.all(
images.map(img => createComposerImage(img)),
)
onAdd(results)
}, [track, requestPhotoAccessIfNeeded, size, onAdd])
return (
<Button
@@ -3,6 +3,7 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logger} from '#/logger'
import {createComposerImage} from '#/state/gallery'
import {useFetchDid} from '#/state/queries/handle'
import {useGetPost} from '#/state/queries/post'
import {useAgent} from '#/state/session'
@@ -26,7 +27,6 @@ import {
isBskyStartUrl,
isShortLink,
} from 'lib/strings/url-helpers'
import {ImageModel} from 'state/models/media/image'
import {ComposerOpts} from 'state/shell/composer'
export function useExternalLinkFetch({
@@ -161,14 +161,15 @@ export function useExternalLinkFetch({
timeout: 15e3,
})
.catch(() => undefined)
.then(localThumb => {
.then(thumb => (thumb ? createComposerImage(thumb) : undefined))
.then(thumb => {
if (aborted) {
return
}
setExtLink({
...extLink,
isLoading: false, // done
localThumb: localThumb ? new ImageModel(localThumb) : undefined,
localThumb: thumb,
})
})
return cleanup
+16 -13
View File
@@ -13,6 +13,7 @@ import {LinearGradient} from 'expo-linear-gradient'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {ComposerImage} from '#/state/gallery'
import {useModalControls} from '#/state/modals'
import {MAX_ALT_TEXT} from 'lib/constants'
import {useIsKeyboardVisible} from 'lib/hooks/useIsKeyboardVisible'
@@ -21,21 +22,21 @@ import {enforceLen} from 'lib/strings/helpers'
import {gradients, s} from 'lib/styles'
import {useTheme} from 'lib/ThemeContext'
import {isAndroid, isWeb} from 'platform/detection'
import {ImageModel} from 'state/models/media/image'
import {Text} from '../util/text/Text'
import {ScrollView, TextInput} from './util'
export const snapPoints = ['100%']
interface Props {
image: ImageModel
image: ComposerImage
onChange: (next: ComposerImage) => void
}
export function Component({image}: Props) {
export function Component({image, onChange}: Props) {
const pal = usePalette('default')
const theme = useTheme()
const {_} = useLingui()
const [altText, setAltText] = useState(image.altText)
const [altText, setAltText] = useState(image.alt)
const windim = useWindowDimensions()
const {closeModal} = useModalControls()
const inputRef = React.useRef<RNTextInput>(null)
@@ -60,7 +61,8 @@ export function Component({image}: Props) {
const imageStyles = useMemo<ImageStyle>(() => {
const maxWidth = isWeb ? 450 : windim.width
if (image.height > image.width) {
const media = image.transformed ?? image.source
if (media.height > media.width) {
return {
resizeMode: 'contain',
width: '100%',
@@ -70,7 +72,7 @@ export function Component({image}: Props) {
}
return {
width: '100%',
height: (maxWidth / image.width) * image.height,
height: (maxWidth / media.width) * media.height,
borderRadius: 8,
}
}, [image, windim])
@@ -79,15 +81,18 @@ export function Component({image}: Props) {
(v: string) => {
v = enforceLen(v, MAX_ALT_TEXT)
setAltText(v)
image.setAltText(v)
},
[setAltText, image],
[setAltText],
)
const onPressSave = useCallback(() => {
image.setAltText(altText)
onChange({
...image,
alt: altText,
})
closeModal()
}, [closeModal, image, altText])
}, [closeModal, image, altText, onChange])
return (
<ScrollView
@@ -101,9 +106,7 @@ export function Component({image}: Props) {
<Image
testID="selectedPhotoImage"
style={imageStyles}
source={{
uri: image.cropped?.path ?? image.path,
}}
source={{uri: (image.transformed ?? image.source).path}}
contentFit="contain"
accessible={true}
accessibilityIgnoresInvertColors
-380
View File
@@ -1,380 +0,0 @@
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'
import {Pressable, StyleSheet, View} from 'react-native'
import {useWindowDimensions} from 'react-native'
import {LinearGradient} from 'expo-linear-gradient'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {Slider} from '@miblanchard/react-native-slider'
import {observer} from 'mobx-react-lite'
import ImageEditor, {Position} from 'react-avatar-editor'
import {MAX_ALT_TEXT} from '#/lib/constants'
import {usePalette} from '#/lib/hooks/usePalette'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {enforceLen} from '#/lib/strings/helpers'
import {gradients, s} from '#/lib/styles'
import {useTheme} from '#/lib/ThemeContext'
import {getKeys} from '#/lib/type-assertions'
import {useModalControls} from '#/state/modals'
import {GalleryModel} from '#/state/models/media/gallery'
import {ImageModel} from '#/state/models/media/image'
import {atoms as a} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {
AspectRatio11_Stroke2_Corner0_Rounded as A11,
AspectRatio34_Stroke2_Corner0_Rounded as A34,
AspectRatio43_Stroke2_Corner0_Rounded as A43,
} from '#/components/icons/AspectRatio'
import {CircleBanSign_Stroke2_Corner0_Rounded as Ban} from '#/components/icons/CircleBanSign'
import {
FlipHorizontal_Stroke2_Corner0_Rounded as FlipHorizontal,
FlipVertical_Stroke2_Corner0_Rounded as FlipVertical,
} from '#/components/icons/FlipImage'
import {Text} from '../util/text/Text'
import {TextInput} from './util'
export const snapPoints = ['80%']
const RATIOS = {
'4:3': {
icon: A43,
},
'1:1': {
icon: A11,
},
'3:4': {
icon: A34,
},
None: {
icon: Ban,
},
} as const
type AspectRatio = keyof typeof RATIOS
interface Props {
image: ImageModel
gallery: GalleryModel
}
export const Component = observer(function EditImageImpl({
image,
gallery,
}: Props) {
const pal = usePalette('default')
const theme = useTheme()
const {_} = useLingui()
const windowDimensions = useWindowDimensions()
const {isMobile} = useWebMediaQueries()
const {closeModal} = useModalControls()
const {
aspectRatio,
// rotate = 0
} = image.attributes
const editorRef = useRef<ImageEditor>(null)
const [scale, setScale] = useState<number>(image.attributes.scale ?? 1)
const [position, setPosition] = useState<Position | undefined>(
image.attributes.position,
)
const [altText, setAltText] = useState(image?.altText ?? '')
const onFlipHorizontal = useCallback(() => {
image.flipHorizontal()
}, [image])
const onFlipVertical = useCallback(() => {
image.flipVertical()
}, [image])
// const onSetRotate = useCallback(
// (direction: 'left' | 'right') => {
// const rotation = (rotate + 90 * (direction === 'left' ? -1 : 1)) % 360
// image.setRotate(rotation)
// },
// [rotate, image],
// )
const onSetRatio = useCallback(
(ratio: AspectRatio) => {
image.setRatio(ratio)
},
[image],
)
const adjustments = useMemo(
() => [
// {
// name: 'rotate-left' as const,
// label: 'Rotate left',
// onPress: () => {
// onSetRotate('left')
// },
// },
// {
// name: 'rotate-right' as const,
// label: 'Rotate right',
// onPress: () => {
// onSetRotate('right')
// },
// },
{
icon: FlipHorizontal,
label: _(msg`Flip horizontal`),
onPress: onFlipHorizontal,
},
{
icon: FlipVertical,
label: _(msg`Flip vertically`),
onPress: onFlipVertical,
},
],
[onFlipHorizontal, onFlipVertical, _],
)
useEffect(() => {
image.prev = image.cropped
image.prevAttributes = image.attributes
image.resetCropped()
}, [image])
const onCloseModal = useCallback(() => {
closeModal()
}, [closeModal])
const onPressCancel = useCallback(async () => {
await gallery.previous(image)
onCloseModal()
}, [onCloseModal, gallery, image])
const onPressSave = useCallback(async () => {
image.setAltText(altText)
const crop = editorRef.current?.getCroppingRect()
await image.manipulate({
...(crop !== undefined
? {
crop: {
originX: crop.x,
originY: crop.y,
width: crop.width,
height: crop.height,
},
...(scale !== 1 ? {scale} : {}),
...(position !== undefined ? {position} : {}),
}
: {}),
})
image.prev = image.cropped
image.prevAttributes = image.attributes
onCloseModal()
}, [altText, image, position, scale, onCloseModal])
if (image.cropped === undefined) {
return null
}
const computedWidth =
windowDimensions.width > 500 ? 410 : windowDimensions.width - 80
const sideLength = isMobile ? computedWidth : 300
const dimensions = image.getResizedDimensions(aspectRatio, sideLength)
const imgContainerStyles = {width: sideLength, height: sideLength}
const imgControlStyles = {
alignItems: 'center' as const,
flexDirection: isMobile ? ('column' as const) : ('row' as const),
gap: isMobile ? 0 : 5,
}
return (
<View
testID="editImageModal"
style={[
pal.view,
styles.container,
s.flex1,
{
paddingHorizontal: isMobile ? 16 : undefined,
},
]}>
<Text style={[styles.title, pal.text]}>
<Trans>Edit image</Trans>
</Text>
<View style={[styles.gap18, s.flexRow]}>
<View>
<View
style={[styles.imgContainer, pal.borderDark, imgContainerStyles]}>
<ImageEditor
ref={editorRef}
style={styles.imgEditor}
image={image.cropped.path}
scale={scale}
border={0}
position={position}
onPositionChange={setPosition}
{...dimensions}
/>
</View>
<Slider
value={scale}
onValueChange={(v: number | number[]) =>
setScale(Array.isArray(v) ? v[0] : v)
}
minimumValue={1}
maximumValue={3}
/>
</View>
<View style={[a.gap_sm]}>
{!isMobile ? (
<Text type="sm-bold" style={pal.text}>
<Trans>Ratios</Trans>
</Text>
) : null}
<View style={imgControlStyles}>
{getKeys(RATIOS).map(ratio => {
const {icon} = RATIOS[ratio]
const isSelected = aspectRatio === ratio
return (
<Button
key={ratio}
label={ratio}
size="large"
shape="square"
variant="outline"
color={isSelected ? 'primary' : 'secondary'}
onPress={() => {
onSetRatio(ratio)
}}>
<View style={[a.align_center, a.gap_2xs]}>
<ButtonIcon icon={icon} />
<ButtonText style={[a.text_xs]}>{ratio}</ButtonText>
</View>
</Button>
)
})}
</View>
{!isMobile ? (
<Text type="sm-bold" style={[pal.text, styles.subsection]}>
<Trans>Transformations</Trans>
</Text>
) : null}
<View style={imgControlStyles}>
{adjustments.map(({label, icon, onPress}) => (
<Button
key={label}
label={label}
size="large"
shape="square"
variant="outline"
color="secondary"
onPress={onPress}>
<ButtonIcon icon={icon} />
</Button>
))}
</View>
</View>
</View>
<View style={[styles.gap18, styles.bottomSection, pal.border]}>
<Text type="sm-bold" style={pal.text} nativeID="alt-text">
<Trans>Accessibility</Trans>
</Text>
<TextInput
testID="altTextImageInput"
style={[
styles.textArea,
pal.border,
pal.text,
{
maxHeight: isMobile ? 50 : undefined,
},
]}
keyboardAppearance={theme.colorScheme}
multiline
value={altText}
onChangeText={text => setAltText(enforceLen(text, MAX_ALT_TEXT))}
accessibilityLabel={_(msg`Alt text`)}
accessibilityHint=""
accessibilityLabelledBy="alt-text"
/>
</View>
<View style={styles.btns}>
<Pressable onPress={onPressCancel} accessibilityRole="button">
<Text type="xl" style={pal.link}>
<Trans>Cancel</Trans>
</Text>
</Pressable>
<Pressable onPress={onPressSave} accessibilityRole="button">
<LinearGradient
colors={[gradients.blueLight.start, gradients.blueLight.end]}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={[styles.btn]}>
<Text type="xl-medium" style={s.white}>
<Trans context="action">Done</Trans>
</Text>
</LinearGradient>
</Pressable>
</View>
</View>
)
})
const styles = StyleSheet.create({
container: {
gap: 18,
height: '100%',
width: '100%',
},
subsection: {marginTop: 12},
gap18: {gap: 18},
title: {
fontWeight: '600',
fontSize: 24,
},
btns: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
btn: {
borderRadius: 4,
paddingVertical: 8,
paddingHorizontal: 24,
},
imgEditor: {
maxWidth: '100%',
},
imgContainer: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderStyle: 'solid',
marginBottom: 4,
},
flipVertical: {
transform: [{rotate: '90deg'}],
},
flipBtn: {
paddingHorizontal: 4,
paddingVertical: 8,
},
textArea: {
borderWidth: 1,
borderRadius: 6,
paddingTop: 10,
paddingHorizontal: 12,
fontSize: 16,
height: 100,
textAlignVertical: 'top',
},
bottomSection: {
borderTopWidth: 1,
paddingTop: 18,
},
})
-4
View File
@@ -9,7 +9,6 @@ import {FullWindowOverlay} from '#/components/FullWindowOverlay'
import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop'
import * as AddAppPassword from './AddAppPasswords'
import * as AltImageModal from './AltImage'
import * as EditImageModal from './AltImage'
import * as ChangeEmailModal from './ChangeEmail'
import * as ChangeHandleModal from './ChangeHandle'
import * as ChangePasswordModal from './ChangePassword'
@@ -78,9 +77,6 @@ export function ModalsContainer() {
} else if (activeModal?.name === 'alt-text-image') {
snapPoints = AltImageModal.snapPoints
element = <AltImageModal.Component {...activeModal} />
} else if (activeModal?.name === 'edit-image') {
snapPoints = AltImageModal.snapPoints
element = <EditImageModal.Component {...activeModal} />
} else if (activeModal?.name === 'change-handle') {
snapPoints = ChangeHandleModal.snapPoints
element = <ChangeHandleModal.Component {...activeModal} />
+1 -8
View File
@@ -15,7 +15,6 @@ import * as ChangePasswordModal from './ChangePassword'
import * as CreateOrEditListModal from './CreateOrEditList'
import * as CropImageModal from './crop-image/CropImage.web'
import * as DeleteAccountModal from './DeleteAccount'
import * as EditImageModal from './EditImage'
import * as EditProfileModal from './EditProfile'
import * as InviteCodesModal from './InviteCodes'
import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
@@ -54,11 +53,7 @@ function Modal({modal}: {modal: ModalIface}) {
}
const onPressMask = () => {
if (
modal.name === 'crop-image' ||
modal.name === 'edit-image' ||
modal.name === 'alt-text-image'
) {
if (modal.name === 'crop-image' || modal.name === 'alt-text-image') {
return // dont close on mask presses during crop
}
closeModal()
@@ -95,8 +90,6 @@ function Modal({modal}: {modal: ModalIface}) {
element = <PostLanguagesSettingsModal.Component />
} else if (modal.name === 'alt-text-image') {
element = <AltTextImageModal.Component {...modal} />
} else if (modal.name === 'edit-image') {
element = <EditImageModal.Component {...modal} />
} else if (modal.name === 'verify-email') {
element = <VerifyEmailModal.Component {...modal} />
} else if (modal.name === 'change-email') {
+2 -5
View File
@@ -2,16 +2,13 @@ import React, {useLayoutEffect} from 'react'
import {Modal, View} from 'react-native'
import {StatusBar} from 'expo-status-bar'
import * as SystemUI from 'expo-system-ui'
import {observer} from 'mobx-react-lite'
import {useComposerState} from '#/state/shell/composer'
import {atoms as a, useTheme} from '#/alf'
import {getBackgroundColor, useThemeName} from '#/alf/util/useColorModeTheme'
import {ComposePost, useComposerCancelRef} from '../com/composer/Composer'
export const Composer = observer(function ComposerImpl({}: {
winHeight: number
}) {
export function Composer({}: {winHeight: number}) {
const t = useTheme()
const state = useComposerState()
const ref = useComposerCancelRef()
@@ -42,7 +39,7 @@ export const Composer = observer(function ComposerImpl({}: {
</View>
</Modal>
)
})
}
function Providers({
children,
+5 -10
View File
@@ -1,17 +1,12 @@
import React, {useEffect} from 'react'
import {Animated, Easing, StyleSheet, View} from 'react-native'
import {observer} from 'mobx-react-lite'
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
import {usePalette} from 'lib/hooks/usePalette'
import {useComposerState} from 'state/shell/composer'
import {useAnimatedValue} from '#/lib/hooks/useAnimatedValue'
import {usePalette} from '#/lib/hooks/usePalette'
import {useComposerState} from '#/state/shell/composer'
import {ComposePost} from '../com/composer/Composer'
export const Composer = observer(function ComposerImpl({
winHeight,
}: {
winHeight: number
}) {
export function Composer({winHeight}: {winHeight: number}) {
const state = useComposerState()
const pal = usePalette('default')
const initInterp = useAnimatedValue(0)
@@ -62,7 +57,7 @@ export const Composer = observer(function ComposerImpl({
/>
</Animated.View>
)
})
}
const styles = StyleSheet.create({
wrapper: {
-15
View File
@@ -16760,21 +16760,6 @@ mkdirp@^1.0.3, mkdirp@^1.0.4:
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
mobx-react-lite@^3.4.0:
version "3.4.3"
resolved "https://registry.yarnpkg.com/mobx-react-lite/-/mobx-react-lite-3.4.3.tgz#3a4c22c30bfaa8b1b2aa48d12b2ba811c0947ab7"
integrity sha512-NkJREyFTSUXR772Qaai51BnE1voWx56LOL80xG7qkZr6vo8vEaLF3sz1JNUVh+rxmUzxYaqOhfuxTfqUh0FXUg==
mobx-utils@^6.0.6:
version "6.0.8"
resolved "https://registry.yarnpkg.com/mobx-utils/-/mobx-utils-6.0.8.tgz#843e222c7694050c2e42842682fd24a84fdb7024"
integrity sha512-fPNt0vJnHwbQx9MojJFEnJLfM3EMGTtpy4/qOOW6xueh1mPofMajrbYAUvByMYAvCJnpy1A5L0t+ZVB5niKO4g==
mobx@^6.6.1:
version "6.10.0"
resolved "https://registry.yarnpkg.com/mobx/-/mobx-6.10.0.tgz#3537680fe98d45232cc19cc8f76280bd8bb6b0b7"
integrity sha512-WMbVpCMFtolbB8swQ5E2YRrU+Yu8iLozCVx3CdGjbBKlP7dFiCSuiG06uea3JCFN5DnvtAX7+G5Bp82e2xu0ww==
moo@^0.5.1:
version "0.5.2"
resolved "https://registry.yarnpkg.com/moo/-/moo-0.5.2.tgz#f9fe82473bc7c184b0d32e2215d3f6e67278733c"