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
parent
91fadadb58
commit
2509290fdd
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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}),
|
||||
|
|
|
@ -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 || {}
|
||||
|
|
|
@ -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)))
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
})
|
|
@ -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>
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
export function OpenCameraBtn() {
|
||||
return null
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
})
|
|
@ -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,
|
||||
|
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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') {
|
||||
|
|
Loading…
Reference in New Issue