Split image cropping into secondary step (#473)

* Split image cropping into secondary step

* Use ImageModel and GalleryModel

* Add fix for pasting image URLs

* Move models to state folder

* Fix things that broke after rebase

* Latest -- has image display bug

* Remove contentFit

* Fix iOS display in gallery

* Tuneup the api signatures and implement compress/resize on web

* Fix await

* Lint fix and remove unused function

* Fix android image pathing

* Fix external embed x button on android

* Remove min-height from composer (no longer useful and was mispositioning the composer on android)

* Fix e2e picker

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>
zio/stable
Ollie Hsieh 2023-04-17 15:41:44 -07:00 committed by GitHub
parent 91fadadb58
commit 2509290fdd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 875 additions and 833 deletions

3
.gitignore vendored
View File

@ -82,6 +82,9 @@ web-build/
# Temporary files created by Metro to check the health of the file watcher
.metro-health-check*
# VSCode
.vscode
# gitignore and github actions
!.gitignore
!.github

View File

@ -10,8 +10,8 @@ import {
import {AtUri} from '@atproto/api'
import {RootStoreModel} from 'state/models/root-store'
import {isNetworkError} from 'lib/strings/errors'
import {Image} from 'lib/media/types'
import {LinkMeta} from '../link-meta/link-meta'
import {Image} from '../media/manip'
import {isWeb} from 'platform/detection'
export interface ExternalEmbedDraft {

View File

@ -161,6 +161,8 @@ export function SUGGESTED_FOLLOWS(serviceUrl: string) {
}
}
export const POST_IMG_MAX_WIDTH = 2000
export const POST_IMG_MAX_HEIGHT = 2000
export const POST_IMG_MAX_SIZE = 1000000
export const POST_IMG_MAX = {
width: 2000,
height: 2000,
size: 1000000,
}

View File

@ -1,13 +1,77 @@
import RNFetchBlob from 'rn-fetch-blob'
import ImageResizer from '@bam.tech/react-native-image-resizer'
import {Image as RNImage, Share} from 'react-native'
import {Image} from 'react-native-image-crop-picker'
import RNFS from 'react-native-fs'
import uuid from 'react-native-uuid'
import * as Toast from 'view/com/util/Toast'
import {Dimensions} from './types'
import {POST_IMG_MAX} from 'lib/constants'
import {isAndroid} from 'platform/detection'
export interface Dim {
width: number
height: number
export async function compressAndResizeImageForPost(
image: Image,
): Promise<Image> {
const uri = `file://${image.path}`
let resized: Omit<Image, 'mime'>
for (let i = 0; i < 9; i++) {
const quality = 100 - i * 10
try {
resized = await ImageResizer.createResizedImage(
uri,
POST_IMG_MAX.width,
POST_IMG_MAX.height,
'JPEG',
quality,
undefined,
undefined,
undefined,
{mode: 'cover'},
)
} catch (err) {
throw new Error(`Failed to resize: ${err}`)
}
if (resized.size < POST_IMG_MAX.size) {
const path = await moveToPermanentPath(resized.path)
return {
path,
mime: 'image/jpeg',
size: resized.size,
height: resized.height,
width: resized.width,
}
}
}
throw new Error(
`This image is too big! We couldn't compress it down to ${POST_IMG_MAX.size} bytes`,
)
}
export async function compressIfNeeded(
img: Image,
maxSize: number = 1000000,
): Promise<Image> {
const origUri = `file://${img.path}`
if (img.size < maxSize) {
return img
}
const resizedImage = await doResize(origUri, {
width: img.width,
height: img.height,
mode: 'stretch',
maxSize,
})
const finalImageMovedPath = await moveToPermanentPath(resizedImage.path)
const finalImg = {
...resizedImage,
path: finalImageMovedPath,
}
return finalImg
}
export interface DownloadAndResizeOpts {
@ -19,14 +83,6 @@ export interface DownloadAndResizeOpts {
timeout: number
}
export interface Image {
path: string
mime: string
size: number
width: number
height: number
}
export async function downloadAndResize(opts: DownloadAndResizeOpts) {
let appendExt = 'jpeg'
try {
@ -55,7 +111,7 @@ export async function downloadAndResize(opts: DownloadAndResizeOpts) {
localUri = `file://${localUri}`
}
return await resize(localUri, opts)
return await doResize(localUri, opts)
} finally {
if (downloadRes) {
downloadRes.flush()
@ -63,79 +119,6 @@ export async function downloadAndResize(opts: DownloadAndResizeOpts) {
}
}
export interface ResizeOpts {
width: number
height: number
mode: 'contain' | 'cover' | 'stretch'
maxSize: number
}
export async function resize(
localUri: string,
opts: ResizeOpts,
): Promise<Image> {
for (let i = 0; i < 9; i++) {
const quality = 100 - i * 10
const resizeRes = await ImageResizer.createResizedImage(
localUri,
opts.width,
opts.height,
'JPEG',
quality,
undefined,
undefined,
undefined,
{mode: opts.mode},
)
if (resizeRes.size < opts.maxSize) {
return {
path: resizeRes.path,
mime: 'image/jpeg',
size: resizeRes.size,
width: resizeRes.width,
height: resizeRes.height,
}
}
}
throw new Error(
`This image is too big! We couldn't compress it down to ${opts.maxSize} bytes`,
)
}
export async function compressIfNeeded(
img: Image,
maxSize: number,
): Promise<Image> {
const origUri = `file://${img.path}`
if (img.size < maxSize) {
return img
}
const resizedImage = await resize(origUri, {
width: img.width,
height: img.height,
mode: 'stretch',
maxSize,
})
const finalImageMovedPath = await moveToPremanantPath(resizedImage.path)
const finalImg = {
...resizedImage,
path: finalImageMovedPath,
}
return finalImg
}
export function scaleDownDimensions(dim: Dim, max: Dim): Dim {
if (dim.width < max.width && dim.height < max.height) {
return dim
}
let wScale = dim.width > max.width ? max.width / dim.width : 1
let hScale = dim.height > max.height ? max.height / dim.height : 1
if (wScale < hScale) {
return {width: dim.width * wScale, height: dim.height * wScale}
}
return {width: dim.width * hScale, height: dim.height * hScale}
}
export async function saveImageModal({uri}: {uri: string}) {
const downloadResponse = await RNFetchBlob.config({
fileCache: true,
@ -154,19 +137,7 @@ export async function saveImageModal({uri}: {uri: string}) {
RNFS.unlink(imagePath)
}
export async function moveToPremanantPath(path: string) {
/*
Since this package stores images in a temp directory, we need to move the file to a permanent location.
Relevant: IOS bug when trying to open a second time:
https://github.com/ivpusic/react-native-image-crop-picker/issues/1199
*/
const filename = uuid.v4()
const destinationPath = `${RNFS.TemporaryDirectoryPath}/${filename}`
RNFS.moveFile(path, destinationPath)
return destinationPath
}
export function getImageDim(path: string): Promise<Dim> {
export function getImageDim(path: string): Promise<Dimensions> {
return new Promise((resolve, reject) => {
RNImage.getSize(
path,
@ -177,3 +148,64 @@ export function getImageDim(path: string): Promise<Dim> {
)
})
}
// internal methods
// =
interface DoResizeOpts {
width: number
height: number
mode: 'contain' | 'cover' | 'stretch'
maxSize: number
}
async function doResize(localUri: string, opts: DoResizeOpts): Promise<Image> {
for (let i = 0; i < 9; i++) {
const quality = 100 - i * 10
const resizeRes = await ImageResizer.createResizedImage(
localUri,
opts.width,
opts.height,
'JPEG',
quality,
undefined,
undefined,
undefined,
{mode: opts.mode},
)
if (resizeRes.size < opts.maxSize) {
return {
path: normalizePath(resizeRes.path),
mime: 'image/jpeg',
size: resizeRes.size,
width: resizeRes.width,
height: resizeRes.height,
}
}
}
throw new Error(
`This image is too big! We couldn't compress it down to ${opts.maxSize} bytes`,
)
}
async function moveToPermanentPath(path: string): Promise<string> {
/*
Since this package stores images in a temp directory, we need to move the file to a permanent location.
Relevant: IOS bug when trying to open a second time:
https://github.com/ivpusic/react-native-image-crop-picker/issues/1199
*/
const filename = uuid.v4()
const destinationPath = `${RNFS.TemporaryDirectoryPath}/${filename}`
await RNFS.moveFile(path, destinationPath)
return normalizePath(destinationPath)
}
function normalizePath(str: string): string {
if (isAndroid) {
if (!str.startsWith('file://')) {
return `file://${str}`
}
}
return str
}

View File

@ -1,6 +1,40 @@
// import {Share} from 'react-native'
// import * as Toast from 'view/com/util/Toast'
import {extractDataUriMime, getDataUriSize} from './util'
import {Dimensions} from './types'
import {Image as RNImage} from 'react-native-image-crop-picker'
import {getDataUriSize, blobToDataUri} from './util'
import {POST_IMG_MAX} from 'lib/constants'
export async function compressAndResizeImageForPost({
path,
width,
height,
}: {
path: string
width: number
height: number
}): Promise<RNImage> {
// Compression is handled in `doResize` via `quality`
return await doResize(path, {
width,
height,
maxSize: POST_IMG_MAX.size,
mode: 'stretch',
})
}
export async function compressIfNeeded(
img: RNImage,
maxSize: number,
): Promise<RNImage> {
if (img.size < maxSize) {
return img
}
return await doResize(img.path, {
width: img.width,
height: img.height,
mode: 'stretch',
maxSize,
})
}
export interface DownloadAndResizeOpts {
uri: string
@ -11,14 +45,6 @@ export interface DownloadAndResizeOpts {
timeout: number
}
export interface Image {
path: string
mime: string
size: number
width: number
height: number
}
export async function downloadAndResize(opts: DownloadAndResizeOpts) {
const controller = new AbortController()
const to = setTimeout(() => controller.abort(), opts.timeout || 5e3)
@ -27,58 +53,7 @@ export async function downloadAndResize(opts: DownloadAndResizeOpts) {
clearTimeout(to)
const dataUri = await blobToDataUri(resBody)
return await resize(dataUri, opts)
}
export interface ResizeOpts {
width: number
height: number
mode: 'contain' | 'cover' | 'stretch'
maxSize: number
}
export async function resize(
dataUri: string,
_opts: ResizeOpts,
): Promise<Image> {
const dim = await getImageDim(dataUri)
// TODO -- need to resize
return {
path: dataUri,
mime: extractDataUriMime(dataUri),
size: getDataUriSize(dataUri),
width: dim.width,
height: dim.height,
}
}
export async function compressIfNeeded(
img: Image,
maxSize: number,
): Promise<Image> {
if (img.size > maxSize) {
// TODO
throw new Error(
"This image is too large and we haven't implemented compression yet -- sorry!",
)
}
return img
}
export interface Dim {
width: number
height: number
}
export function scaleDownDimensions(dim: Dim, max: Dim): Dim {
if (dim.width < max.width && dim.height < max.height) {
return dim
}
let wScale = dim.width > max.width ? max.width / dim.width : 1
let hScale = dim.height > max.height ? max.height / dim.height : 1
if (wScale < hScale) {
return {width: dim.width * wScale, height: dim.height * wScale}
}
return {width: dim.width * hScale, height: dim.height * hScale}
return await doResize(dataUri, opts)
}
export async function saveImageModal(_opts: {uri: string}) {
@ -86,11 +61,7 @@ export async function saveImageModal(_opts: {uri: string}) {
throw new Error('TODO')
}
export async function moveToPremanantPath(path: string) {
return path
}
export async function getImageDim(path: string): Promise<Dim> {
export async function getImageDim(path: string): Promise<Dimensions> {
var img = document.createElement('img')
const promise = new Promise((resolve, reject) => {
img.onload = resolve
@ -101,17 +72,82 @@ export async function getImageDim(path: string): Promise<Dim> {
return {width: img.width, height: img.height}
}
function blobToDataUri(blob: Blob): Promise<string> {
// internal methods
// =
interface DoResizeOpts {
width: number
height: number
mode: 'contain' | 'cover' | 'stretch'
maxSize: number
}
async function doResize(dataUri: string, opts: DoResizeOpts): Promise<RNImage> {
let newDataUri
for (let i = 0; i <= 10; i++) {
newDataUri = await createResizedImage(dataUri, {
width: opts.width,
height: opts.height,
quality: 1 - i * 0.1,
mode: opts.mode,
})
if (getDataUriSize(newDataUri) < opts.maxSize) {
break
}
}
if (!newDataUri) {
throw new Error('Failed to compress image')
}
return {
path: newDataUri,
mime: 'image/jpeg',
size: getDataUriSize(newDataUri),
width: opts.width,
height: opts.height,
}
}
function createResizedImage(
dataUri: string,
{
width,
height,
quality,
mode,
}: {
width: number
height: number
quality: number
mode: 'contain' | 'cover' | 'stretch'
},
): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onloadend = () => {
if (typeof reader.result === 'string') {
resolve(reader.result)
} else {
reject(new Error('Failed to read blob'))
const img = document.createElement('img')
img.addEventListener('load', () => {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) {
return reject(new Error('Failed to resize image'))
}
canvas.width = width
canvas.height = height
let scale = 1
if (mode === 'cover') {
scale = img.width < img.height ? width / img.width : height / img.height
} else if (mode === 'contain') {
scale = img.width > img.height ? width / img.width : height / img.height
}
reader.onerror = reject
reader.readAsDataURL(blob)
let w = img.width * scale
let h = img.height * scale
let x = (width - w) / 2
let y = (height - h) / 2
ctx.drawImage(img, x, y, w, h)
resolve(canvas.toDataURL('image/jpeg', quality))
})
img.src = dataUri
})
}

View File

@ -1,13 +1,8 @@
import {RootStoreModel} from 'state/index'
import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types'
import {
scaleDownDimensions,
Dim,
compressIfNeeded,
moveToPremanantPath,
} from 'lib/media/manip'
export type {PickedMedia} from './types'
import {Image as RNImage} from 'react-native-image-crop-picker'
import RNFS from 'react-native-fs'
import {CropperOptions} from './types'
import {compressAndResizeImageForPost} from './manip'
let _imageCounter = 0
async function getFile() {
@ -17,100 +12,33 @@ async function getFile() {
.concat(['Media', 'DCIM', '100APPLE'])
.join('/'),
)
return files[_imageCounter++ % files.length]
}
export async function openPicker(
_store: RootStoreModel,
opts: PickerOpts,
): Promise<PickedMedia[]> {
const mediaType = opts.mediaType || 'photo'
const items = await getFile()
const toMedia = (item: RNFS.ReadDirItem) => ({
mediaType,
path: item.path,
const file = files[_imageCounter++ % files.length]
return await compressAndResizeImageForPost({
path: file.path,
mime: 'image/jpeg',
size: item.size,
size: file.size,
width: 4288,
height: 2848,
})
if (Array.isArray(items)) {
return items.map(toMedia)
}
return [toMedia(items)]
}
export async function openCamera(
_store: RootStoreModel,
opts: CameraOpts,
): Promise<PickedMedia> {
const mediaType = opts.mediaType || 'photo'
const item = await getFile()
return {
mediaType,
path: item.path,
mime: 'image/jpeg',
size: item.size,
width: 4288,
height: 2848,
}
export async function openPicker(_store: RootStoreModel): Promise<RNImage[]> {
return [await getFile()]
}
export async function openCamera(_store: RootStoreModel): Promise<RNImage> {
return await getFile()
}
export async function openCropper(
_store: RootStoreModel,
opts: CropperOpts,
): Promise<PickedMedia> {
const mediaType = opts.mediaType || 'photo'
const item = await getFile()
opts: CropperOptions,
): Promise<RNImage> {
return {
mediaType,
path: item.path,
path: opts.path,
mime: 'image/jpeg',
size: item.size,
size: 123,
width: 4288,
height: 2848,
}
}
export async function pickImagesFlow(
store: RootStoreModel,
maxFiles: number,
maxDim: Dim,
maxSize: number,
) {
const items = await openPicker(store, {
multiple: true,
maxFiles,
mediaType: 'photo',
})
const result = []
for (const image of items) {
result.push(
await cropAndCompressFlow(store, image.path, image, maxDim, maxSize),
)
}
return result
}
export async function cropAndCompressFlow(
store: RootStoreModel,
path: string,
imgDim: Dim,
maxDim: Dim,
maxSize: number,
) {
// choose target dimensions based on the original
// this causes the photo cropper to start with the full image "selected"
const {width, height} = scaleDownDimensions(imgDim, maxDim)
const cropperRes = await openCropper(store, {
mediaType: 'photo',
path,
freeStyleCropEnabled: true,
width,
height,
})
const img = await compressIfNeeded(cropperRes, maxSize)
const permanentPath = await moveToPremanantPath(img.path)
return permanentPath
}

View File

@ -5,14 +5,8 @@ import {
ImageOrVideo,
} from 'react-native-image-crop-picker'
import {RootStoreModel} from 'state/index'
import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types'
import {
scaleDownDimensions,
Dim,
compressIfNeeded,
moveToPremanantPath,
} from 'lib/media/manip'
export type {PickedMedia} from './types'
import {PickerOpts, CameraOpts, CropperOptions} from './types'
import {Image as RNImage} from 'react-native-image-crop-picker'
/**
* NOTE
@ -25,18 +19,17 @@ export type {PickedMedia} from './types'
export async function openPicker(
_store: RootStoreModel,
opts: PickerOpts,
): Promise<PickedMedia[]> {
const mediaType = opts.mediaType || 'photo'
opts?: PickerOpts,
): Promise<RNImage[]> {
const items = await openPickerFn({
mediaType,
multiple: opts.multiple,
maxFiles: opts.maxFiles,
mediaType: 'photo', // TODO: eventually add other media types
multiple: opts?.multiple,
maxFiles: opts?.maxFiles,
forceJpg: true, // ios only
compressImageQuality: 0.8,
})
const toMedia = (item: ImageOrVideo) => ({
mediaType,
path: item.path,
mime: item.mime,
size: item.size,
@ -52,20 +45,17 @@ export async function openPicker(
export async function openCamera(
_store: RootStoreModel,
opts: CameraOpts,
): Promise<PickedMedia> {
const mediaType = opts.mediaType || 'photo'
): Promise<RNImage> {
const item = await openCameraFn({
mediaType,
width: opts.width,
height: opts.height,
freeStyleCropEnabled: opts.freeStyleCropEnabled,
cropperCircleOverlay: opts.cropperCircleOverlay,
cropping: true,
cropping: false,
forceJpg: true, // ios only
compressImageQuality: 0.8,
})
return {
mediaType,
path: item.path,
mime: item.mime,
size: item.size,
@ -76,21 +66,15 @@ export async function openCamera(
export async function openCropper(
_store: RootStoreModel,
opts: CropperOpts,
): Promise<PickedMedia> {
const mediaType = opts.mediaType || 'photo'
opts: CropperOptions,
): Promise<RNImage> {
const item = await openCropperFn({
path: opts.path,
mediaType: opts.mediaType || 'photo',
width: opts.width,
height: opts.height,
freeStyleCropEnabled: opts.freeStyleCropEnabled,
cropperCircleOverlay: opts.cropperCircleOverlay,
...opts,
forceJpg: true, // ios only
compressImageQuality: 0.8,
})
return {
mediaType,
path: item.path,
mime: item.mime,
size: item.size,
@ -98,46 +82,3 @@ export async function openCropper(
height: item.height,
}
}
export async function pickImagesFlow(
store: RootStoreModel,
maxFiles: number,
maxDim: Dim,
maxSize: number,
) {
const items = await openPicker(store, {
multiple: true,
maxFiles,
mediaType: 'photo',
})
const result = []
for (const image of items) {
result.push(
await cropAndCompressFlow(store, image.path, image, maxDim, maxSize),
)
}
return result
}
export async function cropAndCompressFlow(
store: RootStoreModel,
path: string,
imgDim: Dim,
maxDim: Dim,
maxSize: number,
) {
// choose target dimensions based on the original
// this causes the photo cropper to start with the full image "selected"
const {width, height} = scaleDownDimensions(imgDim, maxDim)
const cropperRes = await openCropper(store, {
mediaType: 'photo',
path,
freeStyleCropEnabled: true,
width,
height,
})
const img = await compressIfNeeded(cropperRes, maxSize)
const permanentPath = await moveToPremanantPath(img.path)
return permanentPath
}

View File

@ -1,16 +1,10 @@
/// <reference lib="dom" />
import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types'
export type {PickedMedia} from './types'
import {PickerOpts, CameraOpts, CropperOptions} from './types'
import {RootStoreModel} from 'state/index'
import {
scaleDownDimensions,
getImageDim,
Dim,
compressIfNeeded,
moveToPremanantPath,
} from 'lib/media/manip'
import {getImageDim} from 'lib/media/manip'
import {extractDataUriMime} from './util'
import {Image as RNImage} from 'react-native-image-crop-picker'
interface PickedFile {
uri: string
@ -21,13 +15,12 @@ interface PickedFile {
export async function openPicker(
_store: RootStoreModel,
opts: PickerOpts,
): Promise<PickedMedia[]> {
): Promise<RNImage[]> {
const res = await selectFile(opts)
const dim = await getImageDim(res.uri)
const mime = extractDataUriMime(res.uri)
return [
{
mediaType: 'photo',
path: res.uri,
mime,
size: res.size,
@ -40,21 +33,21 @@ export async function openPicker(
export async function openCamera(
_store: RootStoreModel,
_opts: CameraOpts,
): Promise<PickedMedia> {
): Promise<RNImage> {
// const mediaType = opts.mediaType || 'photo' TODO
throw new Error('TODO')
}
export async function openCropper(
store: RootStoreModel,
opts: CropperOpts,
): Promise<PickedMedia> {
opts: CropperOptions,
): Promise<RNImage> {
// TODO handle more opts
return new Promise((resolve, reject) => {
store.shell.openModal({
name: 'crop-image',
uri: opts.path,
onSelect: (img?: PickedMedia) => {
onSelect: (img?: RNImage) => {
if (img) {
resolve(img)
} else {
@ -65,52 +58,6 @@ export async function openCropper(
})
}
export async function pickImagesFlow(
store: RootStoreModel,
maxFiles: number,
maxDim: Dim,
maxSize: number,
) {
const items = await openPicker(store, {
multiple: true,
maxFiles,
mediaType: 'photo',
})
const result = []
for (const image of items) {
result.push(
await cropAndCompressFlow(store, image.path, image, maxDim, maxSize),
)
}
return result
}
export async function cropAndCompressFlow(
store: RootStoreModel,
path: string,
imgDim: Dim,
maxDim: Dim,
maxSize: number,
) {
// choose target dimensions based on the original
// this causes the photo cropper to start with the full image "selected"
const {width, height} = scaleDownDimensions(imgDim, maxDim)
const cropperRes = await openCropper(store, {
mediaType: 'photo',
path,
freeStyleCropEnabled: true,
width,
height,
})
const img = await compressIfNeeded(cropperRes, maxSize)
const permanentPath = await moveToPremanantPath(img.path)
return permanentPath
}
// helpers
// =
/**
* Opens the select file dialog in the browser.
* NOTE:

View File

@ -1,31 +1,21 @@
import {openCropper} from 'react-native-image-crop-picker'
export interface Dimensions {
width: number
height: number
}
export interface PickerOpts {
mediaType?: 'photo'
mediaType?: string
multiple?: boolean
maxFiles?: number
}
export interface CameraOpts {
mediaType?: 'photo'
width: number
height: number
freeStyleCropEnabled?: boolean
cropperCircleOverlay?: boolean
}
export interface CropperOpts {
path: string
mediaType?: 'photo'
width: number
height: number
freeStyleCropEnabled?: boolean
cropperCircleOverlay?: boolean
}
export interface PickedMedia {
mediaType: 'photo'
path: string
mime: string
size: number
width: number
height: number
}
export type CropperOptions = Parameters<typeof openCropper>[0]

View File

@ -1,7 +1,45 @@
import {Dimensions} from './types'
export function extractDataUriMime(uri: string): string {
return uri.substring(uri.indexOf(':') + 1, uri.indexOf(';'))
}
// Fairly accurate estimate that is more performant
// than decoding and checking length of URI
export function getDataUriSize(uri: string): number {
return Math.round((uri.length * 3) / 4) // very rough estimate
return Math.round((uri.length * 3) / 4)
}
export function scaleDownDimensions(
dim: Dimensions,
max: Dimensions,
): Dimensions {
if (dim.width < max.width && dim.height < max.height) {
return dim
}
const wScale = dim.width > max.width ? max.width / dim.width : 1
const hScale = dim.height > max.height ? max.height / dim.height : 1
if (wScale < hScale) {
return {width: dim.width * wScale, height: dim.height * wScale}
}
return {width: dim.width * hScale, height: dim.height * hScale}
}
export function isUriImage(uri: string) {
return /\.(jpg|jpeg|png).*$/.test(uri)
}
export function blobToDataUri(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onloadend = () => {
if (typeof reader.result === 'string') {
resolve(reader.result)
} else {
reject(new Error('Failed to read blob'))
}
}
reader.onerror = reject
reader.readAsDataURL(blob)
})
}

View File

@ -1,24 +1,24 @@
import {Image} from 'react-native'
import {Dim} from 'lib/media/manip'
import type {Dimensions} from 'lib/media/types'
export class ImageSizesCache {
sizes: Map<string, Dim> = new Map()
activeRequests: Map<string, Promise<Dim>> = new Map()
sizes: Map<string, Dimensions> = new Map()
activeRequests: Map<string, Promise<Dimensions>> = new Map()
constructor() {}
get(uri: string): Dim | undefined {
get(uri: string): Dimensions | undefined {
return this.sizes.get(uri)
}
async fetch(uri: string): Promise<Dim> {
const dim = this.sizes.get(uri)
if (dim) {
return dim
async fetch(uri: string): Promise<Dimensions> {
const Dimensions = this.sizes.get(uri)
if (Dimensions) {
return Dimensions
}
const prom =
this.activeRequests.get(uri) ||
new Promise<Dim>(resolve => {
new Promise<Dimensions>(resolve => {
Image.getSize(
uri,
(width: number, height: number) => resolve({width, height}),

View File

@ -1,5 +1,4 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {PickedMedia} from 'lib/media/picker'
import {
ComAtprotoLabelDefs,
AppBskyActorGetProfile as GetProfile,
@ -10,6 +9,7 @@ import {RootStoreModel} from '../root-store'
import * as apilib from 'lib/api/index'
import {cleanError} from 'lib/strings/errors'
import {FollowState} from '../cache/my-follows'
import {Image as RNImage} from 'react-native-image-crop-picker'
export const ACTOR_TYPE_USER = 'app.bsky.system.actorUser'
@ -122,8 +122,8 @@ export class ProfileModel {
async updateProfile(
updates: AppBskyActorProfile.Record,
newUserAvatar: PickedMedia | undefined | null,
newUserBanner: PickedMedia | undefined | null,
newUserAvatar: RNImage | undefined | null,
newUserBanner: RNImage | undefined | null,
) {
await this.rootStore.agent.upsertProfile(async existing => {
existing = existing || {}

View File

@ -0,0 +1,85 @@
import {makeAutoObservable, runInAction} from 'mobx'
import {RootStoreModel} from 'state/index'
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'
import {getDataUriSize} from 'lib/media/util'
export class GalleryModel {
images: ImageModel[] = []
constructor(public rootStore: RootStoreModel) {
makeAutoObservable(this, {
rootStore: false,
})
}
get isEmpty() {
return this.size === 0
}
get size() {
return this.images.length
}
get paths() {
return this.images.map(image =>
image.compressed === undefined ? image.path : image.compressed.path,
)
}
async add(image_: RNImage) {
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(this.rootStore, image_)
await image.compress()
runInAction(() => {
this.images.push(image)
})
}
}
async paste(uri: string) {
if (this.size >= 4) {
return
}
const {width, height} = await getImageDim(uri)
const image: RNImage = {
path: uri,
height,
width,
size: getDataUriSize(uri),
mime: 'image/jpeg',
}
runInAction(() => {
this.add(image)
})
}
crop(image: ImageModel) {
image.crop()
}
remove(image: ImageModel) {
const index = this.images.findIndex(image_ => image_.path === image.path)
this.images.splice(index, 1)
}
async pick() {
const images = await openPicker(this.rootStore, {
multiple: true,
maxFiles: 4 - this.images.length,
})
await Promise.all(images.map(image => this.add(image)))
}
}

View File

@ -0,0 +1,85 @@
import {Image as RNImage} from 'react-native-image-crop-picker'
import {RootStoreModel} from 'state/index'
import {compressAndResizeImageForPost} from 'lib/media/manip'
import {makeAutoObservable, runInAction} from 'mobx'
import {openCropper} from 'lib/media/picker'
import {POST_IMG_MAX} from 'lib/constants'
import {scaleDownDimensions} from 'lib/media/util'
// TODO: EXIF embed
// Cases to consider: ExternalEmbed
export class ImageModel implements RNImage {
path: string
mime = 'image/jpeg'
width: number
height: number
size: number
cropped?: RNImage = undefined
compressed?: RNImage = undefined
scaledWidth: number = POST_IMG_MAX.width
scaledHeight: number = POST_IMG_MAX.height
constructor(public rootStore: RootStoreModel, image: RNImage) {
makeAutoObservable(this, {
rootStore: false,
})
this.path = image.path
this.width = image.width
this.height = image.height
this.size = image.size
this.calcScaledDimensions()
}
calcScaledDimensions() {
const {width, height} = scaleDownDimensions(
{width: this.width, height: this.height},
POST_IMG_MAX,
)
this.scaledWidth = width
this.scaledHeight = height
}
async crop() {
try {
const cropped = await openCropper(this.rootStore, {
mediaType: 'photo',
path: this.path,
freeStyleCropEnabled: true,
width: this.scaledWidth,
height: this.scaledHeight,
})
runInAction(() => {
this.cropped = cropped
})
} catch (err) {
this.rootStore.log.error('Failed to crop photo', err)
}
this.compress()
}
async compress() {
try {
const {width, height} = scaleDownDimensions(
this.cropped
? {width: this.cropped.width, height: this.cropped.height}
: {width: this.width, height: this.height},
POST_IMG_MAX,
)
const compressed = await compressAndResizeImageForPost({
...(this.cropped === undefined ? this : this.cropped),
width,
height,
})
runInAction(() => {
this.compressed = compressed
})
} catch (err) {
this.rootStore.log.error('Failed to compress photo', err)
}
}
}

View File

@ -3,7 +3,7 @@ import {RootStoreModel} from '../root-store'
import {makeAutoObservable} from 'mobx'
import {ProfileModel} from '../content/profile'
import {isObj, hasProp} from 'lib/type-guards'
import {PickedMedia} from 'lib/media/types'
import {Image} from 'lib/media/types'
export interface ConfirmModal {
name: 'confirm'
@ -38,7 +38,7 @@ export interface ReportAccountModal {
export interface CropImageModal {
name: 'crop-image'
uri: string
onSelect: (img?: PickedMedia) => void
onSelect: (img?: Image) => void
}
export interface DeleteAccountModal {

View File

@ -1,4 +1,4 @@
import React from 'react'
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'
import {observer} from 'mobx-react-lite'
import {
ActivityIndicator,
@ -30,47 +30,42 @@ import {sanitizeDisplayName} from 'lib/strings/display-names'
import {cleanError} from 'lib/strings/errors'
import {SelectPhotoBtn} from './photos/SelectPhotoBtn'
import {OpenCameraBtn} from './photos/OpenCameraBtn'
import {SelectedPhotos} from './photos/SelectedPhotos'
import {usePalette} from 'lib/hooks/usePalette'
import QuoteEmbed from '../util/post-embeds/QuoteEmbed'
import {useExternalLinkFetch} from './useExternalLinkFetch'
import {isDesktopWeb} from 'platform/detection'
import {GalleryModel} from 'state/models/media/gallery'
import {Gallery} from './photos/Gallery'
const MAX_GRAPHEME_LENGTH = 300
type Props = ComposerOpts & {
onClose: () => void
}
export const ComposePost = observer(function ComposePost({
replyTo,
onPost,
onClose,
quote: initQuote,
}: {
replyTo?: ComposerOpts['replyTo']
onPost?: ComposerOpts['onPost']
onClose: () => void
quote?: ComposerOpts['quote']
}) {
}: Props) {
const {track} = useAnalytics()
const pal = usePalette('default')
const store = useStores()
const textInput = React.useRef<TextInputRef>(null)
const [isProcessing, setIsProcessing] = React.useState(false)
const [processingState, setProcessingState] = React.useState('')
const [error, setError] = React.useState('')
const [richtext, setRichText] = React.useState(new RichText({text: ''}))
const graphemeLength = React.useMemo(
() => richtext.graphemeLength,
[richtext],
)
const [quote, setQuote] = React.useState<ComposerOpts['quote'] | undefined>(
const textInput = useRef<TextInputRef>(null)
const [isProcessing, setIsProcessing] = useState(false)
const [processingState, setProcessingState] = useState('')
const [error, setError] = useState('')
const [richtext, setRichText] = useState(new RichText({text: ''}))
const graphemeLength = useMemo(() => richtext.graphemeLength, [richtext])
const [quote, setQuote] = useState<ComposerOpts['quote'] | undefined>(
initQuote,
)
const {extLink, setExtLink} = useExternalLinkFetch({setQuote})
const [suggestedLinks, setSuggestedLinks] = React.useState<Set<string>>(
new Set(),
)
const [selectedPhotos, setSelectedPhotos] = React.useState<string[]>([])
const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set())
const gallery = useMemo(() => new GalleryModel(store), [store])
const autocompleteView = React.useMemo<UserAutocompleteModel>(
const autocompleteView = useMemo<UserAutocompleteModel>(
() => new UserAutocompleteModel(store),
[store],
)
@ -82,17 +77,17 @@ export const ComposePost = observer(function ComposePost({
// is focused during unmount, an exception will throw (seems that a blur method isnt implemented)
// manually blurring before closing gets around that
// -prf
const hackfixOnClose = React.useCallback(() => {
const hackfixOnClose = useCallback(() => {
textInput.current?.blur()
onClose()
}, [textInput, onClose])
// initial setup
React.useEffect(() => {
useEffect(() => {
autocompleteView.setup()
}, [autocompleteView])
React.useEffect(() => {
useEffect(() => {
// HACK
// wait a moment before focusing the input to resolve some layout bugs with the keyboard-avoiding-view
// -prf
@ -109,60 +104,51 @@ export const ComposePost = observer(function ComposePost({
}
}, [])
const onPressContainer = React.useCallback(() => {
const onPressContainer = useCallback(() => {
textInput.current?.focus()
}, [textInput])
const onSelectPhotos = React.useCallback(
(photos: string[]) => {
track('Composer:SelectedPhotos')
setSelectedPhotos(photos)
},
[track, setSelectedPhotos],
)
const onPressAddLinkCard = React.useCallback(
const onPressAddLinkCard = useCallback(
(uri: string) => {
setExtLink({uri, isLoading: true})
},
[setExtLink],
)
const onPhotoPasted = React.useCallback(
const onPhotoPasted = useCallback(
async (uri: string) => {
if (selectedPhotos.length >= 4) {
return
}
onSelectPhotos([...selectedPhotos, uri])
track('Composer:PastedPhotos')
gallery.paste(uri)
},
[selectedPhotos, onSelectPhotos],
[gallery, track],
)
const onPressPublish = React.useCallback(async () => {
if (isProcessing) {
return
}
if (richtext.graphemeLength > MAX_GRAPHEME_LENGTH) {
const onPressPublish = useCallback(async () => {
if (isProcessing || richtext.graphemeLength > MAX_GRAPHEME_LENGTH) {
return
}
setError('')
if (richtext.text.trim().length === 0 && selectedPhotos.length === 0) {
if (richtext.text.trim().length === 0 && gallery.isEmpty) {
setError('Did you want to say anything?')
return false
}
setIsProcessing(true)
try {
await apilib.post(store, {
rawText: richtext.text,
replyTo: replyTo?.uri,
images: selectedPhotos,
images: gallery.paths,
quote: quote,
extLink: extLink,
onStateChange: setProcessingState,
knownHandles: autocompleteView.knownHandles,
})
track('Create Post', {
imageCount: selectedPhotos.length,
imageCount: gallery.size,
})
} catch (e: any) {
if (extLink) {
@ -191,34 +177,33 @@ export const ComposePost = observer(function ComposePost({
hackfixOnClose,
onPost,
quote,
selectedPhotos,
setExtLink,
store,
track,
gallery,
])
const canPost = graphemeLength <= MAX_GRAPHEME_LENGTH
const selectTextInputPlaceholder = replyTo
? 'Write your reply'
: selectedPhotos.length !== 0
: gallery.isEmpty
? 'Write a comment'
: "What's up?"
const canSelectImages = gallery.size <= 4
const viewStyles = {
paddingBottom: Platform.OS === 'android' ? insets.bottom : 0,
paddingTop: Platform.OS === 'android' ? insets.top : 15,
}
return (
<KeyboardAvoidingView
testID="composePostView"
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.outer}>
<TouchableWithoutFeedback onPressIn={onPressContainer}>
<View
style={[
s.flex1,
{
paddingBottom: Platform.OS === 'android' ? insets.bottom : 0,
paddingTop: Platform.OS === 'android' ? insets.top : 15,
},
]}>
<View style={[s.flex1, viewStyles]}>
<View style={styles.topbar}>
<TouchableOpacity
testID="composerCancelButton"
@ -301,11 +286,8 @@ export const ComposePost = observer(function ComposePost({
/>
</View>
<SelectedPhotos
selectedPhotos={selectedPhotos}
onSelectPhotos={onSelectPhotos}
/>
{selectedPhotos.length === 0 && extLink && (
<Gallery gallery={gallery} />
{gallery.isEmpty && extLink && (
<ExternalEmbed
link={extLink}
onRemove={() => setExtLink(undefined)}
@ -317,9 +299,7 @@ export const ComposePost = observer(function ComposePost({
</View>
) : undefined}
</ScrollView>
{!extLink &&
selectedPhotos.length === 0 &&
suggestedLinks.size > 0 ? (
{!extLink && suggestedLinks.size > 0 ? (
<View style={s.mb5}>
{Array.from(suggestedLinks).map(url => (
<TouchableOpacity
@ -335,16 +315,12 @@ export const ComposePost = observer(function ComposePost({
</View>
) : null}
<View style={[pal.border, styles.bottomBar]}>
<SelectPhotoBtn
enabled={selectedPhotos.length < 4}
selectedPhotos={selectedPhotos}
onSelectPhotos={setSelectedPhotos}
/>
<OpenCameraBtn
enabled={selectedPhotos.length < 4}
selectedPhotos={selectedPhotos}
onSelectPhotos={setSelectedPhotos}
/>
{canSelectImages ? (
<>
<SelectPhotoBtn gallery={gallery} />
<OpenCameraBtn gallery={gallery} />
</>
) : null}
<View style={s.flex1} />
<CharProgress count={graphemeLength} />
</View>

View File

@ -2,11 +2,10 @@ import React from 'react'
import {
ActivityIndicator,
StyleSheet,
TouchableWithoutFeedback,
TouchableOpacity,
View,
} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {BlurView} from '../util/BlurView'
import {AutoSizedImage} from '../util/images/AutoSizedImage'
import {Text} from '../util/text/Text'
import {s} from 'lib/styles'
@ -61,11 +60,9 @@ export const ExternalEmbed = ({
</Text>
)}
</View>
<TouchableWithoutFeedback onPress={onRemove}>
<BlurView style={styles.removeBtn} blurType="dark">
<TouchableOpacity style={styles.removeBtn} onPress={onRemove}>
<FontAwesomeIcon size={18} icon="xmark" style={s.white} />
</BlurView>
</TouchableWithoutFeedback>
</TouchableOpacity>
</View>
)
}
@ -92,6 +89,7 @@ const styles = StyleSheet.create({
right: 10,
width: 36,
height: 36,
backgroundColor: 'rgba(0, 0, 0, 0.75)',
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',

View File

@ -0,0 +1,130 @@
import React, {useCallback} from 'react'
import {GalleryModel} from 'state/models/media/gallery'
import {observer} from 'mobx-react-lite'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {colors} from 'lib/styles'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {ImageModel} from 'state/models/media/image'
import {Image} from 'expo-image'
interface Props {
gallery: GalleryModel
}
export const Gallery = observer(function ({gallery}: Props) {
const getImageStyle = useCallback(() => {
switch (gallery.size) {
case 1:
return styles.image250
case 2:
return styles.image175
default:
return styles.image85
}
}, [gallery])
const imageStyle = getImageStyle()
const handleRemovePhoto = useCallback(
(image: ImageModel) => {
gallery.remove(image)
},
[gallery],
)
const handleEditPhoto = useCallback(
(image: ImageModel) => {
gallery.crop(image)
},
[gallery],
)
return !gallery.isEmpty ? (
<View testID="selectedPhotosView" style={styles.gallery}>
{gallery.images.map(image =>
image.compressed !== undefined ? (
<View
key={`selected-image-${image.path}`}
style={[styles.imageContainer, imageStyle]}>
<View style={styles.imageControls}>
<TouchableOpacity
testID="cropPhotoButton"
onPress={() => {
handleEditPhoto(image)
}}
style={styles.imageControl}>
<FontAwesomeIcon
icon="pen"
size={12}
style={{color: colors.white}}
/>
</TouchableOpacity>
<TouchableOpacity
testID="removePhotoButton"
onPress={() => handleRemovePhoto(image)}
style={styles.imageControl}>
<FontAwesomeIcon
icon="xmark"
size={16}
style={{color: colors.white}}
/>
</TouchableOpacity>
</View>
<Image
testID="selectedPhotoImage"
style={[styles.image, imageStyle]}
source={{
uri: image.compressed.path,
}}
/>
</View>
) : null,
)}
</View>
) : null
})
const styles = StyleSheet.create({
gallery: {
flex: 1,
flexDirection: 'row',
marginTop: 16,
},
imageContainer: {
margin: 2,
},
image: {
resizeMode: 'cover',
borderRadius: 8,
},
image250: {
width: 250,
height: 250,
},
image175: {
width: 175,
height: 175,
},
image85: {
width: 85,
height: 85,
},
imageControls: {
position: 'absolute',
display: 'flex',
flexDirection: 'row',
gap: 4,
top: 8,
right: 8,
zIndex: 1,
},
imageControl: {
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: 'rgba(0, 0, 0, 0.75)',
borderWidth: 0.5,
alignItems: 'center',
justifyContent: 'center',
},
})

View File

@ -1,4 +1,4 @@
import React from 'react'
import React, {useCallback} from 'react'
import {TouchableOpacity} from 'react-native'
import {
FontAwesomeIcon,
@ -10,62 +10,44 @@ import {useStores} from 'state/index'
import {s} from 'lib/styles'
import {isDesktopWeb} from 'platform/detection'
import {openCamera} from 'lib/media/picker'
import {compressIfNeeded} from 'lib/media/manip'
import {useCameraPermission} from 'lib/hooks/usePermissions'
import {
POST_IMG_MAX_WIDTH,
POST_IMG_MAX_HEIGHT,
POST_IMG_MAX_SIZE,
} from 'lib/constants'
import {POST_IMG_MAX} from 'lib/constants'
import {GalleryModel} from 'state/models/media/gallery'
const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10}
export function OpenCameraBtn({
enabled,
selectedPhotos,
onSelectPhotos,
}: {
enabled: boolean
selectedPhotos: string[]
onSelectPhotos: (v: string[]) => void
}) {
type Props = {
gallery: GalleryModel
}
export function OpenCameraBtn({gallery}: Props) {
const pal = usePalette('default')
const {track} = useAnalytics()
const store = useStores()
const {requestCameraAccessIfNeeded} = useCameraPermission()
const onPressTakePicture = React.useCallback(async () => {
const onPressTakePicture = useCallback(async () => {
track('Composer:CameraOpened')
if (!enabled) {
return
}
try {
if (!(await requestCameraAccessIfNeeded())) {
return
}
const cameraRes = await openCamera(store, {
mediaType: 'photo',
width: POST_IMG_MAX_WIDTH,
height: POST_IMG_MAX_HEIGHT,
const img = await openCamera(store, {
width: POST_IMG_MAX.width,
height: POST_IMG_MAX.height,
freeStyleCropEnabled: true,
})
const img = await compressIfNeeded(cameraRes, POST_IMG_MAX_SIZE)
onSelectPhotos([...selectedPhotos, img.path])
gallery.add(img)
} catch (err: any) {
// ignore
store.log.warn('Error using camera', err)
}
}, [
track,
store,
onSelectPhotos,
selectedPhotos,
enabled,
requestCameraAccessIfNeeded,
])
}, [gallery, track, store, requestCameraAccessIfNeeded])
if (isDesktopWeb) {
return <></>
return null
}
return (
@ -76,11 +58,7 @@ export function OpenCameraBtn({
hitSlop={HITSLOP}>
<FontAwesomeIcon
icon="camera"
style={
(enabled
? pal.link
: [pal.textLight, s.dimmed]) as FontAwesomeIconStyle
}
style={pal.link as FontAwesomeIconStyle}
size={24}
/>
</TouchableOpacity>

View File

@ -0,0 +1,3 @@
export function OpenCameraBtn() {
return null
}

View File

@ -1,86 +1,36 @@
import React from 'react'
import {Platform, TouchableOpacity} from 'react-native'
import React, {useCallback} from 'react'
import {TouchableOpacity} from 'react-native'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {usePalette} from 'lib/hooks/usePalette'
import {useAnalytics} from 'lib/analytics'
import {useStores} from 'state/index'
import {s} from 'lib/styles'
import {isDesktopWeb} from 'platform/detection'
import {openPicker, cropAndCompressFlow, pickImagesFlow} from 'lib/media/picker'
import {usePhotoLibraryPermission} from 'lib/hooks/usePermissions'
import {
POST_IMG_MAX_WIDTH,
POST_IMG_MAX_HEIGHT,
POST_IMG_MAX_SIZE,
} from 'lib/constants'
import {GalleryModel} from 'state/models/media/gallery'
const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10}
export function SelectPhotoBtn({
enabled,
selectedPhotos,
onSelectPhotos,
}: {
enabled: boolean
selectedPhotos: string[]
onSelectPhotos: (v: string[]) => void
}) {
type Props = {
gallery: GalleryModel
}
export function SelectPhotoBtn({gallery}: Props) {
const pal = usePalette('default')
const {track} = useAnalytics()
const store = useStores()
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
const onPressSelectPhotos = React.useCallback(async () => {
const onPressSelectPhotos = useCallback(async () => {
track('Composer:GalleryOpened')
if (!enabled) {
if (!isDesktopWeb && !(await requestPhotoAccessIfNeeded())) {
return
}
if (isDesktopWeb) {
const images = await pickImagesFlow(
store,
4 - selectedPhotos.length,
{width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT},
POST_IMG_MAX_SIZE,
)
onSelectPhotos([...selectedPhotos, ...images])
} else {
if (!(await requestPhotoAccessIfNeeded())) {
return
}
const items = await openPicker(store, {
multiple: true,
maxFiles: 4 - selectedPhotos.length,
mediaType: 'photo',
})
const result = []
for (const image of items) {
if (Platform.OS === 'android') {
result.push(image.path)
continue
}
result.push(
await cropAndCompressFlow(
store,
image.path,
image,
{width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT},
POST_IMG_MAX_SIZE,
),
)
}
onSelectPhotos([...selectedPhotos, ...result])
}
}, [
track,
store,
onSelectPhotos,
selectedPhotos,
enabled,
requestPhotoAccessIfNeeded,
])
gallery.pick()
}, [track, gallery, requestPhotoAccessIfNeeded])
return (
<TouchableOpacity
@ -90,11 +40,7 @@ export function SelectPhotoBtn({
hitSlop={HITSLOP}>
<FontAwesomeIcon
icon={['far', 'image']}
style={
(enabled
? pal.link
: [pal.textLight, s.dimmed]) as FontAwesomeIconStyle
}
style={pal.link as FontAwesomeIconStyle}
size={24}
/>
</TouchableOpacity>

View File

@ -1,96 +0,0 @@
import React, {useCallback} from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Image} from 'expo-image'
import {colors} from 'lib/styles'
export const SelectedPhotos = ({
selectedPhotos,
onSelectPhotos,
}: {
selectedPhotos: string[]
onSelectPhotos: (v: string[]) => void
}) => {
const imageStyle =
selectedPhotos.length === 1
? styles.image250
: selectedPhotos.length === 2
? styles.image175
: styles.image85
const handleRemovePhoto = useCallback(
item => {
onSelectPhotos(selectedPhotos.filter(filterItem => filterItem !== item))
},
[selectedPhotos, onSelectPhotos],
)
return selectedPhotos.length !== 0 ? (
<View testID="selectedPhotosView" style={styles.gallery}>
{selectedPhotos.length !== 0 &&
selectedPhotos.map((item, index) => (
<View
key={`selected-image-${index}`}
style={[styles.imageContainer, imageStyle]}>
<TouchableOpacity
testID="removePhotoButton"
onPress={() => handleRemovePhoto(item)}
style={styles.removePhotoButton}>
<FontAwesomeIcon
icon="xmark"
size={16}
style={{color: colors.white}}
/>
</TouchableOpacity>
<Image
testID="selectedPhotoImage"
style={[styles.image, imageStyle]}
source={{uri: item}}
/>
</View>
))}
</View>
) : null
}
const styles = StyleSheet.create({
gallery: {
flex: 1,
flexDirection: 'row',
marginTop: 16,
},
imageContainer: {
margin: 2,
},
image: {
resizeMode: 'cover',
borderRadius: 8,
},
image250: {
width: 250,
height: 250,
},
image175: {
width: 175,
height: 175,
},
image85: {
width: 85,
height: 85,
},
removePhotoButton: {
position: 'absolute',
top: 8,
right: 8,
width: 24,
height: 24,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.black,
zIndex: 1,
borderColor: colors.gray4,
borderWidth: 0.5,
},
})

View File

@ -1,4 +1,4 @@
import React from 'react'
import React, {forwardRef, useCallback, useEffect, useRef, useMemo} from 'react'
import {
NativeSyntheticEvent,
StyleSheet,
@ -14,18 +14,13 @@ import isEqual from 'lodash.isequal'
import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
import {Autocomplete} from './mobile/Autocomplete'
import {Text} from 'view/com/util/text/Text'
import {useStores} from 'state/index'
import {cleanError} from 'lib/strings/errors'
import {getImageDim} from 'lib/media/manip'
import {cropAndCompressFlow} from 'lib/media/picker'
import {getMentionAt, insertMentionAt} from 'lib/strings/mention-manip'
import {
POST_IMG_MAX_WIDTH,
POST_IMG_MAX_HEIGHT,
POST_IMG_MAX_SIZE,
} from 'lib/constants'
import {usePalette} from 'lib/hooks/usePalette'
import {useTheme} from 'lib/ThemeContext'
import {isUriImage} from 'lib/media/util'
import {downloadAndResize} from 'lib/media/manip'
import {POST_IMG_MAX} from 'lib/constants'
export interface TextInputRef {
focus: () => void
@ -48,7 +43,7 @@ interface Selection {
end: number
}
export const TextInput = React.forwardRef(
export const TextInput = forwardRef(
(
{
richtext,
@ -63,9 +58,8 @@ export const TextInput = React.forwardRef(
ref,
) => {
const pal = usePalette('default')
const store = useStores()
const textInput = React.useRef<PasteInputRef>(null)
const textInputSelection = React.useRef<Selection>({start: 0, end: 0})
const textInput = useRef<PasteInputRef>(null)
const textInputSelection = useRef<Selection>({start: 0, end: 0})
const theme = useTheme()
React.useImperativeHandle(ref, () => ({
@ -73,7 +67,7 @@ export const TextInput = React.forwardRef(
blur: () => textInput.current?.blur(),
}))
React.useEffect(() => {
useEffect(() => {
// HACK
// wait a moment before focusing the input to resolve some layout bugs with the keyboard-avoiding-view
// -prf
@ -90,8 +84,8 @@ export const TextInput = React.forwardRef(
}
}, [])
const onChangeText = React.useCallback(
(newText: string) => {
const onChangeText = useCallback(
async (newText: string) => {
const newRt = new RichText({text: newText})
newRt.detectFacetsWithoutResolution()
setRichText(newRt)
@ -108,50 +102,62 @@ export const TextInput = React.forwardRef(
}
const set: Set<string> = new Set()
if (newRt.facets) {
for (const facet of newRt.facets) {
for (const feature of facet.features) {
if (AppBskyRichtextFacet.isLink(feature)) {
if (isUriImage(feature.uri)) {
const res = await downloadAndResize({
uri: feature.uri,
width: POST_IMG_MAX.width,
height: POST_IMG_MAX.height,
mode: 'contain',
maxSize: POST_IMG_MAX.size,
timeout: 15e3,
})
if (res !== undefined) {
onPhotoPasted(res.path)
}
} else {
set.add(feature.uri)
}
}
}
}
}
if (!isEqual(set, suggestedLinks)) {
onSuggestedLinksChanged(set)
}
},
[setRichText, autocompleteView, suggestedLinks, onSuggestedLinksChanged],
[
setRichText,
autocompleteView,
suggestedLinks,
onSuggestedLinksChanged,
onPhotoPasted,
],
)
const onPaste = React.useCallback(
const onPaste = useCallback(
async (err: string | undefined, files: PastedFile[]) => {
if (err) {
return onError(cleanError(err))
}
const uris = files.map(f => f.uri)
const imgUri = uris.find(uri => /\.(jpe?g|png)$/.test(uri))
if (imgUri) {
let imgDim
try {
imgDim = await getImageDim(imgUri)
} catch (e) {
imgDim = {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT}
}
const finalImgPath = await cropAndCompressFlow(
store,
imgUri,
imgDim,
{width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT},
POST_IMG_MAX_SIZE,
)
onPhotoPasted(finalImgPath)
const uri = uris.find(isUriImage)
if (uri) {
onPhotoPasted(uri)
}
},
[store, onError, onPhotoPasted],
[onError, onPhotoPasted],
)
const onSelectionChange = React.useCallback(
const onSelectionChange = useCallback(
(evt: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => {
// NOTE we track the input selection using a ref to avoid excessive renders -prf
textInputSelection.current = evt.nativeEvent.selection
@ -159,7 +165,7 @@ export const TextInput = React.forwardRef(
[textInputSelection],
)
const onSelectAutocompleteItem = React.useCallback(
const onSelectAutocompleteItem = useCallback(
(item: string) => {
onChangeText(
insertMentionAt(
@ -173,23 +179,19 @@ export const TextInput = React.forwardRef(
[onChangeText, richtext, autocompleteView],
)
const textDecorated = React.useMemo(() => {
const textDecorated = useMemo(() => {
let i = 0
return Array.from(richtext.segments()).map(segment => {
if (!segment.facet) {
return (
<Text key={i++} style={[pal.text, styles.textInputFormatting]}>
return Array.from(richtext.segments()).map(segment => (
<Text
key={i++}
style={[
!segment.facet ? pal.text : pal.link,
styles.textInputFormatting,
]}>
{segment.text}
</Text>
)
} else {
return (
<Text key={i++} style={[pal.link, styles.textInputFormatting]}>
{segment.text}
</Text>
)
}
})
))
}, [richtext, pal.link, pal.text])
return (
@ -223,7 +225,6 @@ const styles = StyleSheet.create({
textInput: {
flex: 1,
width: '100%',
minHeight: 80,
padding: 5,
paddingBottom: 20,
marginLeft: 8,

View File

@ -12,6 +12,7 @@ import isEqual from 'lodash.isequal'
import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
import {createSuggestion} from './web/Autocomplete'
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
import {isUriImage, blobToDataUri} from 'lib/media/util'
export interface TextInputRef {
focus: () => void
@ -37,7 +38,7 @@ export const TextInput = React.forwardRef(
suggestedLinks,
autocompleteView,
setRichText,
// onPhotoPasted, TODO
onPhotoPasted,
onSuggestedLinksChanged,
}: // onError, TODO
TextInputProps,
@ -72,6 +73,15 @@ export const TextInput = React.forwardRef(
attributes: {
class: modeClass,
},
handlePaste: (_, event) => {
const items = event.clipboardData?.items
if (items === undefined) {
return
}
getImageFromUri(items, onPhotoPasted)
},
},
content: richtext.text.toString(),
autofocus: true,
@ -147,3 +157,33 @@ const styles = StyleSheet.create({
marginBottom: 10,
},
})
function getImageFromUri(
items: DataTransferItemList,
callback: (uri: string) => void,
) {
for (let index = 0; index < items.length; index++) {
const item = items[index]
const {kind, type} = item
if (type === 'text/plain') {
item.getAsString(async itemString => {
if (isUriImage(itemString)) {
const response = await fetch(itemString)
const blob = await response.blob()
blobToDataUri(blob).then(callback, err => console.error(err))
}
})
}
if (kind === 'file') {
const file = item.getAsFile()
if (file instanceof Blob) {
blobToDataUri(new Blob([file], {type: item.type})).then(callback, err =>
console.error(err),
)
}
}
}
}

View File

@ -6,6 +6,7 @@ import {getPostAsQuote} from 'lib/link-meta/bsky'
import {downloadAndResize} from 'lib/media/manip'
import {isBskyPostUrl} from 'lib/strings/url-helpers'
import {ComposerOpts} from 'state/models/ui/shell'
import {POST_IMG_MAX} from 'lib/constants'
export function useExternalLinkFetch({
setQuote,
@ -55,13 +56,12 @@ export function useExternalLinkFetch({
return cleanup
}
if (extLink.isLoading && extLink.meta?.image && !extLink.localThumb) {
console.log('attempting download')
downloadAndResize({
uri: extLink.meta.image,
width: 2000,
height: 2000,
width: POST_IMG_MAX.width,
height: POST_IMG_MAX.height,
mode: 'contain',
maxSize: 1000000,
maxSize: POST_IMG_MAX.size,
timeout: 15e3,
})
.catch(() => undefined)

View File

@ -8,7 +8,7 @@ import {
} from 'react-native'
import LinearGradient from 'react-native-linear-gradient'
import {ScrollView, TextInput} from './util'
import {PickedMedia} from '../../../lib/media/picker'
import {Image as RNImage} from 'react-native-image-crop-picker'
import {Text} from '../util/text/Text'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {useStores} from 'state/index'
@ -53,15 +53,15 @@ export function Component({
profileView.avatar,
)
const [newUserBanner, setNewUserBanner] = useState<
PickedMedia | undefined | null
RNImage | undefined | null
>()
const [newUserAvatar, setNewUserAvatar] = useState<
PickedMedia | undefined | null
RNImage | undefined | null
>()
const onPressCancel = () => {
store.shell.closeModal()
}
const onSelectNewAvatar = async (img: PickedMedia | null) => {
const onSelectNewAvatar = async (img: RNImage | null) => {
track('EditProfile:AvatarSelected')
try {
// if img is null, user selected "remove avatar"
@ -71,13 +71,13 @@ export function Component({
return
}
const finalImg = await compressIfNeeded(img, 1000000)
setNewUserAvatar({mediaType: 'photo', ...finalImg})
setNewUserAvatar(finalImg)
setUserAvatar(finalImg.path)
} catch (e: any) {
setError(cleanError(e))
}
}
const onSelectNewBanner = async (img: PickedMedia | null) => {
const onSelectNewBanner = async (img: RNImage | null) => {
if (!img) {
setNewUserBanner(null)
setUserBanner(null)
@ -86,7 +86,7 @@ export function Component({
track('EditProfile:BannerSelected')
try {
const finalImg = await compressIfNeeded(img, 1000000)
setNewUserBanner({mediaType: 'photo', ...finalImg})
setNewUserBanner(finalImg)
setUserBanner(finalImg.path)
} catch (e: any) {
setError(cleanError(e))

View File

@ -4,7 +4,7 @@ import ImageEditor from 'react-avatar-editor'
import {Slider} from '@miblanchard/react-native-slider'
import LinearGradient from 'react-native-linear-gradient'
import {Text} from 'view/com/util/text/Text'
import {PickedMedia} from 'lib/media/types'
import {Dimensions, Image} from 'lib/media/types'
import {getDataUriSize} from 'lib/media/util'
import {s, gradients} from 'lib/styles'
import {useStores} from 'state/index'
@ -16,11 +16,8 @@ enum AspectRatio {
Wide = 'wide',
Tall = 'tall',
}
interface Dim {
width: number
height: number
}
const DIMS: Record<string, Dim> = {
const DIMS: Record<string, Dimensions> = {
[AspectRatio.Square]: {width: 1000, height: 1000},
[AspectRatio.Wide]: {width: 1000, height: 750},
[AspectRatio.Tall]: {width: 750, height: 1000},
@ -33,7 +30,7 @@ export function Component({
onSelect,
}: {
uri: string
onSelect: (img?: PickedMedia) => void
onSelect: (img?: Image) => void
}) {
const store = useStores()
const pal = usePalette('default')
@ -52,7 +49,6 @@ export function Component({
if (canvas) {
const dataUri = canvas.toDataURL('image/jpeg')
onSelect({
mediaType: 'photo',
path: dataUri,
mime: 'image/jpeg',
size: getDataUriSize(dataUri),

View File

@ -4,12 +4,7 @@ import Svg, {Circle, Path} from 'react-native-svg'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {IconProp} from '@fortawesome/fontawesome-svg-core'
import {HighPriorityImage} from 'view/com/util/images/Image'
import {
openCamera,
openCropper,
openPicker,
PickedMedia,
} from '../../../lib/media/picker'
import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
import {
usePhotoLibraryPermission,
useCameraPermission,
@ -19,6 +14,7 @@ import {colors} from 'lib/styles'
import {DropdownButton} from './forms/DropdownButton'
import {usePalette} from 'lib/hooks/usePalette'
import {isWeb} from 'platform/detection'
import {Image as RNImage} from 'react-native-image-crop-picker'
function DefaultAvatar({size}: {size: number}) {
return (
@ -50,7 +46,7 @@ export function UserAvatar({
size: number
avatar?: string | null
hasWarning?: boolean
onSelectNewAvatar?: (img: PickedMedia | null) => void
onSelectNewAvatar?: (img: RNImage | null) => void
}) {
const store = useStores()
const pal = usePalette('default')
@ -68,7 +64,6 @@ export function UserAvatar({
}
onSelectNewAvatar?.(
await openCamera(store, {
mediaType: 'photo',
width: 1000,
height: 1000,
cropperCircleOverlay: true,
@ -84,9 +79,8 @@ export function UserAvatar({
if (!(await requestPhotoAccessIfNeeded())) {
return
}
const items = await openPicker(store, {
mediaType: 'photo',
})
const items = await openPicker(store)
onSelectNewAvatar?.(
await openCropper(store, {
mediaType: 'photo',

View File

@ -4,12 +4,8 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {IconProp} from '@fortawesome/fontawesome-svg-core'
import {Image} from 'expo-image'
import {colors} from 'lib/styles'
import {
openCamera,
openCropper,
openPicker,
PickedMedia,
} from '../../../lib/media/picker'
import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
import {Image as TImage} from 'lib/media/types'
import {useStores} from 'state/index'
import {
usePhotoLibraryPermission,
@ -24,7 +20,7 @@ export function UserBanner({
onSelectNewBanner,
}: {
banner?: string | null
onSelectNewBanner?: (img: PickedMedia | null) => void
onSelectNewBanner?: (img: TImage | null) => void
}) {
const store = useStores()
const pal = usePalette('default')
@ -42,7 +38,6 @@ export function UserBanner({
}
onSelectNewBanner?.(
await openCamera(store, {
mediaType: 'photo',
// compressImageMaxWidth: 3000, TODO needed?
width: 3000,
// compressImageMaxHeight: 1000, TODO needed?
@ -59,9 +54,7 @@ export function UserBanner({
if (!(await requestPhotoAccessIfNeeded())) {
return
}
const items = await openPicker(store, {
mediaType: 'photo',
})
const items = await openPicker(store)
onSelectNewBanner?.(
await openCropper(store, {
mediaType: 'photo',

View File

@ -1,4 +1,5 @@
import React from 'react'
import {Dimensions} from 'lib/media/types'
import React, {useState} from 'react'
import {
LayoutChangeEvent,
StyleProp,
@ -11,11 +12,6 @@ import {Image, ImageStyle} from 'expo-image'
export const DELAY_PRESS_IN = 500
interface Dim {
width: number
height: number
}
export type ImageLayoutGridType = 'two' | 'three' | 'four'
export function ImageLayoutGrid({
@ -33,7 +29,7 @@ export function ImageLayoutGrid({
onPressIn?: (index: number) => void
style?: StyleProp<ViewStyle>
}) {
const [containerInfo, setContainerInfo] = React.useState<Dim | undefined>()
const [containerInfo, setContainerInfo] = useState<Dimensions | undefined>()
const onLayout = (evt: LayoutChangeEvent) => {
setContainerInfo({
@ -71,7 +67,7 @@ function ImageLayoutGridInner({
onPress?: (index: number) => void
onLongPress?: (index: number) => void
onPressIn?: (index: number) => void
containerInfo: Dim
containerInfo: Dimensions
}) {
const size1 = React.useMemo<ImageStyle>(() => {
if (type === 'three') {