Merge branch 'refactor/mobx-image' of https://github.com/mary-ext/fork-bsky-app into mary-ext-refactor/mobx-image
This commit is contained in:
+1
-5
@@ -153,17 +153,14 @@
|
|||||||
"lodash.set": "^4.3.2",
|
"lodash.set": "^4.3.2",
|
||||||
"lodash.shuffle": "^4.2.0",
|
"lodash.shuffle": "^4.2.0",
|
||||||
"lodash.throttle": "^4.1.1",
|
"lodash.throttle": "^4.1.1",
|
||||||
"mobx": "^6.6.1",
|
|
||||||
"mobx-react-lite": "^3.4.0",
|
|
||||||
"mobx-utils": "^6.0.6",
|
|
||||||
"nanoid": "^5.0.5",
|
"nanoid": "^5.0.5",
|
||||||
"normalize-url": "^8.0.0",
|
"normalize-url": "^8.0.0",
|
||||||
"patch-package": "^6.5.1",
|
"patch-package": "^6.5.1",
|
||||||
"postinstall-postinstall": "^2.1.0",
|
"postinstall-postinstall": "^2.1.0",
|
||||||
"psl": "^1.9.0",
|
"psl": "^1.9.0",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-avatar-editor": "^13.0.0",
|
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
"react-image-crop": "^11.0.5",
|
||||||
"react-keyed-flatten-children": "^3.0.0",
|
"react-keyed-flatten-children": "^3.0.0",
|
||||||
"react-native": "0.73.2",
|
"react-native": "0.73.2",
|
||||||
"react-native-date-picker": "^4.4.0",
|
"react-native-date-picker": "^4.4.0",
|
||||||
@@ -226,7 +223,6 @@
|
|||||||
"@types/lodash.set": "^4.3.7",
|
"@types/lodash.set": "^4.3.7",
|
||||||
"@types/lodash.shuffle": "^4.2.7",
|
"@types/lodash.shuffle": "^4.2.7",
|
||||||
"@types/psl": "^1.1.1",
|
"@types/psl": "^1.1.1",
|
||||||
"@types/react-avatar-editor": "^13.0.0",
|
|
||||||
"@types/react-dom": "^18.2.18",
|
"@types/react-dom": "^18.2.18",
|
||||||
"@types/react-responsive": "^8.0.5",
|
"@types/react-responsive": "^8.0.5",
|
||||||
"@types/react-test-renderer": "^17.0.1",
|
"@types/react-test-renderer": "^17.0.1",
|
||||||
|
|||||||
+14
-39
@@ -13,20 +13,19 @@ import {
|
|||||||
import {AtUri} from '@atproto/api'
|
import {AtUri} from '@atproto/api'
|
||||||
|
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
|
import {ComposerImage, compressImage} from '#/state/gallery'
|
||||||
import {ThreadgateSetting} from '#/state/queries/threadgate'
|
import {ThreadgateSetting} from '#/state/queries/threadgate'
|
||||||
import {isNetworkError} from 'lib/strings/errors'
|
import {isNetworkError} from 'lib/strings/errors'
|
||||||
import {shortenLinks} from 'lib/strings/rich-text-manip'
|
import {shortenLinks} from 'lib/strings/rich-text-manip'
|
||||||
import {isNative, isWeb} from 'platform/detection'
|
import {isWeb} from 'platform/detection'
|
||||||
import {ImageModel} from 'state/models/media/image'
|
|
||||||
import {LinkMeta} from '../link-meta/link-meta'
|
import {LinkMeta} from '../link-meta/link-meta'
|
||||||
import {safeDeleteAsync} from '../media/manip'
|
|
||||||
|
|
||||||
export interface ExternalEmbedDraft {
|
export interface ExternalEmbedDraft {
|
||||||
uri: string
|
uri: string
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
meta?: LinkMeta
|
meta?: LinkMeta
|
||||||
embed?: AppBskyEmbedRecord.Main
|
embed?: AppBskyEmbedRecord.Main
|
||||||
localThumb?: ImageModel
|
localThumb?: ComposerImage
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function uploadBlob(
|
export async function uploadBlob(
|
||||||
@@ -56,7 +55,7 @@ interface PostOpts {
|
|||||||
cid: string
|
cid: string
|
||||||
}
|
}
|
||||||
extLink?: ExternalEmbedDraft
|
extLink?: ExternalEmbedDraft
|
||||||
images?: ImageModel[]
|
images?: ComposerImage[]
|
||||||
labels?: string[]
|
labels?: string[]
|
||||||
threadgate?: ThreadgateSetting[]
|
threadgate?: ThreadgateSetting[]
|
||||||
onStateChange?: (state: string) => void
|
onStateChange?: (state: string) => void
|
||||||
@@ -113,18 +112,16 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
|
|||||||
const images: AppBskyEmbedImages.Image[] = []
|
const images: AppBskyEmbedImages.Image[] = []
|
||||||
for (const image of opts.images) {
|
for (const image of opts.images) {
|
||||||
opts.onStateChange?.(`Uploading image #${images.length + 1}...`)
|
opts.onStateChange?.(`Uploading image #${images.length + 1}...`)
|
||||||
|
|
||||||
logger.debug(`Compressing image`)
|
logger.debug(`Compressing image`)
|
||||||
await image.compress()
|
const {path, width, height, mime} = await compressImage(image)
|
||||||
const path = image.compressed?.path ?? image.path
|
|
||||||
const {width, height} = image.compressed || image
|
|
||||||
logger.debug(`Uploading image`)
|
logger.debug(`Uploading image`)
|
||||||
const res = await uploadBlob(agent, path, 'image/jpeg')
|
const res = await uploadBlob(agent, path, mime)
|
||||||
if (isNative) {
|
|
||||||
safeDeleteAsync(path)
|
|
||||||
}
|
|
||||||
images.push({
|
images.push({
|
||||||
image: res.data.blob,
|
image: res.data.blob,
|
||||||
alt: image.altText ?? '',
|
alt: image.alt,
|
||||||
aspectRatio: {width, height},
|
aspectRatio: {width, height},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -154,32 +151,10 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
|
|||||||
let thumb
|
let thumb
|
||||||
if (opts.extLink.localThumb) {
|
if (opts.extLink.localThumb) {
|
||||||
opts.onStateChange?.('Uploading link thumbnail...')
|
opts.onStateChange?.('Uploading link thumbnail...')
|
||||||
let encoding
|
const {path, mime} = opts.extLink.localThumb.source
|
||||||
if (opts.extLink.localThumb.mime) {
|
const res = await uploadBlob(agent, path, mime)
|
||||||
encoding = opts.extLink.localThumb.mime
|
|
||||||
} else if (opts.extLink.localThumb.path.endsWith('.png')) {
|
thumb = res.data.blob
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (opts.quote) {
|
if (opts.quote) {
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import {
|
|||||||
launchImageLibraryAsync,
|
launchImageLibraryAsync,
|
||||||
MediaTypeOptions,
|
MediaTypeOptions,
|
||||||
} from 'expo-image-picker'
|
} from 'expo-image-picker'
|
||||||
import {getDataUriSize} from './util'
|
|
||||||
import * as Toast from 'view/com/util/Toast'
|
import * as Toast from 'view/com/util/Toast'
|
||||||
|
import {getDataUriSize} from './util'
|
||||||
|
|
||||||
export async function openPicker(opts?: ImagePickerOptions) {
|
export async function openPicker(opts?: ImagePickerOptions) {
|
||||||
const response = await launchImageLibraryAsync({
|
const response = await launchImageLibraryAsync({
|
||||||
@@ -26,7 +27,7 @@ export async function openPicker(opts?: ImagePickerOptions) {
|
|||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
.map(image => ({
|
.map(image => ({
|
||||||
mime: 'image/jpeg',
|
mime: image.mimeType || 'image/jpeg',
|
||||||
height: image.height,
|
height: image.height,
|
||||||
width: image.width,
|
width: image.width,
|
||||||
path: image.uri,
|
path: image.uri,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export async function openCropper(opts: CropperOptions): Promise<RNImage> {
|
|||||||
name: 'crop-image',
|
name: 'crop-image',
|
||||||
uri: opts.path,
|
uri: opts.path,
|
||||||
dimensions:
|
dimensions:
|
||||||
opts.height && opts.width
|
opts.width && opts.height
|
||||||
? {width: opts.width, height: opts.height}
|
? {width: opts.width, height: opts.height}
|
||||||
: undefined,
|
: undefined,
|
||||||
onSelect: (img?: RNImage) => {
|
onSelect: (img?: RNImage) => {
|
||||||
|
|||||||
@@ -0,0 +1,297 @@
|
|||||||
|
import uuid from 'react-native-uuid'
|
||||||
|
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'] & {aspect: number}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 function createInitialImages(
|
||||||
|
uris: {uri: string; width: number; height: number}[] | undefined,
|
||||||
|
): ComposerImageWithoutTransformation[] {
|
||||||
|
if (uris === undefined) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return uris.map(({uri, width, height}) => {
|
||||||
|
return {
|
||||||
|
alt: '',
|
||||||
|
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, uuid.v4() + '')
|
||||||
|
|
||||||
|
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,8 +3,6 @@ import {Image as RNImage} from 'react-native-image-crop-picker'
|
|||||||
import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api'
|
import {AppBskyActorDefs, AppBskyGraphDefs} from '@atproto/api'
|
||||||
|
|
||||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||||
import {GalleryModel} from '#/state/models/media/gallery'
|
|
||||||
import {ImageModel} from '#/state/models/media/image'
|
|
||||||
import {ThreadgateSetting} from '../queries/threadgate'
|
import {ThreadgateSetting} from '../queries/threadgate'
|
||||||
|
|
||||||
export interface EditProfileModal {
|
export interface EditProfileModal {
|
||||||
@@ -38,12 +36,6 @@ export interface ListAddRemoveUsersModal {
|
|||||||
) => void
|
) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EditImageModal {
|
|
||||||
name: 'edit-image'
|
|
||||||
image: ImageModel
|
|
||||||
gallery: GalleryModel
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CropImageModal {
|
export interface CropImageModal {
|
||||||
name: 'crop-image'
|
name: 'crop-image'
|
||||||
uri: string
|
uri: string
|
||||||
@@ -51,11 +43,6 @@ export interface CropImageModal {
|
|||||||
onSelect: (img?: RNImage) => void
|
onSelect: (img?: RNImage) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AltTextImageModal {
|
|
||||||
name: 'alt-text-image'
|
|
||||||
image: ImageModel
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeleteAccountModal {
|
export interface DeleteAccountModal {
|
||||||
name: 'delete-account'
|
name: 'delete-account'
|
||||||
}
|
}
|
||||||
@@ -151,9 +138,7 @@ export type Modal =
|
|||||||
| ListAddRemoveUsersModal
|
| ListAddRemoveUsersModal
|
||||||
|
|
||||||
// Posts
|
// Posts
|
||||||
| AltTextImageModal
|
|
||||||
| CropImageModal
|
| CropImageModal
|
||||||
| EditImageModal
|
|
||||||
| RepostModal
|
| RepostModal
|
||||||
| SelfLabelModal
|
| SelfLabelModal
|
||||||
| ThreadgateModal
|
| ThreadgateModal
|
||||||
|
|||||||
@@ -1,110 +0,0 @@
|
|||||||
import {makeAutoObservable, runInAction} from 'mobx'
|
|
||||||
import {ImageModel} from './image'
|
|
||||||
import {Image as RNImage} from 'react-native-image-crop-picker'
|
|
||||||
import {openPicker} from 'lib/media/picker'
|
|
||||||
import {getImageDim} from 'lib/media/manip'
|
|
||||||
|
|
||||||
interface InitialImageUri {
|
|
||||||
uri: string
|
|
||||||
width: number
|
|
||||||
height: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export class GalleryModel {
|
|
||||||
images: ImageModel[] = []
|
|
||||||
|
|
||||||
constructor(uris?: {uri: string; width: number; height: number}[]) {
|
|
||||||
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_: Omit<RNImage, 'size'>) {
|
|
||||||
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,
|
|
||||||
mime: 'image/jpeg',
|
|
||||||
}
|
|
||||||
|
|
||||||
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({
|
|
||||||
mime: 'image/jpeg',
|
|
||||||
height: uriObj.height,
|
|
||||||
width: uriObj.width,
|
|
||||||
path: uriObj.uri,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,299 +0,0 @@
|
|||||||
import {Image as RNImage} from 'react-native-image-crop-picker'
|
|
||||||
import {makeAutoObservable, runInAction} from 'mobx'
|
|
||||||
import {POST_IMG_MAX} from 'lib/constants'
|
|
||||||
import * as ImageManipulator from 'expo-image-manipulator'
|
|
||||||
import {getDataUriSize} from 'lib/media/util'
|
|
||||||
import {openCropper} from 'lib/media/picker'
|
|
||||||
import {ActionCrop, FlipType, SaveFormat} from 'expo-image-manipulator'
|
|
||||||
import {Position} from 'react-avatar-editor'
|
|
||||||
import {Dimensions} from 'lib/media/types'
|
|
||||||
import {isIOS} from 'platform/detection'
|
|
||||||
import {logger} from '#/logger'
|
|
||||||
|
|
||||||
export interface ImageManipulationAttributes {
|
|
||||||
aspectRatio?: '4:3' | '1:1' | '3:4' | 'None'
|
|
||||||
rotate?: number
|
|
||||||
scale?: number
|
|
||||||
position?: Position
|
|
||||||
flipHorizontal?: boolean
|
|
||||||
flipVertical?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
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: 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() {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {
|
import {
|
||||||
|
AppBskyActorDefs,
|
||||||
AppBskyEmbedRecord,
|
AppBskyEmbedRecord,
|
||||||
AppBskyRichtextFacet,
|
AppBskyRichtextFacet,
|
||||||
ModerationDecision,
|
ModerationDecision,
|
||||||
AppBskyActorDefs,
|
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
|
|
||||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||||
|
import {purgeTemporaryImageFiles} from '../gallery'
|
||||||
|
|
||||||
export interface ComposerOptsPostRef {
|
export interface ComposerOptsPostRef {
|
||||||
uri: string
|
uri: string
|
||||||
@@ -62,6 +64,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
|||||||
|
|
||||||
const closeComposer = useNonReactiveCallback(() => {
|
const closeComposer = useNonReactiveCallback(() => {
|
||||||
let wasOpen = !!state
|
let wasOpen = !!state
|
||||||
|
|
||||||
|
purgeTemporaryImageFiles()
|
||||||
setState(undefined)
|
setState(undefined)
|
||||||
return wasOpen
|
return wasOpen
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {RichText} from '@atproto/api'
|
|||||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {observer} from 'mobx-react-lite'
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createGIFDescription,
|
createGIFDescription,
|
||||||
@@ -26,6 +25,7 @@ import {LikelyType} from '#/lib/link-meta/link-meta'
|
|||||||
import {logEvent} from '#/lib/statsig/statsig'
|
import {logEvent} from '#/lib/statsig/statsig'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {emitPostCreated} from '#/state/events'
|
import {emitPostCreated} from '#/state/events'
|
||||||
|
import {ComposerImage, createInitialImages, pasteImage} from '#/state/gallery'
|
||||||
import {useModals} from '#/state/modals'
|
import {useModals} from '#/state/modals'
|
||||||
import {useRequireAltTextEnabled} from '#/state/preferences'
|
import {useRequireAltTextEnabled} from '#/state/preferences'
|
||||||
import {
|
import {
|
||||||
@@ -50,7 +50,6 @@ import {shortenLinks} from 'lib/strings/rich-text-manip'
|
|||||||
import {colors, gradients, s} from 'lib/styles'
|
import {colors, gradients, s} from 'lib/styles'
|
||||||
import {isAndroid, isIOS, isNative, isWeb} from 'platform/detection'
|
import {isAndroid, isIOS, isNative, isWeb} from 'platform/detection'
|
||||||
import {useDialogStateControlContext} from 'state/dialogs'
|
import {useDialogStateControlContext} from 'state/dialogs'
|
||||||
import {GalleryModel} from 'state/models/media/gallery'
|
|
||||||
import {ComposerOpts} from 'state/shell/composer'
|
import {ComposerOpts} from 'state/shell/composer'
|
||||||
import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo'
|
import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo'
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
@@ -77,8 +76,10 @@ import {TextInput, TextInputRef} from './text-input/TextInput'
|
|||||||
import {ThreadgateBtn} from './threadgate/ThreadgateBtn'
|
import {ThreadgateBtn} from './threadgate/ThreadgateBtn'
|
||||||
import {useExternalLinkFetch} from './useExternalLinkFetch'
|
import {useExternalLinkFetch} from './useExternalLinkFetch'
|
||||||
|
|
||||||
|
const MAX_IMAGES = 4
|
||||||
|
|
||||||
type Props = ComposerOpts
|
type Props = ComposerOpts
|
||||||
export const ComposePost = observer(function ComposePost({
|
export const ComposePost = ({
|
||||||
replyTo,
|
replyTo,
|
||||||
onPost,
|
onPost,
|
||||||
quote: initQuote,
|
quote: initQuote,
|
||||||
@@ -86,7 +87,7 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
openPicker,
|
openPicker,
|
||||||
text: initText,
|
text: initText,
|
||||||
imageUris: initImageUris,
|
imageUris: initImageUris,
|
||||||
}: Props) {
|
}: Props) => {
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const {getAgent} = useAgent()
|
const {getAgent} = useAgent()
|
||||||
const {data: currentProfile} = useProfileQuery({did: currentAccount!.did})
|
const {data: currentProfile} = useProfileQuery({did: currentAccount!.did})
|
||||||
@@ -123,6 +124,7 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
const graphemeLength = useMemo(() => {
|
const graphemeLength = useMemo(() => {
|
||||||
return shortenLinks(richtext).graphemeLength
|
return shortenLinks(richtext).graphemeLength
|
||||||
}, [richtext])
|
}, [richtext])
|
||||||
|
|
||||||
const [quote, setQuote] = useState<ComposerOpts['quote'] | undefined>(
|
const [quote, setQuote] = useState<ComposerOpts['quote'] | undefined>(
|
||||||
initQuote,
|
initQuote,
|
||||||
)
|
)
|
||||||
@@ -130,10 +132,11 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
const [extGif, setExtGif] = useState<Gif>()
|
const [extGif, setExtGif] = useState<Gif>()
|
||||||
const [labels, setLabels] = useState<string[]>([])
|
const [labels, setLabels] = useState<string[]>([])
|
||||||
const [threadgate, setThreadgate] = useState<ThreadgateSetting[]>([])
|
const [threadgate, setThreadgate] = useState<ThreadgateSetting[]>([])
|
||||||
const gallery = useMemo(
|
|
||||||
() => new GalleryModel(initImageUris),
|
const [images, setImages] = useState<ComposerImage[]>(() =>
|
||||||
[initImageUris],
|
createInitialImages(initImageUris),
|
||||||
)
|
)
|
||||||
|
|
||||||
const onClose = useCallback(() => {
|
const onClose = useCallback(() => {
|
||||||
closeComposer()
|
closeComposer()
|
||||||
}, [closeComposer])
|
}, [closeComposer])
|
||||||
@@ -149,7 +152,7 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const onPressCancel = useCallback(() => {
|
const onPressCancel = useCallback(() => {
|
||||||
if (graphemeLength > 0 || !gallery.isEmpty) {
|
if (graphemeLength > 0 || images.length !== 0) {
|
||||||
closeAllDialogs()
|
closeAllDialogs()
|
||||||
if (Keyboard) {
|
if (Keyboard) {
|
||||||
Keyboard.dismiss()
|
Keyboard.dismiss()
|
||||||
@@ -160,7 +163,7 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
graphemeLength,
|
graphemeLength,
|
||||||
gallery.isEmpty,
|
images.length,
|
||||||
closeAllDialogs,
|
closeAllDialogs,
|
||||||
discardPromptControl,
|
discardPromptControl,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -207,18 +210,27 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
[extLink, setExtLink],
|
[extLink, setExtLink],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const onImageAdd = useCallback(
|
||||||
|
(next: ComposerImage[]) => {
|
||||||
|
setImages(prev => prev.concat(next.slice(0, MAX_IMAGES - prev.length)))
|
||||||
|
},
|
||||||
|
[setImages],
|
||||||
|
)
|
||||||
|
|
||||||
const onPhotoPasted = useCallback(
|
const onPhotoPasted = useCallback(
|
||||||
async (uri: string) => {
|
async (uri: string) => {
|
||||||
track('Composer:PastedPhotos')
|
track('Composer:PastedPhotos')
|
||||||
await gallery.paste(uri)
|
|
||||||
|
const res = await pasteImage(uri)
|
||||||
|
onImageAdd([res])
|
||||||
},
|
},
|
||||||
[gallery, track],
|
[track, onImageAdd],
|
||||||
)
|
)
|
||||||
|
|
||||||
const isAltTextRequiredAndMissing = useMemo(() => {
|
const isAltTextRequiredAndMissing = useMemo(() => {
|
||||||
if (!requireAltTextEnabled) return false
|
if (!requireAltTextEnabled) return false
|
||||||
|
|
||||||
if (gallery.needsAltText) return true
|
if (images.some(img => img.alt === '')) return true
|
||||||
if (extGif) {
|
if (extGif) {
|
||||||
if (!extLink?.meta?.description) return true
|
if (!extLink?.meta?.description) return true
|
||||||
|
|
||||||
@@ -226,7 +238,7 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
if (!parsedAlt.isPreferred) return true
|
if (!parsedAlt.isPreferred) return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}, [gallery.needsAltText, extLink, extGif, requireAltTextEnabled])
|
}, [images, extLink, extGif, requireAltTextEnabled])
|
||||||
|
|
||||||
const onPressPublish = async () => {
|
const onPressPublish = async () => {
|
||||||
if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) {
|
if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) {
|
||||||
@@ -241,7 +253,7 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
richtext.text.trim().length === 0 &&
|
richtext.text.trim().length === 0 &&
|
||||||
gallery.isEmpty &&
|
images.length === 0 &&
|
||||||
!extLink &&
|
!extLink &&
|
||||||
!quote
|
!quote
|
||||||
) {
|
) {
|
||||||
@@ -261,7 +273,7 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
await apilib.post(getAgent(), {
|
await apilib.post(getAgent(), {
|
||||||
rawText: richtext.text,
|
rawText: richtext.text,
|
||||||
replyTo: replyTo?.uri,
|
replyTo: replyTo?.uri,
|
||||||
images: gallery.images,
|
images: images,
|
||||||
quote,
|
quote,
|
||||||
extLink,
|
extLink,
|
||||||
labels,
|
labels,
|
||||||
@@ -273,7 +285,7 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
logger.error(e, {
|
logger.error(e, {
|
||||||
message: `Composer: create post failed`,
|
message: `Composer: create post failed`,
|
||||||
hasImages: gallery.size > 0,
|
hasImages: images.length > 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (extLink) {
|
if (extLink) {
|
||||||
@@ -289,7 +301,7 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
} finally {
|
} finally {
|
||||||
if (postUri) {
|
if (postUri) {
|
||||||
logEvent('post:create', {
|
logEvent('post:create', {
|
||||||
imageCount: gallery.size,
|
imageCount: images.length,
|
||||||
isReply: replyTo != null,
|
isReply: replyTo != null,
|
||||||
hasLink: extLink != null,
|
hasLink: extLink != null,
|
||||||
hasQuote: quote != null,
|
hasQuote: quote != null,
|
||||||
@@ -298,7 +310,7 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
track('Create Post', {
|
track('Create Post', {
|
||||||
imageCount: gallery.size,
|
imageCount: images.length,
|
||||||
})
|
})
|
||||||
if (replyTo && replyTo.uri) track('Post:Reply')
|
if (replyTo && replyTo.uri) track('Post:Reply')
|
||||||
}
|
}
|
||||||
@@ -323,8 +335,8 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
? _(msg`Write your reply`)
|
? _(msg`Write your reply`)
|
||||||
: _(msg`What's up?`)
|
: _(msg`What's up?`)
|
||||||
|
|
||||||
const canSelectImages = gallery.size < 4 && !extLink
|
const canSelectImages = images.length < MAX_IMAGES && !extLink
|
||||||
const hasMedia = gallery.size > 0 || Boolean(extLink)
|
const hasMedia = images.length > 0 || Boolean(extLink)
|
||||||
|
|
||||||
const onEmojiButtonPress = useCallback(() => {
|
const onEmojiButtonPress = useCallback(() => {
|
||||||
openPicker?.(textInput.current?.getCursorPosition())
|
openPicker?.(textInput.current?.getCursorPosition())
|
||||||
@@ -509,8 +521,8 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Gallery gallery={gallery} />
|
<Gallery images={images} onChange={setImages} />
|
||||||
{gallery.isEmpty && extLink && (
|
{images.length === 0 && extLink && (
|
||||||
<View style={a.relative}>
|
<View style={a.relative}>
|
||||||
<ExternalEmbed
|
<ExternalEmbed
|
||||||
link={extLink}
|
link={extLink}
|
||||||
@@ -541,8 +553,12 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
<SuggestedLanguage text={richtext.text} />
|
<SuggestedLanguage text={richtext.text} />
|
||||||
<View style={[pal.border, styles.bottomBar]}>
|
<View style={[pal.border, styles.bottomBar]}>
|
||||||
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||||
<SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} />
|
<SelectPhotoBtn
|
||||||
<OpenCameraBtn gallery={gallery} disabled={!canSelectImages} />
|
size={images.length}
|
||||||
|
disabled={!canSelectImages}
|
||||||
|
onAdd={onImageAdd}
|
||||||
|
/>
|
||||||
|
<OpenCameraBtn disabled={!canSelectImages} onAdd={onImageAdd} />
|
||||||
<SelectGifBtn
|
<SelectGifBtn
|
||||||
onClose={focusTextInput}
|
onClose={focusTextInput}
|
||||||
onSelectGif={onSelectGif}
|
onSelectGif={onSelectGif}
|
||||||
@@ -577,7 +593,7 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
/>
|
/>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
outer: {
|
outer: {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export const ExternalEmbed = ({
|
|||||||
title: link.meta?.title ?? link.uri,
|
title: link.meta?.title ?? link.uri,
|
||||||
uri: link.uri,
|
uri: link.uri,
|
||||||
description: link.meta?.description ?? '',
|
description: link.meta?.description ?? '',
|
||||||
thumb: link.localThumb?.path,
|
thumb: link.localThumb?.source.path,
|
||||||
},
|
},
|
||||||
[link],
|
[link],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function GifAltText({
|
|||||||
title: linkProp.meta?.title ?? linkProp.uri,
|
title: linkProp.meta?.title ?? linkProp.uri,
|
||||||
uri: linkProp.uri,
|
uri: linkProp.uri,
|
||||||
description: linkProp.meta?.description ?? '',
|
description: linkProp.meta?.description ?? '',
|
||||||
thumb: linkProp.localThumb?.path,
|
thumb: linkProp.localThumb?.source.path,
|
||||||
},
|
},
|
||||||
params: parseEmbedPlayerFromUrl(linkProp.uri),
|
params: parseEmbedPlayerFromUrl(linkProp.uri),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import {ComposerImage} from '#/state/gallery'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
|
||||||
|
export type EditImageDialogProps = {
|
||||||
|
control: Dialog.DialogOuterProps['control']
|
||||||
|
image: ComposerImage
|
||||||
|
onChange: (next: ComposerImage) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EditImageDialog = ({}: EditImageDialogProps): React.ReactNode => {
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import 'react-image-crop/dist/ReactCrop.css'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {Path, Svg} from 'react-native-svg'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import ReactCrop, {PercentCrop} from 'react-image-crop'
|
||||||
|
|
||||||
|
import {manipulateImage} from '#/state/gallery'
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Button, ButtonText} from '#/components/Button'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import {EditImageDialogProps} from './EditImageDialog'
|
||||||
|
|
||||||
|
export const EditImageDialog = (props: EditImageDialogProps) => {
|
||||||
|
return (
|
||||||
|
<Dialog.Outer control={props.control}>
|
||||||
|
<EditImageInner key={props.image.source.id} {...props} />
|
||||||
|
</Dialog.Outer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditImageInner = ({control, image, onChange}: EditImageDialogProps) => {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const t = useTheme()
|
||||||
|
|
||||||
|
const source = image.source
|
||||||
|
const manips = image.manips
|
||||||
|
|
||||||
|
const {initialCrop, initialAspect, sourceAspect} = React.useMemo(() => {
|
||||||
|
const initialArea = manips?.crop
|
||||||
|
const originalAspect = source.width / source.height
|
||||||
|
|
||||||
|
let crop: PercentCrop | undefined
|
||||||
|
let aspect = originalAspect
|
||||||
|
|
||||||
|
if (initialArea) {
|
||||||
|
crop = {
|
||||||
|
unit: '%',
|
||||||
|
x: (initialArea.originX / source.width) * 100,
|
||||||
|
y: (initialArea.originY / source.height) * 100,
|
||||||
|
width: (initialArea.width / source.width) * 100,
|
||||||
|
height: (initialArea.height / source.height) * 100,
|
||||||
|
}
|
||||||
|
|
||||||
|
aspect = initialArea.aspect
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
initialCrop: crop,
|
||||||
|
initialAspect: aspect,
|
||||||
|
sourceAspect: originalAspect,
|
||||||
|
}
|
||||||
|
}, [source, manips])
|
||||||
|
|
||||||
|
const [crop, setCrop] = React.useState(initialCrop)
|
||||||
|
const [aspect, setAspect] = React.useState(initialAspect)
|
||||||
|
|
||||||
|
const isEmpty = !crop || (crop.width || crop.height) === 0
|
||||||
|
const isNew = initialCrop ? true : !isEmpty
|
||||||
|
|
||||||
|
const onPressSubmit = React.useCallback(async () => {
|
||||||
|
const result = await manipulateImage(image, {
|
||||||
|
crop: !isEmpty
|
||||||
|
? {
|
||||||
|
aspect: aspect,
|
||||||
|
originX: (crop.x * source.width) / 100,
|
||||||
|
originY: (crop.y * source.height) / 100,
|
||||||
|
width: (crop.width * source.width) / 100,
|
||||||
|
height: (crop.height * source.height) / 100,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
onChange(result)
|
||||||
|
control.close()
|
||||||
|
}, [crop, isEmpty, aspect, image, source, control, onChange])
|
||||||
|
|
||||||
|
const changeAspect = (next: number) => {
|
||||||
|
if (next !== aspect) {
|
||||||
|
setAspect(next)
|
||||||
|
setCrop(undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.Inner label={_(msg`Edit image`)}>
|
||||||
|
<Dialog.Close />
|
||||||
|
|
||||||
|
<Text style={[a.text_2xl, a.font_bold, a.leading_tight, a.pb_sm]}>
|
||||||
|
<Trans>Edit image</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={[a.align_center]}>
|
||||||
|
<ReactCrop
|
||||||
|
aspect={aspect}
|
||||||
|
crop={crop}
|
||||||
|
onChange={(_, next) => setCrop(next)}
|
||||||
|
className="ReactCrop--no-animate">
|
||||||
|
<img src={source.path} style={{maxHeight: `50vh`}} />
|
||||||
|
</ReactCrop>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[a.mt_md, a.gap_md]}>
|
||||||
|
<View style={[a.flex_row]}>
|
||||||
|
<ToolbarButton
|
||||||
|
label={_(msg`Original`)}
|
||||||
|
onPress={() => changeAspect(sourceAspect)}>
|
||||||
|
<Svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||||
|
<Path
|
||||||
|
d="M3 5H21V19H3V5Z"
|
||||||
|
stroke={
|
||||||
|
aspect === sourceAspect
|
||||||
|
? t.palette.primary_600
|
||||||
|
: t.palette.white
|
||||||
|
}
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
</ToolbarButton>
|
||||||
|
|
||||||
|
<ToolbarButton
|
||||||
|
label={_(msg`Wide`)}
|
||||||
|
onPress={() => changeAspect(16 / 9)}>
|
||||||
|
<Svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||||
|
<Path
|
||||||
|
d="M1 7H23V17H1V7Z"
|
||||||
|
stroke={
|
||||||
|
aspect === 16 / 9 ? t.palette.primary_600 : t.palette.white
|
||||||
|
}
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
</ToolbarButton>
|
||||||
|
|
||||||
|
<ToolbarButton
|
||||||
|
label={_(msg`Square`)}
|
||||||
|
onPress={() => changeAspect(1 / 1)}>
|
||||||
|
<Svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||||
|
<Path
|
||||||
|
d="M4 4H20V20H4V4Z"
|
||||||
|
stroke={
|
||||||
|
aspect === 1 / 1 ? t.palette.primary_600 : t.palette.white
|
||||||
|
}
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</Svg>
|
||||||
|
</ToolbarButton>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
disabled={!isNew}
|
||||||
|
label={_(msg`Save`)}
|
||||||
|
size="medium"
|
||||||
|
color="primary"
|
||||||
|
variant="solid"
|
||||||
|
onPress={onPressSubmit}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Save</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</Dialog.Inner>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToolbarButton = ({
|
||||||
|
children,
|
||||||
|
label,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
children: React.ReactElement
|
||||||
|
label: string
|
||||||
|
onPress?: () => void
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
onPress={onPress}
|
||||||
|
label={label}
|
||||||
|
style={a.p_sm}
|
||||||
|
variant="ghost"
|
||||||
|
shape="round"
|
||||||
|
color="primary">
|
||||||
|
{children}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,29 +1,31 @@
|
|||||||
import React, {useState} from 'react'
|
import React from 'react'
|
||||||
import {ImageStyle, Keyboard, LayoutChangeEvent} from 'react-native'
|
import {ImageStyle, LayoutChangeEvent, ViewStyle} from 'react-native'
|
||||||
import {StyleSheet, TouchableOpacity, View} from 'react-native'
|
import {StyleSheet, TouchableOpacity, View} from 'react-native'
|
||||||
import {Image} from 'expo-image'
|
import {Image} from 'expo-image'
|
||||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {observer} from 'mobx-react-lite'
|
|
||||||
|
|
||||||
import {useModalControls} from '#/state/modals'
|
import {ComposerImage, cropImage} from '#/state/gallery'
|
||||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||||
import {Dimensions} from 'lib/media/types'
|
import {Dimensions} from 'lib/media/types'
|
||||||
import {colors, s} from 'lib/styles'
|
import {colors, s} from 'lib/styles'
|
||||||
import {isNative} from 'platform/detection'
|
import {isNative} from 'platform/detection'
|
||||||
import {GalleryModel} from 'state/models/media/gallery'
|
|
||||||
import {Text} from 'view/com/util/text/Text'
|
import {Text} from 'view/com/util/text/Text'
|
||||||
import {useTheme} from '#/alf'
|
import {useTheme} from '#/alf'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import {EditImageDialog} from './EditImageDialog'
|
||||||
|
import {ImageAltTextDialog} from './ImageAltTextDialog'
|
||||||
|
|
||||||
const IMAGE_GAP = 8
|
const IMAGE_GAP = 8
|
||||||
|
|
||||||
interface GalleryProps {
|
interface GalleryProps {
|
||||||
gallery: GalleryModel
|
images: ComposerImage[]
|
||||||
|
onChange: (next: ComposerImage[]) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Gallery = (props: GalleryProps) => {
|
export let Gallery = (props: GalleryProps): React.ReactNode => {
|
||||||
const [containerInfo, setContainerInfo] = useState<Dimensions | undefined>()
|
const [containerInfo, setContainerInfo] = React.useState<Dimensions>()
|
||||||
|
|
||||||
const onLayout = (evt: LayoutChangeEvent) => {
|
const onLayout = (evt: LayoutChangeEvent) => {
|
||||||
const {width, height} = evt.nativeEvent.layout
|
const {width, height} = evt.nativeEvent.layout
|
||||||
@@ -41,177 +43,195 @@ export const Gallery = (props: GalleryProps) => {
|
|||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Gallery = React.memo(Gallery)
|
||||||
|
|
||||||
interface GalleryInnerProps extends GalleryProps {
|
interface GalleryInnerProps extends GalleryProps {
|
||||||
containerInfo: Dimensions
|
containerInfo: Dimensions
|
||||||
}
|
}
|
||||||
|
|
||||||
const GalleryInner = observer(function GalleryImpl({
|
const GalleryInner = ({images, containerInfo, onChange}: GalleryInnerProps) => {
|
||||||
gallery,
|
|
||||||
containerInfo,
|
|
||||||
}: GalleryInnerProps) {
|
|
||||||
const {_} = useLingui()
|
|
||||||
const {isMobile} = useWebMediaQueries()
|
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) {
|
const isOverflow = isMobile && images.length > 2
|
||||||
side = 250
|
|
||||||
} else {
|
|
||||||
side = (containerInfo.width - IMAGE_GAP * (gallery.size - 1)) / gallery.size
|
|
||||||
}
|
|
||||||
|
|
||||||
const imageStyle = {
|
return {
|
||||||
height: side,
|
altTextControlStyle: isOverflow
|
||||||
width: side,
|
? {left: 4, bottom: 4}
|
||||||
}
|
: !isMobile && images.length < 3
|
||||||
|
? {left: 8, top: 8}
|
||||||
const isOverflow = isMobile && gallery.size > 2
|
: {left: 4, top: 4},
|
||||||
|
imageControlsStyle: {
|
||||||
const altTextControlStyle = isOverflow
|
display: 'flex' as const,
|
||||||
? {
|
flexDirection: 'row' as const,
|
||||||
left: 4,
|
position: 'absolute' as const,
|
||||||
bottom: 4,
|
...(isOverflow
|
||||||
}
|
? {top: 4, right: 4, gap: 4}
|
||||||
: !isMobile && gallery.size < 3
|
: !isMobile && images.length < 3
|
||||||
? {
|
? {top: 8, right: 8, gap: 8}
|
||||||
left: 8,
|
: {top: 4, right: 4, gap: 4}),
|
||||||
top: 8,
|
zIndex: 1,
|
||||||
}
|
},
|
||||||
: {
|
imageStyle: {
|
||||||
left: 4,
|
height: side,
|
||||||
top: 4,
|
width: side,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
}, [images.length, containerInfo, isMobile])
|
||||||
|
|
||||||
const imageControlsStyle = {
|
return images.length !== 0 ? (
|
||||||
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 ? (
|
|
||||||
<>
|
<>
|
||||||
<View testID="selectedPhotosView" style={styles.gallery}>
|
<View testID="selectedPhotosView" style={styles.gallery}>
|
||||||
{gallery.images.map(image => (
|
{images.map((image, index) => {
|
||||||
<View key={`selected-image-${image.path}`} style={[imageStyle]}>
|
return (
|
||||||
<TouchableOpacity
|
<GalleryItem
|
||||||
testID="altTextButton"
|
key={image.source.id}
|
||||||
accessibilityRole="button"
|
image={image}
|
||||||
accessibilityLabel={_(msg`Add alt text`)}
|
altTextControlStyle={altTextControlStyle}
|
||||||
accessibilityHint=""
|
imageControlsStyle={imageControlsStyle}
|
||||||
onPress={() => {
|
imageStyle={imageStyle}
|
||||||
Keyboard.dismiss()
|
onChange={next => {
|
||||||
openModal({
|
onChange(
|
||||||
name: 'alt-text-image',
|
images.map(i => (i.source === image.source ? next : i)),
|
||||||
image,
|
)
|
||||||
})
|
|
||||||
}}
|
}}
|
||||||
style={[styles.altTextControl, altTextControlStyle]}>
|
onRemove={() => {
|
||||||
{image.altText.length > 0 ? (
|
const next = images.slice()
|
||||||
<FontAwesomeIcon
|
next.splice(index, 1)
|
||||||
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}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Image
|
onChange(next)
|
||||||
testID="selectedPhotoImage"
|
|
||||||
style={[styles.image, imageStyle] as ImageStyle}
|
|
||||||
source={{
|
|
||||||
uri: image.cropped?.path ?? image.path,
|
|
||||||
}}
|
}}
|
||||||
accessible={true}
|
|
||||||
accessibilityIgnoresInvertColors
|
|
||||||
/>
|
/>
|
||||||
</View>
|
)
|
||||||
))}
|
})}
|
||||||
</View>
|
</View>
|
||||||
<AltTextReminder />
|
<AltTextReminder />
|
||||||
</>
|
</>
|
||||||
) : null
|
) : 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 altTextControl = Dialog.useDialogControl()
|
||||||
|
const editControl = Dialog.useDialogControl()
|
||||||
|
|
||||||
|
const onEdit = () => {
|
||||||
|
if (isNative) {
|
||||||
|
cropImage(image).then(next => {
|
||||||
|
onChange(next)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
editControl.open()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={imageStyle}>
|
||||||
|
<TouchableOpacity
|
||||||
|
testID="altTextButton"
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={_(msg`Add alt text`)}
|
||||||
|
accessibilityHint=""
|
||||||
|
onPress={altTextControl.open}
|
||||||
|
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}>
|
||||||
|
<TouchableOpacity
|
||||||
|
testID="editPhotoButton"
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={_(msg`Edit image`)}
|
||||||
|
accessibilityHint=""
|
||||||
|
onPress={onEdit}
|
||||||
|
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={altTextControl.open}
|
||||||
|
style={styles.altTextHiddenRegion}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Image
|
||||||
|
testID="selectedPhotoImage"
|
||||||
|
style={[styles.image, imageStyle] as ImageStyle}
|
||||||
|
source={{
|
||||||
|
uri: (image.transformed ?? image.source).path,
|
||||||
|
}}
|
||||||
|
accessible={true}
|
||||||
|
accessibilityIgnoresInvertColors
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ImageAltTextDialog
|
||||||
|
control={altTextControl}
|
||||||
|
image={image}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EditImageDialog
|
||||||
|
control={editControl}
|
||||||
|
image={image}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function AltTextReminder() {
|
export function AltTextReminder() {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import {ImageStyle, useWindowDimensions, View} from 'react-native'
|
||||||
|
import {Image} from 'expo-image'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {isWeb} from '#/platform/detection'
|
||||||
|
import {ComposerImage} from '#/state/gallery'
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Button, ButtonText} from '#/components/Button'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import * as TextField from '#/components/forms/TextField'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
control: Dialog.DialogOuterProps['control']
|
||||||
|
image: ComposerImage
|
||||||
|
onChange: (next: ComposerImage) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ImageAltTextDialog = (props: Props): React.ReactNode => {
|
||||||
|
return (
|
||||||
|
<Dialog.Outer control={props.control}>
|
||||||
|
<Dialog.Handle />
|
||||||
|
|
||||||
|
<ImageAltTextInner {...props} />
|
||||||
|
</Dialog.Outer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ImageAltTextInner = ({
|
||||||
|
control,
|
||||||
|
image,
|
||||||
|
onChange,
|
||||||
|
}: Props): React.ReactNode => {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const t = useTheme()
|
||||||
|
|
||||||
|
const windim = useWindowDimensions()
|
||||||
|
|
||||||
|
const [altText, setAltText] = React.useState(image.alt)
|
||||||
|
const trimmed = React.useMemo(() => altText.trim(), [altText])
|
||||||
|
|
||||||
|
const onPressSubmit = React.useCallback(() => {
|
||||||
|
control.close()
|
||||||
|
onChange({...image, alt: trimmed})
|
||||||
|
}, [control, image, trimmed, onChange])
|
||||||
|
|
||||||
|
const imageStyle = React.useMemo<ImageStyle>(() => {
|
||||||
|
const maxWidth = isWeb ? 450 : windim.width
|
||||||
|
const source = image.transformed ?? image.source
|
||||||
|
|
||||||
|
if (source.height > source.width) {
|
||||||
|
return {
|
||||||
|
resizeMode: 'contain',
|
||||||
|
width: '100%',
|
||||||
|
aspectRatio: 1,
|
||||||
|
borderRadius: 8,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
width: '100%',
|
||||||
|
height: (maxWidth / source.width) * source.height,
|
||||||
|
borderRadius: 8,
|
||||||
|
}
|
||||||
|
}, [image, windim])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.ScrollableInner label={_(msg`Add alt text`)}>
|
||||||
|
<Dialog.Close />
|
||||||
|
|
||||||
|
<View>
|
||||||
|
<Text style={[a.text_2xl, a.font_bold, a.leading_tight, a.pb_sm]}>
|
||||||
|
<Trans>Add ALT text</Trans>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={[t.atoms.bg_contrast_50, a.rounded_sm, a.overflow_hidden]}>
|
||||||
|
<Image
|
||||||
|
style={imageStyle}
|
||||||
|
source={{
|
||||||
|
uri: (image.transformed ?? image.source).path,
|
||||||
|
}}
|
||||||
|
contentFit="contain"
|
||||||
|
accessible={true}
|
||||||
|
accessibilityIgnoresInvertColors
|
||||||
|
enableLiveTextInteraction
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[a.mt_md, a.gap_md]}>
|
||||||
|
<View>
|
||||||
|
<TextField.LabelText>
|
||||||
|
<Trans>Descriptive alt text</Trans>
|
||||||
|
</TextField.LabelText>
|
||||||
|
<TextField.Root>
|
||||||
|
<Dialog.Input
|
||||||
|
label={_(msg`Alt text`)}
|
||||||
|
onChangeText={text => setAltText(text)}
|
||||||
|
value={altText}
|
||||||
|
multiline
|
||||||
|
numberOfLines={3}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</TextField.Root>
|
||||||
|
</View>
|
||||||
|
<Button
|
||||||
|
label={_(msg`Save`)}
|
||||||
|
size="medium"
|
||||||
|
color="primary"
|
||||||
|
variant="solid"
|
||||||
|
onPress={onPressSubmit}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Save</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</Dialog.ScrollableInner>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -9,17 +9,17 @@ import {useCameraPermission} from '#/lib/hooks/usePermissions'
|
|||||||
import {openCamera} from '#/lib/media/picker'
|
import {openCamera} from '#/lib/media/picker'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {isMobileWeb, isNative} from '#/platform/detection'
|
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 {atoms as a, useTheme} from '#/alf'
|
||||||
import {Button} from '#/components/Button'
|
import {Button} from '#/components/Button'
|
||||||
import {Camera_Stroke2_Corner0_Rounded as Camera} from '#/components/icons/Camera'
|
import {Camera_Stroke2_Corner0_Rounded as Camera} from '#/components/icons/Camera'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
gallery: GalleryModel
|
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
onAdd: (next: ComposerImage[]) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OpenCameraBtn({gallery, disabled}: Props) {
|
export function OpenCameraBtn({disabled, onAdd}: Props) {
|
||||||
const {track} = useAnalytics()
|
const {track} = useAnalytics()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {requestCameraAccessIfNeeded} = useCameraPermission()
|
const {requestCameraAccessIfNeeded} = useCameraPermission()
|
||||||
@@ -48,13 +48,16 @@ export function OpenCameraBtn({gallery, disabled}: Props) {
|
|||||||
if (mediaPermissionRes) {
|
if (mediaPermissionRes) {
|
||||||
await MediaLibrary.createAssetAsync(img.path)
|
await MediaLibrary.createAssetAsync(img.path)
|
||||||
}
|
}
|
||||||
gallery.add(img)
|
|
||||||
|
const res = await createComposerImage(img)
|
||||||
|
|
||||||
|
onAdd([res])
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
// ignore
|
// ignore
|
||||||
logger.warn('Error using camera', {error: err})
|
logger.warn('Error using camera', {error: err})
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
gallery,
|
onAdd,
|
||||||
track,
|
track,
|
||||||
requestCameraAccessIfNeeded,
|
requestCameraAccessIfNeeded,
|
||||||
mediaPermissionRes,
|
mediaPermissionRes,
|
||||||
|
|||||||
@@ -5,18 +5,20 @@ import {useLingui} from '@lingui/react'
|
|||||||
|
|
||||||
import {useAnalytics} from '#/lib/analytics/analytics'
|
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||||
import {usePhotoLibraryPermission} from '#/lib/hooks/usePermissions'
|
import {usePhotoLibraryPermission} from '#/lib/hooks/usePermissions'
|
||||||
|
import {openPicker} from '#/lib/media/picker'
|
||||||
import {isNative} from '#/platform/detection'
|
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 {atoms as a, useTheme} from '#/alf'
|
||||||
import {Button} from '#/components/Button'
|
import {Button} from '#/components/Button'
|
||||||
import {Image_Stroke2_Corner0_Rounded as Image} from '#/components/icons/Image'
|
import {Image_Stroke2_Corner0_Rounded as Image} from '#/components/icons/Image'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
gallery: GalleryModel
|
size: number
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
onAdd: (next: ComposerImage[]) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SelectPhotoBtn({gallery, disabled}: Props) {
|
export function SelectPhotoBtn({size, disabled, onAdd}: Props) {
|
||||||
const {track} = useAnalytics()
|
const {track} = useAnalytics()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
|
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
|
||||||
@@ -29,8 +31,17 @@ export function SelectPhotoBtn({gallery, disabled}: Props) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
gallery.pick()
|
const images = await openPicker({
|
||||||
}, [track, requestPhotoAccessIfNeeded, gallery])
|
selectionLimit: 4 - size,
|
||||||
|
allowsMultipleSelection: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
images.map(img => createComposerImage(img)),
|
||||||
|
)
|
||||||
|
|
||||||
|
onAdd(results)
|
||||||
|
}, [track, requestPhotoAccessIfNeeded, size, onAdd])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {useEffect, useState} from 'react'
|
import {useEffect, useState} from 'react'
|
||||||
|
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
|
import {createComposerImage} from '#/state/gallery'
|
||||||
import {useFetchDid} from '#/state/queries/handle'
|
import {useFetchDid} from '#/state/queries/handle'
|
||||||
import {useGetPost} from '#/state/queries/post'
|
import {useGetPost} from '#/state/queries/post'
|
||||||
import {useAgent} from '#/state/session'
|
import {useAgent} from '#/state/session'
|
||||||
@@ -18,7 +19,6 @@ import {
|
|||||||
isBskyListUrl,
|
isBskyListUrl,
|
||||||
isBskyPostUrl,
|
isBskyPostUrl,
|
||||||
} from 'lib/strings/url-helpers'
|
} from 'lib/strings/url-helpers'
|
||||||
import {ImageModel} from 'state/models/media/image'
|
|
||||||
import {ComposerOpts} from 'state/shell/composer'
|
import {ComposerOpts} from 'state/shell/composer'
|
||||||
|
|
||||||
export function useExternalLinkFetch({
|
export function useExternalLinkFetch({
|
||||||
@@ -118,14 +118,16 @@ export function useExternalLinkFetch({
|
|||||||
timeout: 15e3,
|
timeout: 15e3,
|
||||||
})
|
})
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
.then(localThumb => {
|
.then(thumb => (thumb ? createComposerImage(thumb) : undefined))
|
||||||
|
.then(thumb => {
|
||||||
if (aborted) {
|
if (aborted) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setExtLink({
|
setExtLink({
|
||||||
...extLink,
|
...extLink,
|
||||||
isLoading: false, // done
|
isLoading: false, // done
|
||||||
localThumb: localThumb ? new ImageModel(localThumb) : undefined,
|
localThumb: thumb,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
return cleanup
|
return cleanup
|
||||||
|
|||||||
@@ -1,185 +0,0 @@
|
|||||||
import React, {useCallback, useMemo, useState} from 'react'
|
|
||||||
import {
|
|
||||||
ImageStyle,
|
|
||||||
ScrollView as RNScrollView,
|
|
||||||
StyleSheet,
|
|
||||||
TextInput as RNTextInput,
|
|
||||||
TouchableOpacity,
|
|
||||||
useWindowDimensions,
|
|
||||||
View,
|
|
||||||
} from 'react-native'
|
|
||||||
import {Image} from 'expo-image'
|
|
||||||
import {LinearGradient} from 'expo-linear-gradient'
|
|
||||||
import {msg, Trans} from '@lingui/macro'
|
|
||||||
import {useLingui} from '@lingui/react'
|
|
||||||
|
|
||||||
import {useModalControls} from '#/state/modals'
|
|
||||||
import {MAX_ALT_TEXT} from 'lib/constants'
|
|
||||||
import {useIsKeyboardVisible} from 'lib/hooks/useIsKeyboardVisible'
|
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
|
||||||
import {enforceLen} from 'lib/strings/helpers'
|
|
||||||
import {gradients, s} from 'lib/styles'
|
|
||||||
import {useTheme} from 'lib/ThemeContext'
|
|
||||||
import {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
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Component({image}: Props) {
|
|
||||||
const pal = usePalette('default')
|
|
||||||
const theme = useTheme()
|
|
||||||
const {_} = useLingui()
|
|
||||||
const [altText, setAltText] = useState(image.altText)
|
|
||||||
const windim = useWindowDimensions()
|
|
||||||
const {closeModal} = useModalControls()
|
|
||||||
const inputRef = React.useRef<RNTextInput>(null)
|
|
||||||
const scrollViewRef = React.useRef<RNScrollView>(null)
|
|
||||||
const keyboardShown = useIsKeyboardVisible()
|
|
||||||
|
|
||||||
// Autofocus hack when we open the modal. We have to wait for the animation to complete first
|
|
||||||
React.useEffect(() => {
|
|
||||||
setTimeout(() => {
|
|
||||||
inputRef.current?.focus()
|
|
||||||
}, 500)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// We'd rather be at the bottom here so that we can easily dismiss the modal instead of having to scroll
|
|
||||||
// (especially on android, it acts weird)
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (keyboardShown[0]) {
|
|
||||||
scrollViewRef.current?.scrollToEnd()
|
|
||||||
}
|
|
||||||
}, [keyboardShown])
|
|
||||||
|
|
||||||
const imageStyles = useMemo<ImageStyle>(() => {
|
|
||||||
const maxWidth = isWeb ? 450 : windim.width
|
|
||||||
if (image.height > image.width) {
|
|
||||||
return {
|
|
||||||
resizeMode: 'contain',
|
|
||||||
width: '100%',
|
|
||||||
aspectRatio: 1,
|
|
||||||
borderRadius: 8,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
width: '100%',
|
|
||||||
height: (maxWidth / image.width) * image.height,
|
|
||||||
borderRadius: 8,
|
|
||||||
}
|
|
||||||
}, [image, windim])
|
|
||||||
|
|
||||||
const onUpdate = useCallback(
|
|
||||||
(v: string) => {
|
|
||||||
v = enforceLen(v, MAX_ALT_TEXT)
|
|
||||||
setAltText(v)
|
|
||||||
image.setAltText(v)
|
|
||||||
},
|
|
||||||
[setAltText, image],
|
|
||||||
)
|
|
||||||
|
|
||||||
const onPressSave = useCallback(() => {
|
|
||||||
image.setAltText(altText)
|
|
||||||
closeModal()
|
|
||||||
}, [closeModal, image, altText])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView
|
|
||||||
testID="altTextImageModal"
|
|
||||||
style={[pal.view, styles.scrollContainer]}
|
|
||||||
keyboardShouldPersistTaps="always"
|
|
||||||
ref={scrollViewRef}
|
|
||||||
nativeID="imageAltText">
|
|
||||||
<View style={styles.scrollInner}>
|
|
||||||
<View style={[pal.viewLight, styles.imageContainer]}>
|
|
||||||
<Image
|
|
||||||
testID="selectedPhotoImage"
|
|
||||||
style={imageStyles}
|
|
||||||
source={{
|
|
||||||
uri: image.cropped?.path ?? image.path,
|
|
||||||
}}
|
|
||||||
contentFit="contain"
|
|
||||||
accessible={true}
|
|
||||||
accessibilityIgnoresInvertColors
|
|
||||||
enableLiveTextInteraction
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<TextInput
|
|
||||||
testID="altTextImageInput"
|
|
||||||
style={[styles.textArea, pal.border, pal.text]}
|
|
||||||
keyboardAppearance={theme.colorScheme}
|
|
||||||
multiline
|
|
||||||
placeholder={_(msg`Add alt text`)}
|
|
||||||
placeholderTextColor={pal.colors.textLight}
|
|
||||||
value={altText}
|
|
||||||
onChangeText={onUpdate}
|
|
||||||
accessibilityLabel={_(msg`Image alt text`)}
|
|
||||||
accessibilityHint=""
|
|
||||||
accessibilityLabelledBy="imageAltText"
|
|
||||||
// @ts-ignore This is fine, type is weird on the BottomSheetTextInput
|
|
||||||
ref={inputRef}
|
|
||||||
/>
|
|
||||||
<View style={styles.buttonControls}>
|
|
||||||
<TouchableOpacity
|
|
||||||
testID="altTextImageSaveBtn"
|
|
||||||
onPress={onPressSave}
|
|
||||||
accessibilityLabel={_(msg`Save alt text`)}
|
|
||||||
accessibilityHint=""
|
|
||||||
accessibilityRole="button">
|
|
||||||
<LinearGradient
|
|
||||||
colors={[gradients.blueLight.start, gradients.blueLight.end]}
|
|
||||||
start={{x: 0, y: 0}}
|
|
||||||
end={{x: 1, y: 1}}
|
|
||||||
style={[styles.button]}>
|
|
||||||
<Text type="button-lg" style={[s.white, s.bold]}>
|
|
||||||
<Trans>Done</Trans>
|
|
||||||
</Text>
|
|
||||||
</LinearGradient>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
scrollContainer: {
|
|
||||||
flex: 1,
|
|
||||||
height: '100%',
|
|
||||||
paddingHorizontal: isWeb ? 0 : 12,
|
|
||||||
paddingVertical: isWeb ? 0 : 24,
|
|
||||||
},
|
|
||||||
scrollInner: {
|
|
||||||
gap: 12,
|
|
||||||
paddingTop: isWeb ? 0 : 12,
|
|
||||||
},
|
|
||||||
imageContainer: {
|
|
||||||
borderRadius: 8,
|
|
||||||
},
|
|
||||||
textArea: {
|
|
||||||
borderWidth: 1,
|
|
||||||
borderRadius: 6,
|
|
||||||
paddingTop: 10,
|
|
||||||
paddingHorizontal: 12,
|
|
||||||
fontSize: 16,
|
|
||||||
height: 100,
|
|
||||||
textAlignVertical: 'top',
|
|
||||||
},
|
|
||||||
button: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
width: '100%',
|
|
||||||
borderRadius: 32,
|
|
||||||
padding: 10,
|
|
||||||
},
|
|
||||||
buttonControls: {
|
|
||||||
gap: 8,
|
|
||||||
paddingBottom: isWeb ? 0 : 50,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import {StyleSheet, TouchableOpacity, View} from 'react-native'
|
||||||
|
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||||
|
import {manipulateAsync, SaveFormat} from 'expo-image-manipulator'
|
||||||
|
import {LinearGradient} from 'expo-linear-gradient'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
import ReactCrop, {PercentCrop} from 'react-image-crop'
|
||||||
|
|
||||||
|
import {useModalControls} from '#/state/modals'
|
||||||
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
|
import {getDataUriSize} from 'lib/media/util'
|
||||||
|
import {gradients, s} from 'lib/styles'
|
||||||
|
import {Text} from 'view/com/util/text/Text'
|
||||||
|
|
||||||
|
export const snapPoints = ['0%']
|
||||||
|
|
||||||
|
export function Component({
|
||||||
|
uri,
|
||||||
|
dimensions,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
uri: string
|
||||||
|
dimensions?: {width: number; height: number}
|
||||||
|
onSelect: (img?: RNImage) => void
|
||||||
|
}) {
|
||||||
|
const pal = usePalette('default')
|
||||||
|
const {_} = useLingui()
|
||||||
|
|
||||||
|
const {closeModal} = useModalControls()
|
||||||
|
|
||||||
|
const imageRef = React.useRef<HTMLImageElement>(null)
|
||||||
|
const [crop, setCrop] = React.useState<PercentCrop>()
|
||||||
|
|
||||||
|
const isEmpty = !crop || (crop.width || crop.height) === 0
|
||||||
|
const aspect = dimensions ? dimensions.width / dimensions.height : undefined
|
||||||
|
|
||||||
|
const onPressCancel = () => {
|
||||||
|
onSelect(undefined)
|
||||||
|
closeModal()
|
||||||
|
}
|
||||||
|
const onPressDone = async () => {
|
||||||
|
if (!isEmpty) {
|
||||||
|
const img = imageRef.current!
|
||||||
|
|
||||||
|
const result = await manipulateAsync(
|
||||||
|
uri,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
crop: {
|
||||||
|
originX: (crop.x * img.width) / 100,
|
||||||
|
originY: (crop.y * img.height) / 100,
|
||||||
|
width: (crop.width * img.width) / 100,
|
||||||
|
height: (crop.height * img.height) / 100,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{
|
||||||
|
base64: true,
|
||||||
|
format: SaveFormat.JPEG,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
onSelect({
|
||||||
|
path: result.uri,
|
||||||
|
mime: 'image/jpeg',
|
||||||
|
size: result.base64 !== undefined ? getDataUriSize(result.base64) : 0,
|
||||||
|
width: result.width,
|
||||||
|
height: result.height,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
onSelect(undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
closeModal()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
<View style={[styles.cropper, pal.borderDark]}>
|
||||||
|
<ReactCrop
|
||||||
|
aspect={aspect}
|
||||||
|
crop={crop}
|
||||||
|
onChange={(_, next) => setCrop(next)}>
|
||||||
|
<img ref={imageRef} src={uri} style={{maxHeight: '75vh'}} />
|
||||||
|
</ReactCrop>
|
||||||
|
</View>
|
||||||
|
<View style={styles.btns}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onPressCancel}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={_(msg`Cancel image crop`)}
|
||||||
|
accessibilityHint={_(msg`Exits image cropping process`)}>
|
||||||
|
<Text type="xl" style={pal.link}>
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<View style={s.flex1} />
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onPressDone}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={_(msg`Save image crop`)}
|
||||||
|
accessibilityHint={_(msg`Saves image crop settings`)}>
|
||||||
|
<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>Done</Trans>
|
||||||
|
</Text>
|
||||||
|
</LinearGradient>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
cropper: {
|
||||||
|
marginLeft: 'auto',
|
||||||
|
marginRight: 'auto',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 4,
|
||||||
|
overflow: 'hidden',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
ctrls: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 10,
|
||||||
|
},
|
||||||
|
btns: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 10,
|
||||||
|
},
|
||||||
|
btn: {
|
||||||
|
borderRadius: 4,
|
||||||
|
paddingVertical: 8,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -1,402 +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 {MaterialIcons} from '@expo/vector-icons'
|
|
||||||
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 {useModalControls} from '#/state/modals'
|
|
||||||
import {MAX_ALT_TEXT} from 'lib/constants'
|
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
|
||||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
|
||||||
import {RectTallIcon, RectWideIcon, SquareIcon} from 'lib/icons'
|
|
||||||
import {enforceLen} from 'lib/strings/helpers'
|
|
||||||
import {gradients, s} from 'lib/styles'
|
|
||||||
import {useTheme} from 'lib/ThemeContext'
|
|
||||||
import {getKeys} from 'lib/type-assertions'
|
|
||||||
import {GalleryModel} from 'state/models/media/gallery'
|
|
||||||
import {ImageModel} from 'state/models/media/image'
|
|
||||||
import {Text} from '../util/text/Text'
|
|
||||||
import {TextInput} from './util'
|
|
||||||
|
|
||||||
export const snapPoints = ['80%']
|
|
||||||
|
|
||||||
const RATIOS = {
|
|
||||||
'4:3': {
|
|
||||||
Icon: RectWideIcon,
|
|
||||||
},
|
|
||||||
'1:1': {
|
|
||||||
Icon: SquareIcon,
|
|
||||||
},
|
|
||||||
'3:4': {
|
|
||||||
Icon: RectTallIcon,
|
|
||||||
},
|
|
||||||
None: {
|
|
||||||
label: 'None',
|
|
||||||
Icon: MaterialIcons,
|
|
||||||
name: 'do-not-disturb-alt',
|
|
||||||
},
|
|
||||||
} 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')
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
{
|
|
||||||
name: 'flip' as const,
|
|
||||||
label: _(msg`Flip horizontal`),
|
|
||||||
onPress: onFlipHorizontal,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'flip' as const,
|
|
||||||
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])
|
|
||||||
|
|
||||||
const getLabelIconSize = useCallback((as: AspectRatio) => {
|
|
||||||
switch (as) {
|
|
||||||
case 'None':
|
|
||||||
return 22
|
|
||||||
case '1:1':
|
|
||||||
return 32
|
|
||||||
default:
|
|
||||||
return 26
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
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>
|
|
||||||
{!isMobile ? (
|
|
||||||
<Text type="sm-bold" style={pal.text}>
|
|
||||||
<Trans>Ratios</Trans>
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
<View style={imgControlStyles}>
|
|
||||||
{getKeys(RATIOS).map(ratio => {
|
|
||||||
const {Icon, ...props} = RATIOS[ratio]
|
|
||||||
const labelIconSize = getLabelIconSize(ratio)
|
|
||||||
const isSelected = aspectRatio === ratio
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
key={ratio}
|
|
||||||
onPress={() => {
|
|
||||||
onSetRatio(ratio)
|
|
||||||
}}
|
|
||||||
accessibilityLabel={ratio}
|
|
||||||
accessibilityHint="">
|
|
||||||
<Icon
|
|
||||||
size={labelIconSize}
|
|
||||||
style={[styles.imgControl, isSelected ? s.blue3 : pal.text]}
|
|
||||||
color={(isSelected ? s.blue3 : pal.text).color}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Text
|
|
||||||
type={isSelected ? 'xs-bold' : 'xs-medium'}
|
|
||||||
style={[isSelected ? s.blue3 : pal.text, s.textCenter]}>
|
|
||||||
{ratio}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
{!isMobile ? (
|
|
||||||
<Text type="sm-bold" style={[pal.text, styles.subsection]}>
|
|
||||||
<Trans>Transformations</Trans>
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
<View style={imgControlStyles}>
|
|
||||||
{adjustments.map(({label, name, onPress}) => (
|
|
||||||
<Pressable
|
|
||||||
key={label}
|
|
||||||
onPress={onPress}
|
|
||||||
accessibilityLabel={label}
|
|
||||||
accessibilityHint=""
|
|
||||||
style={styles.flipBtn}>
|
|
||||||
<MaterialIcons
|
|
||||||
name={name}
|
|
||||||
size={label?.startsWith('Flip') ? 22 : 24}
|
|
||||||
style={[
|
|
||||||
pal.text,
|
|
||||||
label === _(msg`Flip vertically`)
|
|
||||||
? styles.flipVertical
|
|
||||||
: undefined,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
))}
|
|
||||||
</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: 'bold',
|
|
||||||
fontSize: 24,
|
|
||||||
},
|
|
||||||
btns: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
},
|
|
||||||
btn: {
|
|
||||||
borderRadius: 4,
|
|
||||||
paddingVertical: 8,
|
|
||||||
paddingHorizontal: 24,
|
|
||||||
},
|
|
||||||
imgControl: {
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
height: 40,
|
|
||||||
},
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -7,8 +7,6 @@ import {useModalControls, useModals} from '#/state/modals'
|
|||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop'
|
import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop'
|
||||||
import * as AddAppPassword from './AddAppPasswords'
|
import * as AddAppPassword from './AddAppPasswords'
|
||||||
import * as AltImageModal from './AltImage'
|
|
||||||
import * as EditImageModal from './AltImage'
|
|
||||||
import * as ChangeEmailModal from './ChangeEmail'
|
import * as ChangeEmailModal from './ChangeEmail'
|
||||||
import * as ChangeHandleModal from './ChangeHandle'
|
import * as ChangeHandleModal from './ChangeHandle'
|
||||||
import * as ChangePasswordModal from './ChangePassword'
|
import * as ChangePasswordModal from './ChangePassword'
|
||||||
@@ -82,12 +80,6 @@ export function ModalsContainer() {
|
|||||||
} else if (activeModal?.name === 'threadgate') {
|
} else if (activeModal?.name === 'threadgate') {
|
||||||
snapPoints = ThreadgateModal.snapPoints
|
snapPoints = ThreadgateModal.snapPoints
|
||||||
element = <ThreadgateModal.Component {...activeModal} />
|
element = <ThreadgateModal.Component {...activeModal} />
|
||||||
} 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') {
|
} else if (activeModal?.name === 'change-handle') {
|
||||||
snapPoints = ChangeHandleModal.snapPoints
|
snapPoints = ChangeHandleModal.snapPoints
|
||||||
element = <ChangeHandleModal.Component {...activeModal} />
|
element = <ChangeHandleModal.Component {...activeModal} />
|
||||||
|
|||||||
@@ -8,14 +8,12 @@ import {useModalControls, useModals} from '#/state/modals'
|
|||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||||
import * as AddAppPassword from './AddAppPasswords'
|
import * as AddAppPassword from './AddAppPasswords'
|
||||||
import * as AltTextImageModal from './AltImage'
|
|
||||||
import * as ChangeEmailModal from './ChangeEmail'
|
import * as ChangeEmailModal from './ChangeEmail'
|
||||||
import * as ChangeHandleModal from './ChangeHandle'
|
import * as ChangeHandleModal from './ChangeHandle'
|
||||||
import * as ChangePasswordModal from './ChangePassword'
|
import * as ChangePasswordModal from './ChangePassword'
|
||||||
import * as CreateOrEditListModal from './CreateOrEditList'
|
import * as CreateOrEditListModal from './CreateOrEditList'
|
||||||
import * as CropImageModal from './crop-image/CropImage.web'
|
import * as CropImage from './CropImage.web'
|
||||||
import * as DeleteAccountModal from './DeleteAccount'
|
import * as DeleteAccountModal from './DeleteAccount'
|
||||||
import * as EditImageModal from './EditImage'
|
|
||||||
import * as EditProfileModal from './EditProfile'
|
import * as EditProfileModal from './EditProfile'
|
||||||
import * as InviteCodesModal from './InviteCodes'
|
import * as InviteCodesModal from './InviteCodes'
|
||||||
import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
|
import * as ContentLanguagesSettingsModal from './lang-settings/ContentLanguagesSettings'
|
||||||
@@ -56,13 +54,6 @@ function Modal({modal}: {modal: ModalIface}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onPressMask = () => {
|
const onPressMask = () => {
|
||||||
if (
|
|
||||||
modal.name === 'crop-image' ||
|
|
||||||
modal.name === 'edit-image' ||
|
|
||||||
modal.name === 'alt-text-image'
|
|
||||||
) {
|
|
||||||
return // dont close on mask presses during crop
|
|
||||||
}
|
|
||||||
closeModal()
|
closeModal()
|
||||||
}
|
}
|
||||||
const onInnerPress = () => {
|
const onInnerPress = () => {
|
||||||
@@ -80,7 +71,7 @@ function Modal({modal}: {modal: ModalIface}) {
|
|||||||
} else if (modal.name === 'list-add-remove-users') {
|
} else if (modal.name === 'list-add-remove-users') {
|
||||||
element = <ListAddUserModal.Component {...modal} />
|
element = <ListAddUserModal.Component {...modal} />
|
||||||
} else if (modal.name === 'crop-image') {
|
} else if (modal.name === 'crop-image') {
|
||||||
element = <CropImageModal.Component {...modal} />
|
element = <CropImage.Component {...modal} />
|
||||||
} else if (modal.name === 'delete-account') {
|
} else if (modal.name === 'delete-account') {
|
||||||
element = <DeleteAccountModal.Component />
|
element = <DeleteAccountModal.Component />
|
||||||
} else if (modal.name === 'repost') {
|
} else if (modal.name === 'repost') {
|
||||||
@@ -99,10 +90,6 @@ function Modal({modal}: {modal: ModalIface}) {
|
|||||||
element = <ContentLanguagesSettingsModal.Component />
|
element = <ContentLanguagesSettingsModal.Component />
|
||||||
} else if (modal.name === 'post-languages-settings') {
|
} else if (modal.name === 'post-languages-settings') {
|
||||||
element = <PostLanguagesSettingsModal.Component />
|
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') {
|
} else if (modal.name === 'verify-email') {
|
||||||
element = <VerifyEmailModal.Component {...modal} />
|
element = <VerifyEmailModal.Component {...modal} />
|
||||||
} else if (modal.name === 'change-email') {
|
} else if (modal.name === 'change-email') {
|
||||||
|
|||||||
@@ -1,228 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import {StyleSheet, TouchableOpacity, View} from 'react-native'
|
|
||||||
import {Image as RNImage} from 'react-native-image-crop-picker'
|
|
||||||
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 ImageEditor from 'react-avatar-editor'
|
|
||||||
|
|
||||||
import {useModalControls} from '#/state/modals'
|
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
|
||||||
import {RectTallIcon, RectWideIcon, SquareIcon} from 'lib/icons'
|
|
||||||
import {Dimensions} from 'lib/media/types'
|
|
||||||
import {getDataUriSize} from 'lib/media/util'
|
|
||||||
import {gradients, s} from 'lib/styles'
|
|
||||||
import {Text} from 'view/com/util/text/Text'
|
|
||||||
import {calculateDimensions} from './cropImageUtil'
|
|
||||||
|
|
||||||
enum AspectRatio {
|
|
||||||
Square = 'square',
|
|
||||||
Wide = 'wide',
|
|
||||||
Tall = 'tall',
|
|
||||||
Custom = 'custom',
|
|
||||||
}
|
|
||||||
|
|
||||||
const DIMS: Record<string, Dimensions> = {
|
|
||||||
[AspectRatio.Square]: {width: 1000, height: 1000},
|
|
||||||
[AspectRatio.Wide]: {width: 1000, height: 750},
|
|
||||||
[AspectRatio.Tall]: {width: 750, height: 1000},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const snapPoints = ['0%']
|
|
||||||
|
|
||||||
export function Component({
|
|
||||||
uri,
|
|
||||||
dimensions,
|
|
||||||
onSelect,
|
|
||||||
}: {
|
|
||||||
uri: string
|
|
||||||
dimensions?: Dimensions
|
|
||||||
onSelect: (img?: RNImage) => void
|
|
||||||
}) {
|
|
||||||
const {closeModal} = useModalControls()
|
|
||||||
const pal = usePalette('default')
|
|
||||||
const {_} = useLingui()
|
|
||||||
const defaultAspectStyle = dimensions
|
|
||||||
? AspectRatio.Custom
|
|
||||||
: AspectRatio.Square
|
|
||||||
const [as, setAs] = React.useState<AspectRatio>(defaultAspectStyle)
|
|
||||||
const [scale, setScale] = React.useState<number>(1)
|
|
||||||
const editorRef = React.useRef<ImageEditor>(null)
|
|
||||||
const imageEditorWidth = dimensions ? dimensions.width : DIMS[as].width
|
|
||||||
const imageEditorHeight = dimensions ? dimensions.height : DIMS[as].height
|
|
||||||
|
|
||||||
const doSetAs = (v: AspectRatio) => () => setAs(v)
|
|
||||||
|
|
||||||
const onPressCancel = () => {
|
|
||||||
onSelect(undefined)
|
|
||||||
closeModal()
|
|
||||||
}
|
|
||||||
const onPressDone = () => {
|
|
||||||
const canvas = editorRef.current?.getImageScaledToCanvas()
|
|
||||||
if (canvas) {
|
|
||||||
const dataUri = canvas.toDataURL('image/jpeg')
|
|
||||||
onSelect({
|
|
||||||
path: dataUri,
|
|
||||||
mime: 'image/jpeg',
|
|
||||||
size: getDataUriSize(dataUri),
|
|
||||||
width: imageEditorWidth,
|
|
||||||
height: imageEditorHeight,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
onSelect(undefined)
|
|
||||||
}
|
|
||||||
closeModal()
|
|
||||||
}
|
|
||||||
|
|
||||||
let cropperStyle
|
|
||||||
if (as === AspectRatio.Square) {
|
|
||||||
cropperStyle = styles.cropperSquare
|
|
||||||
} else if (as === AspectRatio.Wide) {
|
|
||||||
cropperStyle = styles.cropperWide
|
|
||||||
} else if (as === AspectRatio.Tall) {
|
|
||||||
cropperStyle = styles.cropperTall
|
|
||||||
} else if (as === AspectRatio.Custom) {
|
|
||||||
const cropperDimensions = calculateDimensions(
|
|
||||||
550,
|
|
||||||
imageEditorHeight,
|
|
||||||
imageEditorWidth,
|
|
||||||
)
|
|
||||||
cropperStyle = {
|
|
||||||
width: cropperDimensions.width,
|
|
||||||
height: cropperDimensions.height,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View>
|
|
||||||
<View style={[styles.cropper, pal.borderDark, cropperStyle]}>
|
|
||||||
<ImageEditor
|
|
||||||
ref={editorRef}
|
|
||||||
style={styles.imageEditor}
|
|
||||||
image={uri}
|
|
||||||
width={imageEditorWidth}
|
|
||||||
height={imageEditorHeight}
|
|
||||||
scale={scale}
|
|
||||||
border={0}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View style={styles.ctrls}>
|
|
||||||
<Slider
|
|
||||||
value={scale}
|
|
||||||
onValueChange={(v: number | number[]) =>
|
|
||||||
setScale(Array.isArray(v) ? v[0] : v)
|
|
||||||
}
|
|
||||||
minimumValue={1}
|
|
||||||
maximumValue={3}
|
|
||||||
containerStyle={styles.slider}
|
|
||||||
/>
|
|
||||||
{as === AspectRatio.Custom ? null : (
|
|
||||||
<>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={doSetAs(AspectRatio.Wide)}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={_(msg`Wide`)}
|
|
||||||
accessibilityHint={_(msg`Sets image aspect ratio to wide`)}>
|
|
||||||
<RectWideIcon
|
|
||||||
size={24}
|
|
||||||
style={as === AspectRatio.Wide ? s.blue3 : pal.text}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={doSetAs(AspectRatio.Tall)}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={_(msg`Tall`)}
|
|
||||||
accessibilityHint={_(msg`Sets image aspect ratio to tall`)}>
|
|
||||||
<RectTallIcon
|
|
||||||
size={24}
|
|
||||||
style={as === AspectRatio.Tall ? s.blue3 : pal.text}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={doSetAs(AspectRatio.Square)}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={_(msg`Square`)}
|
|
||||||
accessibilityHint={_(msg`Sets image aspect ratio to square`)}>
|
|
||||||
<SquareIcon
|
|
||||||
size={24}
|
|
||||||
style={as === AspectRatio.Square ? s.blue3 : pal.text}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
<View style={styles.btns}>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={onPressCancel}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={_(msg`Cancel image crop`)}
|
|
||||||
accessibilityHint={_(msg`Exits image cropping process`)}>
|
|
||||||
<Text type="xl" style={pal.link}>
|
|
||||||
<Trans>Cancel</Trans>
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<View style={s.flex1} />
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={onPressDone}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={_(msg`Save image crop`)}
|
|
||||||
accessibilityHint={_(msg`Saves image crop settings`)}>
|
|
||||||
<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>Done</Trans>
|
|
||||||
</Text>
|
|
||||||
</LinearGradient>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
cropper: {
|
|
||||||
marginLeft: 'auto',
|
|
||||||
marginRight: 'auto',
|
|
||||||
borderWidth: 1,
|
|
||||||
borderRadius: 4,
|
|
||||||
overflow: 'hidden',
|
|
||||||
},
|
|
||||||
cropperSquare: {
|
|
||||||
width: 400,
|
|
||||||
height: 400,
|
|
||||||
},
|
|
||||||
cropperWide: {
|
|
||||||
width: 400,
|
|
||||||
height: 300,
|
|
||||||
},
|
|
||||||
cropperTall: {
|
|
||||||
width: 300,
|
|
||||||
height: 400,
|
|
||||||
},
|
|
||||||
imageEditor: {
|
|
||||||
maxWidth: '100%',
|
|
||||||
},
|
|
||||||
ctrls: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginTop: 10,
|
|
||||||
},
|
|
||||||
slider: {
|
|
||||||
flex: 1,
|
|
||||||
marginRight: 10,
|
|
||||||
},
|
|
||||||
btns: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginTop: 10,
|
|
||||||
},
|
|
||||||
btn: {
|
|
||||||
borderRadius: 4,
|
|
||||||
paddingVertical: 8,
|
|
||||||
paddingHorizontal: 24,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
export const calculateDimensions = (
|
|
||||||
maxWidth: number,
|
|
||||||
originalHeight: number,
|
|
||||||
originalWidth: number,
|
|
||||||
) => {
|
|
||||||
const aspectRatio = originalWidth / originalHeight
|
|
||||||
const newHeight = maxWidth / aspectRatio
|
|
||||||
const newWidth = maxWidth
|
|
||||||
return {
|
|
||||||
width: newWidth,
|
|
||||||
height: newHeight,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +1,12 @@
|
|||||||
import React, {useEffect} from 'react'
|
import React, {useEffect} from 'react'
|
||||||
import {observer} from 'mobx-react-lite'
|
|
||||||
import {Animated, Easing, Platform, StyleSheet, View} from 'react-native'
|
import {Animated, Easing, Platform, StyleSheet, View} from 'react-native'
|
||||||
import {ComposePost} from '../com/composer/Composer'
|
|
||||||
import {useComposerState} from 'state/shell/composer'
|
|
||||||
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
|
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
|
import {useComposerState} from 'state/shell/composer'
|
||||||
|
import {ComposePost} from '../com/composer/Composer'
|
||||||
|
|
||||||
export const Composer = observer(function ComposerImpl({
|
export const Composer = ({winHeight}: {winHeight: number}) => {
|
||||||
winHeight,
|
|
||||||
}: {
|
|
||||||
winHeight: number
|
|
||||||
}) {
|
|
||||||
const state = useComposerState()
|
const state = useComposerState()
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const initInterp = useAnimatedValue(0)
|
const initInterp = useAnimatedValue(0)
|
||||||
@@ -60,7 +56,7 @@ export const Composer = observer(function ComposerImpl({
|
|||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
wrapper: {
|
wrapper: {
|
||||||
|
|||||||
@@ -2210,7 +2210,7 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@babel/helper-plugin-utils" "^7.22.5"
|
"@babel/helper-plugin-utils" "^7.22.5"
|
||||||
|
|
||||||
"@babel/plugin-transform-runtime@^7.0.0", "@babel/plugin-transform-runtime@^7.12.1", "@babel/plugin-transform-runtime@^7.16.4":
|
"@babel/plugin-transform-runtime@^7.0.0", "@babel/plugin-transform-runtime@^7.16.4":
|
||||||
version "7.22.10"
|
version "7.22.10"
|
||||||
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.22.10.tgz#89eda6daf1d3af6f36fb368766553054c8d7cd46"
|
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.22.10.tgz#89eda6daf1d3af6f36fb368766553054c8d7cd46"
|
||||||
integrity sha512-RchI7HePu1eu0CYNKHHHQdfenZcM4nz8rew5B1VWqeRKdcwW5aQ5HeG9eTUbWiAS1UrmHVLmoxTWHt3iLD/NhA==
|
integrity sha512-RchI7HePu1eu0CYNKHHHQdfenZcM4nz8rew5B1VWqeRKdcwW5aQ5HeG9eTUbWiAS1UrmHVLmoxTWHt3iLD/NhA==
|
||||||
@@ -7811,13 +7811,6 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc"
|
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc"
|
||||||
integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==
|
integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==
|
||||||
|
|
||||||
"@types/react-avatar-editor@^13.0.0":
|
|
||||||
version "13.0.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/@types/react-avatar-editor/-/react-avatar-editor-13.0.0.tgz#5963e16c931746c47e478d669dd72d388b427393"
|
|
||||||
integrity sha512-5ymOayy6mfT35xTqzni7UjXvCNEg8/pH4pI5RenITp9PBc02KGTYjSV1WboXiQDYSh5KomLT0ngBLEAIhV1QoQ==
|
|
||||||
dependencies:
|
|
||||||
"@types/react" "*"
|
|
||||||
|
|
||||||
"@types/react-dom@^18.2.18":
|
"@types/react-dom@^18.2.18":
|
||||||
version "18.2.18"
|
version "18.2.18"
|
||||||
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.18.tgz#16946e6cd43971256d874bc3d0a72074bb8571dd"
|
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.18.tgz#16946e6cd43971256d874bc3d0a72074bb8571dd"
|
||||||
@@ -16376,21 +16369,6 @@ mkdirp@^1.0.3, mkdirp@^1.0.4:
|
|||||||
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
|
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
|
||||||
integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
|
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==
|
|
||||||
|
|
||||||
moment@^2.19.3:
|
moment@^2.19.3:
|
||||||
version "2.29.4"
|
version "2.29.4"
|
||||||
resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.4.tgz#3dbe052889fe7c1b2ed966fcb3a77328964ef108"
|
resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.4.tgz#3dbe052889fe7c1b2ed966fcb3a77328964ef108"
|
||||||
@@ -18599,15 +18577,6 @@ react-app-polyfill@^3.0.0:
|
|||||||
regenerator-runtime "^0.13.9"
|
regenerator-runtime "^0.13.9"
|
||||||
whatwg-fetch "^3.6.2"
|
whatwg-fetch "^3.6.2"
|
||||||
|
|
||||||
react-avatar-editor@^13.0.0:
|
|
||||||
version "13.0.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/react-avatar-editor/-/react-avatar-editor-13.0.0.tgz#55013625ee9ae715c1fe2dc553b8079994d8a5f2"
|
|
||||||
integrity sha512-0xw63MbRRQdDy7YI1IXU9+7tTFxYEFLV8CABvryYOGjZmXRTH2/UA0mafe57ns62uaEFX181kA4XlGlxCaeXKA==
|
|
||||||
dependencies:
|
|
||||||
"@babel/plugin-transform-runtime" "^7.12.1"
|
|
||||||
"@babel/runtime" "^7.12.5"
|
|
||||||
prop-types "^15.7.2"
|
|
||||||
|
|
||||||
react-dev-utils@^12.0.1:
|
react-dev-utils@^12.0.1:
|
||||||
version "12.0.1"
|
version "12.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/react-dev-utils/-/react-dev-utils-12.0.1.tgz#ba92edb4a1f379bd46ccd6bcd4e7bc398df33e73"
|
resolved "https://registry.yarnpkg.com/react-dev-utils/-/react-dev-utils-12.0.1.tgz#ba92edb4a1f379bd46ccd6bcd4e7bc398df33e73"
|
||||||
@@ -18664,6 +18633,11 @@ react-freeze@^1.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/react-freeze/-/react-freeze-1.0.3.tgz#5e3ca90e682fed1d73a7cb50c2c7402b3e85618d"
|
resolved "https://registry.yarnpkg.com/react-freeze/-/react-freeze-1.0.3.tgz#5e3ca90e682fed1d73a7cb50c2c7402b3e85618d"
|
||||||
integrity sha512-ZnXwLQnGzrDpHBHiC56TXFXvmolPeMjTn1UOm610M4EXGzbEDR7oOIyS2ZiItgbs6eZc4oU/a0hpk8PrcKvv5g==
|
integrity sha512-ZnXwLQnGzrDpHBHiC56TXFXvmolPeMjTn1UOm610M4EXGzbEDR7oOIyS2ZiItgbs6eZc4oU/a0hpk8PrcKvv5g==
|
||||||
|
|
||||||
|
react-image-crop@^11.0.5:
|
||||||
|
version "11.0.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-image-crop/-/react-image-crop-11.0.5.tgz#c7abcf9cae28305d253d55d481158a594a937867"
|
||||||
|
integrity sha512-A/Y/kspOzki1zDL/bSgwWIY1X3CQ9F1QwpdnncWLBVAktnKfAZDIQnWmjXzuzEjZHDMsBlArytIcPBVi6DNklg==
|
||||||
|
|
||||||
"react-is@^16.12.0 || ^17.0.0 || ^18.0.0", react-is@^18.0.0, react-is@^18.2.0:
|
"react-is@^16.12.0 || ^17.0.0 || ^18.0.0", react-is@^18.0.0, react-is@^18.2.0:
|
||||||
version "18.2.0"
|
version "18.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b"
|
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b"
|
||||||
|
|||||||
Reference in New Issue
Block a user