Implement image uploading in the web composer

zio/stable
Paul Frazee 2023-02-23 16:02:31 -06:00
parent 0f293ecf95
commit 4182edfd7e
19 changed files with 338 additions and 281 deletions

View File

@ -1,4 +1,7 @@
import {downloadAndResize, DownloadAndResizeOpts} from '../../src/lib/images' import {
downloadAndResize,
DownloadAndResizeOpts,
} from '../../src/lib/media/manip'
import ImageResizer from '@bam.tech/react-native-image-resizer' import ImageResizer from '@bam.tech/react-native-image-resizer'
import RNFetchBlob from 'rn-fetch-blob' import RNFetchBlob from 'rn-fetch-blob'

View File

@ -1,11 +1,16 @@
import {AppBskyEmbedImages, AppBskyEmbedExternal} from '@atproto/api' import {
AppBskyEmbedImages,
AppBskyEmbedExternal,
ComAtprotoBlobUpload,
} from '@atproto/api'
import {AtUri} from '../../third-party/uri' import {AtUri} from '../../third-party/uri'
import {RootStoreModel} from 'state/models/root-store' import {RootStoreModel} from 'state/models/root-store'
import {extractEntities} from 'lib/strings/rich-text-detection' import {extractEntities} from 'lib/strings/rich-text-detection'
import {isNetworkError} from 'lib/strings/errors' import {isNetworkError} from 'lib/strings/errors'
import {LinkMeta} from '../link-meta/link-meta' import {LinkMeta} from '../link-meta/link-meta'
import {Image} from '../images' import {Image} from '../media/manip'
import {RichText} from '../strings/rich-text' import {RichText} from '../strings/rich-text'
import {isWeb} from 'platform/detection'
export interface ExternalEmbedDraft { export interface ExternalEmbedDraft {
uri: string uri: string
@ -27,6 +32,25 @@ export async function resolveName(store: RootStoreModel, didOrHandle: string) {
return res.data.did return res.data.did
} }
export async function uploadBlob(
store: RootStoreModel,
blob: string,
encoding: string,
): Promise<ComAtprotoBlobUpload.Response> {
if (isWeb) {
// `blob` should be a data uri
return store.api.com.atproto.blob.upload(convertDataURIToUint8Array(blob), {
encoding,
})
} else {
// `blob` should be a path to a file in the local FS
return store.api.com.atproto.blob.upload(
blob, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts
{encoding},
)
}
}
export async function post( export async function post(
store: RootStoreModel, store: RootStoreModel,
rawText: string, rawText: string,
@ -61,10 +85,7 @@ export async function post(
let i = 1 let i = 1
for (const image of images) { for (const image of images) {
onStateChange?.(`Uploading image #${i++}...`) onStateChange?.(`Uploading image #${i++}...`)
const res = await store.api.com.atproto.blob.upload( const res = await uploadBlob(store, image, 'image/jpeg')
image, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts
{encoding: 'image/jpeg'},
)
embed.images.push({ embed.images.push({
image: { image: {
cid: res.data.cid, cid: res.data.cid,
@ -94,9 +115,10 @@ export async function post(
) )
} }
if (encoding) { if (encoding) {
const thumbUploadRes = await store.api.com.atproto.blob.upload( const thumbUploadRes = await uploadBlob(
extLink.localThumb.path, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts store,
{encoding}, extLink.localThumb.path,
encoding,
) )
thumb = { thumb = {
cid: thumbUploadRes.data.cid, cid: thumbUploadRes.data.cid,
@ -199,3 +221,15 @@ export async function unfollow(store: RootStoreModel, followUri: string) {
rkey: followUrip.rkey, rkey: followUrip.rkey,
}) })
} }
// helpers
// =
function convertDataURIToUint8Array(uri: string): Uint8Array {
var raw = window.atob(uri.substring(uri.indexOf(';base64,') + 8))
var binary = new Uint8Array(new ArrayBuffer(raw.length))
for (let i = 0; i < raw.length; i++) {
binary[i] = raw.charCodeAt(i)
}
return binary
}

View File

@ -63,3 +63,7 @@ export const STAGING_SUGGESTED_FOLLOWS = ['arcalinea', 'paul', 'paul2'].map(
export const DEV_SUGGESTED_FOLLOWS = ['alice', 'bob', 'carla'].map( export const DEV_SUGGESTED_FOLLOWS = ['alice', 'bob', 'carla'].map(
handle => `${handle}.test`, handle => `${handle}.test`,
) )
export const POST_IMG_MAX_WIDTH = 2000
export const POST_IMG_MAX_HEIGHT = 2000
export const POST_IMG_MAX_SIZE = 1000000

View File

@ -1,6 +1,6 @@
import RNFetchBlob from 'rn-fetch-blob' import RNFetchBlob from 'rn-fetch-blob'
import ImageResizer from '@bam.tech/react-native-image-resizer' import ImageResizer from '@bam.tech/react-native-image-resizer'
import {Share} from 'react-native' import {Image as RNImage, Share} from 'react-native'
import RNFS from 'react-native-fs' import RNFS from 'react-native-fs'
import uuid from 'react-native-uuid' import uuid from 'react-native-uuid'
import * as Toast from 'view/com/util/Toast' import * as Toast from 'view/com/util/Toast'
@ -135,7 +135,7 @@ export function scaleDownDimensions(dim: Dim, max: Dim): Dim {
return {width: dim.width * hScale, height: dim.height * hScale} return {width: dim.width * hScale, height: dim.height * hScale}
} }
export const saveImageModal = async ({uri}: {uri: string}) => { export async function saveImageModal({uri}: {uri: string}) {
const downloadResponse = await RNFetchBlob.config({ const downloadResponse = await RNFetchBlob.config({
fileCache: true, fileCache: true,
}).fetch('GET', uri) }).fetch('GET', uri)
@ -153,7 +153,7 @@ export const saveImageModal = async ({uri}: {uri: string}) => {
RNFS.unlink(imagePath) RNFS.unlink(imagePath)
} }
export const moveToPremanantPath = async (path: string) => { 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. 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: Relevant: IOS bug when trying to open a second time:
@ -164,3 +164,15 @@ export const moveToPremanantPath = async (path: string) => {
RNFS.moveFile(path, destinationPath) RNFS.moveFile(path, destinationPath)
return destinationPath return destinationPath
} }
export function getImageDim(path: string): Promise<Dim> {
return new Promise((resolve, reject) => {
RNImage.getSize(
path,
(width, height) => {
resolve({width, height})
},
reject,
)
})
}

View File

@ -39,11 +39,16 @@ export async function resize(
} }
export async function compressIfNeeded( export async function compressIfNeeded(
_img: Image, img: Image,
_maxSize: number, maxSize: number,
): Promise<Image> { ): Promise<Image> {
if (img.size > maxSize) {
// TODO // TODO
throw new Error('TODO') throw new Error(
"This image is too large and we haven't implemented compression yet -- sorry!",
)
}
return img
} }
export interface Dim { export interface Dim {
@ -62,7 +67,22 @@ export function scaleDownDimensions(dim: Dim, max: Dim): Dim {
return {width: dim.width * hScale, height: dim.height * hScale} return {width: dim.width * hScale, height: dim.height * hScale}
} }
export const saveImageModal = async (_opts: {uri: string}) => { export async function saveImageModal(_opts: {uri: string}) {
// TODO // TODO
throw new Error('TODO') throw new Error('TODO')
} }
export async function moveToPremanantPath(path: string) {
return path
}
export async function getImageDim(path: string): Promise<Dim> {
var img = document.createElement('img')
const promise = new Promise((resolve, reject) => {
img.onload = resolve
img.onerror = reject
})
img.src = path
await promise
return {width: img.width, height: img.height}
}

View File

@ -6,6 +6,12 @@ import {
} from 'react-native-image-crop-picker' } from 'react-native-image-crop-picker'
import {RootStoreModel} from 'state/index' import {RootStoreModel} from 'state/index'
import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types' import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types'
import {
scaleDownDimensions,
Dim,
compressIfNeeded,
moveToPremanantPath,
} from 'lib/media/manip'
export type {PickedMedia} from './types' export type {PickedMedia} from './types'
/** /**
@ -90,3 +96,46 @@ export async function openCropper(
height: item.height, 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

@ -4,6 +4,13 @@ import {CropImageModal} from 'state/models/shell-ui'
import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types' import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types'
export type {PickedMedia} from './types' export type {PickedMedia} from './types'
import {RootStoreModel} from 'state/index' import {RootStoreModel} from 'state/index'
import {
scaleDownDimensions,
getImageDim,
Dim,
compressIfNeeded,
moveToPremanantPath,
} from 'lib/media/manip'
interface PickedFile { interface PickedFile {
uri: string uri: string
@ -12,21 +19,22 @@ interface PickedFile {
} }
export async function openPicker( export async function openPicker(
store: RootStoreModel, _store: RootStoreModel,
opts: PickerOpts, opts: PickerOpts,
): Promise<PickedMedia[] | PickedMedia> { ): Promise<PickedMedia[]> {
const res = await selectFile(opts) const res = await selectFile(opts)
return new Promise((resolve, reject) => { const dim = await getImageDim(res.uri)
store.shell.openModal( const mime = extractDataUriMime(res.uri)
new CropImageModal(res.uri, (img?: PickedMedia) => { return [
if (img) { {
resolve(img) mediaType: 'photo',
} else { path: res.uri,
reject(new Error('Canceled')) mime,
} size: res.size,
}), width: dim.width,
) height: dim.height,
}) },
]
} }
export async function openCamera( export async function openCamera(
@ -38,13 +46,69 @@ export async function openCamera(
} }
export async function openCropper( export async function openCropper(
_store: RootStoreModel, store: RootStoreModel,
_opts: CropperOpts, opts: CropperOpts,
): Promise<PickedMedia> { ): Promise<PickedMedia> {
// const mediaType = opts.mediaType || 'photo' TODO // TODO handle more opts
throw new Error('TODO') return new Promise((resolve, reject) => {
store.shell.openModal(
new CropImageModal(opts.path, (img?: PickedMedia) => {
if (img) {
resolve(img)
} else {
reject(new Error('Canceled'))
}
}),
)
})
} }
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
// =
function selectFile(opts: PickerOpts): Promise<PickedFile> { function selectFile(opts: PickerOpts): Promise<PickedFile> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
var input = document.createElement('input') var input = document.createElement('input')
@ -73,3 +137,7 @@ function selectFile(opts: PickerOpts): Promise<PickedFile> {
input.click() input.click()
}) })
} }
function extractDataUriMime(uri: string): string {
return uri.substring(uri.indexOf(':') + 1, uri.indexOf(';'))
}

View File

@ -1,5 +1,5 @@
import {makeAutoObservable, runInAction} from 'mobx' import {makeAutoObservable, runInAction} from 'mobx'
import {PickedMedia} from 'view/com/util/images/image-crop-picker/ImageCropPicker' import {PickedMedia} from 'lib/media/picker'
import { import {
AppBskyActorGetProfile as GetProfile, AppBskyActorGetProfile as GetProfile,
AppBskyActorProfile as Profile, AppBskyActorProfile as Profile,
@ -137,11 +137,10 @@ export class ProfileViewModel {
newUserBanner: PickedMedia | undefined, newUserBanner: PickedMedia | undefined,
) { ) {
if (newUserAvatar) { if (newUserAvatar) {
const res = await this.rootStore.api.com.atproto.blob.upload( const res = await apilib.uploadBlob(
newUserAvatar.path, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts this.rootStore,
{ newUserAvatar.path,
encoding: newUserAvatar.mime, newUserAvatar.mime,
},
) )
updates.avatar = { updates.avatar = {
cid: res.data.cid, cid: res.data.cid,
@ -149,11 +148,10 @@ export class ProfileViewModel {
} }
} }
if (newUserBanner) { if (newUserBanner) {
const res = await this.rootStore.api.com.atproto.blob.upload( const res = await apilib.uploadBlob(
newUserBanner.path, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts this.rootStore,
{ newUserBanner.path,
encoding: newUserBanner.mime, newUserBanner.mime,
},
) )
updates.banner = { updates.banner = {
cid: res.data.cid, cid: res.data.cid,

View File

@ -36,11 +36,18 @@ import {s, colors, gradients} from 'lib/styles'
import {cleanError} from 'lib/strings/errors' import {cleanError} from 'lib/strings/errors'
import {detectLinkables, extractEntities} from 'lib/strings/rich-text-detection' import {detectLinkables, extractEntities} from 'lib/strings/rich-text-detection'
import {getLinkMeta} from 'lib/link-meta/link-meta' import {getLinkMeta} from 'lib/link-meta/link-meta'
import {downloadAndResize} from 'lib/images' import {getImageDim, downloadAndResize} from 'lib/media/manip'
import {PhotoCarouselPicker, cropPhoto} from './photos/PhotoCarouselPicker' import {PhotoCarouselPicker} from './photos/PhotoCarouselPicker'
import {cropAndCompressFlow, pickImagesFlow} from '../../../lib/media/picker'
import {getMentionAt, insertMentionAt} from 'lib/strings/mention-manip' import {getMentionAt, insertMentionAt} from 'lib/strings/mention-manip'
import {SelectedPhoto} from './SelectedPhoto' import {SelectedPhoto} from './SelectedPhoto'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {
POST_IMG_MAX_WIDTH,
POST_IMG_MAX_HEIGHT,
POST_IMG_MAX_SIZE,
} from 'lib/constants'
import {isWeb} from 'platform/detection'
const MAX_TEXT_LENGTH = 256 const MAX_TEXT_LENGTH = 256
const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10} const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10}
@ -61,7 +68,7 @@ export const ComposePost = observer(function ComposePost({
onPost?: ComposerOpts['onPost'] onPost?: ComposerOpts['onPost']
onClose: () => void onClose: () => void
}) { }) {
const {track, screen} = useAnalytics() const {track} = useAnalytics()
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores() const store = useStores()
const textInput = useRef<TextInputRef>(null) const textInput = useRef<TextInputRef>(null)
@ -174,14 +181,26 @@ export const ComposePost = observer(function ComposePost({
const onPressContainer = () => { const onPressContainer = () => {
textInput.current?.focus() textInput.current?.focus()
} }
const onPressSelectPhotos = () => { const onPressSelectPhotos = async () => {
track('ComposePost:SelectPhotos') track('ComposePost:SelectPhotos')
if (isWeb) {
if (selectedPhotos.length < 4) {
const images = await pickImagesFlow(
store,
4 - selectedPhotos.length,
{width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT},
POST_IMG_MAX_SIZE,
)
setSelectedPhotos([...selectedPhotos, ...images])
}
} else {
if (isSelectingPhotos) { if (isSelectingPhotos) {
setIsSelectingPhotos(false) setIsSelectingPhotos(false)
} else if (selectedPhotos.length < 4) { } else if (selectedPhotos.length < 4) {
setIsSelectingPhotos(true) setIsSelectingPhotos(true)
} }
} }
}
const onSelectPhotos = (photos: string[]) => { const onSelectPhotos = (photos: string[]) => {
track('ComposePost:SelectPhotos:Done') track('ComposePost:SelectPhotos:Done')
setSelectedPhotos(photos) setSelectedPhotos(photos)
@ -220,7 +239,19 @@ export const ComposePost = observer(function ComposePost({
} }
const imgUri = uris.find(uri => /\.(jpe?g|png)$/.test(uri)) const imgUri = uris.find(uri => /\.(jpe?g|png)$/.test(uri))
if (imgUri) { if (imgUri) {
const finalImgPath = await cropPhoto(store, 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,
)
onSelectPhotos([...selectedPhotos, finalImgPath]) onSelectPhotos([...selectedPhotos, finalImgPath])
} }
} }

View File

@ -9,57 +9,24 @@ import {
openPicker, openPicker,
openCamera, openCamera,
openCropper, openCropper,
} from '../../util/images/image-crop-picker/ImageCropPicker' cropAndCompressFlow,
} from '../../../../lib/media/picker'
import { import {
UserLocalPhotosModel, UserLocalPhotosModel,
PhotoIdentifier, PhotoIdentifier,
} from 'state/models/user-local-photos' } from 'state/models/user-local-photos'
import { import {compressIfNeeded} from 'lib/media/manip'
compressIfNeeded,
moveToPremanantPath,
scaleDownDimensions,
} from 'lib/images'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useStores, RootStoreModel} from 'state/index' import {useStores} from 'state/index'
import { import {
requestPhotoAccessIfNeeded, requestPhotoAccessIfNeeded,
requestCameraAccessIfNeeded, requestCameraAccessIfNeeded,
} from 'lib/permissions' } from 'lib/permissions'
import {
const MAX_WIDTH = 2000 POST_IMG_MAX_WIDTH,
const MAX_HEIGHT = 2000 POST_IMG_MAX_HEIGHT,
const MAX_SIZE = 1000000 POST_IMG_MAX_SIZE,
} from 'lib/constants'
const IMAGE_PARAMS = {
width: 2000,
height: 2000,
freeStyleCropEnabled: true,
}
export async function cropPhoto(
store: RootStoreModel,
path: string,
imgWidth = MAX_WIDTH,
imgHeight = MAX_HEIGHT,
) {
// choose target dimensions based on the original
// this causes the photo cropper to start with the full image "selected"
const {width, height} = scaleDownDimensions(
{width: imgWidth, height: imgHeight},
{width: MAX_WIDTH, height: MAX_HEIGHT},
)
const cropperRes = await openCropper(store, {
mediaType: 'photo',
path,
freeStyleCropEnabled: true,
width,
height,
})
const img = await compressIfNeeded(cropperRes, MAX_SIZE)
const permanentPath = await moveToPremanantPath(img.path)
return permanentPath
}
export const PhotoCarouselPicker = ({ export const PhotoCarouselPicker = ({
selectedPhotos, selectedPhotos,
@ -92,9 +59,11 @@ export const PhotoCarouselPicker = ({
} }
const cameraRes = await openCamera(store, { const cameraRes = await openCamera(store, {
mediaType: 'photo', mediaType: 'photo',
...IMAGE_PARAMS, width: POST_IMG_MAX_WIDTH,
height: POST_IMG_MAX_HEIGHT,
freeStyleCropEnabled: true,
}) })
const img = await compressIfNeeded(cameraRes, MAX_SIZE) const img = await compressIfNeeded(cameraRes, POST_IMG_MAX_SIZE)
onSelectPhotos([...selectedPhotos, img.path]) onSelectPhotos([...selectedPhotos, img.path])
} catch (err: any) { } catch (err: any) {
// ignore // ignore
@ -106,11 +75,15 @@ export const PhotoCarouselPicker = ({
async (item: PhotoIdentifier) => { async (item: PhotoIdentifier) => {
track('PhotoCarouselPicker:PhotoSelected') track('PhotoCarouselPicker:PhotoSelected')
try { try {
const imgPath = await cropPhoto( const imgPath = await cropAndCompressFlow(
store, store,
item.node.image.uri, item.node.image.uri,
item.node.image.width, {
item.node.image.height, width: item.node.image.width,
height: item.node.image.height,
},
{width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT},
POST_IMG_MAX_SIZE,
) )
onSelectPhotos([...selectedPhotos, imgPath]) onSelectPhotos([...selectedPhotos, imgPath])
} catch (err: any) { } catch (err: any) {
@ -132,24 +105,16 @@ export const PhotoCarouselPicker = ({
mediaType: 'photo', mediaType: 'photo',
}) })
const result = [] const result = []
for (const image of items) { for (const image of items) {
// choose target dimensions based on the original result.push(
// this causes the photo cropper to start with the full image "selected" await cropAndCompressFlow(
const {width, height} = scaleDownDimensions( store,
{width: image.width, height: image.height}, image.path,
{width: MAX_WIDTH, height: MAX_HEIGHT}, image,
{width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT},
POST_IMG_MAX_SIZE,
),
) )
const cropperRes = await openCropper(store, {
mediaType: 'photo',
path: image.path,
...IMAGE_PARAMS,
width,
height,
})
const finalImg = await compressIfNeeded(cropperRes, MAX_SIZE)
const permanentPath = await moveToPremanantPath(finalImg.path)
result.push(permanentPath)
} }
onSelectPhotos([...selectedPhotos, ...result]) onSelectPhotos([...selectedPhotos, ...result])
}, [track, store, selectedPhotos, onSelectPhotos]) }, [track, store, selectedPhotos, onSelectPhotos])

View File

@ -1,158 +1,10 @@
import React, {useCallback} from 'react' import React from 'react'
import {StyleSheet, TouchableOpacity, ScrollView} from 'react-native'
import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {
openPicker,
openCamera,
openCropper,
} from '../../util/images/image-crop-picker/ImageCropPicker'
import {compressIfNeeded, scaleDownDimensions} from 'lib/images'
import {usePalette} from 'lib/hooks/usePalette'
import {useStores, RootStoreModel} from 'state/index'
const MAX_WIDTH = 1000 // Not used on Web
const MAX_HEIGHT = 1000
const MAX_SIZE = 300000
const IMAGE_PARAMS = { export const PhotoCarouselPicker = (_opts: {
width: 1000,
height: 1000,
freeStyleCropEnabled: true,
}
export async function cropPhoto(
store: RootStoreModel,
path: string,
imgWidth = MAX_WIDTH,
imgHeight = MAX_HEIGHT,
) {
// choose target dimensions based on the original
// this causes the photo cropper to start with the full image "selected"
const {width, height} = scaleDownDimensions(
{width: imgWidth, height: imgHeight},
{width: MAX_WIDTH, height: MAX_HEIGHT},
)
const cropperRes = await openCropper(store, {
mediaType: 'photo',
path,
freeStyleCropEnabled: true,
width,
height,
})
const img = await compressIfNeeded(cropperRes, MAX_SIZE)
return img.path
}
export const PhotoCarouselPicker = ({
selectedPhotos,
onSelectPhotos,
}: {
selectedPhotos: string[] selectedPhotos: string[]
onSelectPhotos: (v: string[]) => void onSelectPhotos: (v: string[]) => void
}) => { }) => {
const pal = usePalette('default') return <></>
const store = useStores()
const handleOpenCamera = useCallback(async () => {
try {
const cameraRes = await openCamera(store, {
mediaType: 'photo',
...IMAGE_PARAMS,
})
const img = await compressIfNeeded(cameraRes, MAX_SIZE)
onSelectPhotos([...selectedPhotos, img.path])
} catch (err: any) {
// ignore
store.log.warn('Error using camera', err)
}
}, [store, selectedPhotos, onSelectPhotos])
const handleOpenGallery = useCallback(() => {
openPicker(store, {
multiple: true,
maxFiles: 4 - selectedPhotos.length,
mediaType: 'photo',
}).then(async items => {
const result = []
for (const image of items) {
// choose target dimensions based on the original
// this causes the photo cropper to start with the full image "selected"
const {width, height} = scaleDownDimensions(
{width: image.width, height: image.height},
{width: MAX_WIDTH, height: MAX_HEIGHT},
)
const cropperRes = await openCropper(store, {
mediaType: 'photo',
path: image.path,
freeStyleCropEnabled: true,
width,
height,
})
const finalImg = await compressIfNeeded(cropperRes, MAX_SIZE)
result.push(finalImg.path)
}
onSelectPhotos([...selectedPhotos, ...result])
})
}, [store, selectedPhotos, onSelectPhotos])
return (
<ScrollView
testID="photoCarouselPickerView"
horizontal
style={[pal.view, styles.photosContainer]}
keyboardShouldPersistTaps="always"
showsHorizontalScrollIndicator={false}>
<TouchableOpacity
testID="openCameraButton"
style={[styles.galleryButton, pal.border, styles.photo]}
onPress={handleOpenCamera}>
<FontAwesomeIcon
icon="camera"
size={24}
style={pal.link as FontAwesomeIconStyle}
/>
</TouchableOpacity>
<TouchableOpacity
testID="openGalleryButton"
style={[styles.galleryButton, pal.border, styles.photo]}
onPress={handleOpenGallery}>
<FontAwesomeIcon
icon="image"
style={pal.link as FontAwesomeIconStyle}
size={24}
/>
</TouchableOpacity>
</ScrollView>
)
} }
const styles = StyleSheet.create({
photosContainer: {
width: '100%',
maxHeight: 96,
padding: 8,
overflow: 'hidden',
},
galleryButton: {
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
},
photoButton: {
width: 75,
height: 75,
marginRight: 8,
borderWidth: 1,
borderRadius: 16,
},
photo: {
width: 75,
height: 75,
marginRight: 8,
borderRadius: 16,
},
})

View File

@ -4,7 +4,7 @@ import {observer} from 'mobx-react-lite'
import ImageView from './ImageViewing' import ImageView from './ImageViewing'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import * as models from 'state/models/shell-ui' import * as models from 'state/models/shell-ui'
import {saveImageModal} from 'lib/images' import {saveImageModal} from 'lib/media/manip'
import {ImageSource} from './ImageViewing/@types' import {ImageSource} from './ImageViewing/@types'
export const Lightbox = observer(function Lightbox() { export const Lightbox = observer(function Lightbox() {

View File

@ -8,7 +8,7 @@ import {
} from 'react-native' } from 'react-native'
import LinearGradient from 'react-native-linear-gradient' import LinearGradient from 'react-native-linear-gradient'
import {ScrollView, TextInput} from './util' import {ScrollView, TextInput} from './util'
import {PickedMedia} from '../util/images/image-crop-picker/ImageCropPicker' import {PickedMedia} from '../../../lib/media/picker'
import {Text} from '../util/text/Text' import {Text} from '../util/text/Text'
import {ErrorMessage} from '../util/error/ErrorMessage' import {ErrorMessage} from '../util/error/ErrorMessage'
import {useStores} from 'state/index' import {useStores} from 'state/index'
@ -16,7 +16,7 @@ import {ProfileViewModel} from 'state/models/profile-view'
import {s, colors, gradients} from 'lib/styles' import {s, colors, gradients} from 'lib/styles'
import {enforceLen} from 'lib/strings/helpers' import {enforceLen} from 'lib/strings/helpers'
import {MAX_DISPLAY_NAME, MAX_DESCRIPTION} from 'lib/constants' import {MAX_DISPLAY_NAME, MAX_DESCRIPTION} from 'lib/constants'
import {compressIfNeeded} from 'lib/images' import {compressIfNeeded} from 'lib/media/manip'
import {UserBanner} from '../util/UserBanner' import {UserBanner} from '../util/UserBanner'
import {UserAvatar} from '../util/UserAvatar' import {UserAvatar} from '../util/UserAvatar'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'

View File

@ -21,7 +21,10 @@ export const Modal = observer(function Modal() {
return null return null
} }
const onClose = () => { const onPressMask = () => {
if (store.shell.activeModal?.name === 'crop-image') {
return // dont close on mask presses during crop
}
store.shell.closeModal() store.shell.closeModal()
} }
const onInnerPress = () => { const onInnerPress = () => {
@ -70,7 +73,7 @@ export const Modal = observer(function Modal() {
} }
return ( return (
<TouchableWithoutFeedback onPress={onClose}> <TouchableWithoutFeedback onPress={onPressMask}>
<View style={styles.mask}> <View style={styles.mask}>
<TouchableWithoutFeedback onPress={onInnerPress}> <TouchableWithoutFeedback onPress={onInnerPress}>
<View style={[styles.container, pal.view]}>{element}</View> <View style={[styles.container, pal.view]}>{element}</View>

View File

@ -38,6 +38,7 @@ export function Component({
const pal = usePalette('default') const pal = usePalette('default')
const [as, setAs] = React.useState<AspectRatio>(AspectRatio.Square) const [as, setAs] = React.useState<AspectRatio>(AspectRatio.Square)
const [scale, setScale] = React.useState<number>(1) const [scale, setScale] = React.useState<number>(1)
const editorRef = React.useRef<ImageEditor>(null)
const doSetAs = (v: AspectRatio) => () => setAs(v) const doSetAs = (v: AspectRatio) => () => setAs(v)
@ -46,8 +47,20 @@ export function Component({
store.shell.closeModal() store.shell.closeModal()
} }
const onPressDone = () => { const onPressDone = () => {
console.log('TODO') const canvas = editorRef.current?.getImageScaledToCanvas()
onSelect(undefined) // TODO if (canvas) {
const dataUri = canvas.toDataURL('image/jpeg')
onSelect({
mediaType: 'photo',
path: dataUri,
mime: 'image/jpeg',
size: Math.round((dataUri.length * 3) / 4), // very rough estimate
width: DIMS[as].width,
height: DIMS[as].height,
})
} else {
onSelect(undefined)
}
store.shell.closeModal() store.shell.closeModal()
} }
@ -61,13 +74,15 @@ export function Component({
} }
return ( return (
<View> <View>
<View style={[styles.cropper, cropperStyle]}> <View style={[styles.cropper, pal.borderDark, cropperStyle]}>
<ImageEditor <ImageEditor
ref={editorRef}
style={styles.imageEditor} style={styles.imageEditor}
image={uri} image={uri}
width={DIMS[as].width} width={DIMS[as].width}
height={DIMS[as].height} height={DIMS[as].height}
scale={scale} scale={scale}
border={0}
/> />
</View> </View>
<View style={styles.ctrls}> <View style={styles.ctrls}>
@ -126,6 +141,9 @@ const styles = StyleSheet.create({
cropper: { cropper: {
marginLeft: 'auto', marginLeft: 'auto',
marginRight: 'auto', marginRight: 'auto',
borderWidth: 1,
borderRadius: 4,
overflow: 'hidden',
}, },
cropperSquare: { cropperSquare: {
width: 400, width: 400,

View File

@ -13,7 +13,7 @@ import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
import {ImagesLightbox} from 'state/models/shell-ui' import {ImagesLightbox} from 'state/models/shell-ui'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {saveImageModal} from 'lib/images' import {saveImageModal} from 'lib/media/manip'
import YoutubeEmbed from './YoutubeEmbed' import YoutubeEmbed from './YoutubeEmbed'
import ExternalLinkEmbed from './ExternalLinkEmbed' import ExternalLinkEmbed from './ExternalLinkEmbed'
import {getYoutubeVideoId} from 'lib/strings/url-helpers' import {getYoutubeVideoId} from 'lib/strings/url-helpers'

View File

@ -9,7 +9,7 @@ import {
openCropper, openCropper,
openPicker, openPicker,
PickedMedia, PickedMedia,
} from './images/image-crop-picker/ImageCropPicker' } from '../../../lib/media/picker'
import { import {
requestPhotoAccessIfNeeded, requestPhotoAccessIfNeeded,
requestCameraAccessIfNeeded, requestCameraAccessIfNeeded,

View File

@ -10,7 +10,7 @@ import {
openCropper, openCropper,
openPicker, openPicker,
PickedMedia, PickedMedia,
} from './images/image-crop-picker/ImageCropPicker' } from '../../../lib/media/picker'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import { import {
requestPhotoAccessIfNeeded, requestPhotoAccessIfNeeded,