Implement image uploading in the web composer

This commit is contained in:
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,11 +1,16 @@
import {AppBskyEmbedImages, AppBskyEmbedExternal} from '@atproto/api'
import {
AppBskyEmbedImages,
AppBskyEmbedExternal,
ComAtprotoBlobUpload,
} from '@atproto/api'
import {AtUri} from '../../third-party/uri'
import {RootStoreModel} from 'state/models/root-store'
import {extractEntities} from 'lib/strings/rich-text-detection'
import {isNetworkError} from 'lib/strings/errors'
import {LinkMeta} from '../link-meta/link-meta'
import {Image} from '../images'
import {Image} from '../media/manip'
import {RichText} from '../strings/rich-text'
import {isWeb} from 'platform/detection'
export interface ExternalEmbedDraft {
uri: string
@ -27,6 +32,25 @@ export async function resolveName(store: RootStoreModel, didOrHandle: string) {
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(
store: RootStoreModel,
rawText: string,
@ -61,10 +85,7 @@ export async function post(
let i = 1
for (const image of images) {
onStateChange?.(`Uploading image #${i++}...`)
const res = await store.api.com.atproto.blob.upload(
image, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts
{encoding: 'image/jpeg'},
)
const res = await uploadBlob(store, image, 'image/jpeg')
embed.images.push({
image: {
cid: res.data.cid,
@ -94,9 +115,10 @@ export async function post(
)
}
if (encoding) {
const thumbUploadRes = await store.api.com.atproto.blob.upload(
extLink.localThumb.path, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts
{encoding},
const thumbUploadRes = await uploadBlob(
store,
extLink.localThumb.path,
encoding,
)
thumb = {
cid: thumbUploadRes.data.cid,
@ -199,3 +221,15 @@ export async function unfollow(store: RootStoreModel, followUri: string) {
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(
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 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 uuid from 'react-native-uuid'
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}
}
export const saveImageModal = async ({uri}: {uri: string}) => {
export async function saveImageModal({uri}: {uri: string}) {
const downloadResponse = await RNFetchBlob.config({
fileCache: true,
}).fetch('GET', uri)
@ -153,7 +153,7 @@ export const saveImageModal = async ({uri}: {uri: string}) => {
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.
Relevant: IOS bug when trying to open a second time:
@ -164,3 +164,15 @@ export const moveToPremanantPath = async (path: string) => {
RNFS.moveFile(path, 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(
_img: Image,
_maxSize: number,
img: Image,
maxSize: number,
): Promise<Image> {
// TODO
throw new Error('TODO')
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 {
@ -62,7 +67,22 @@ export function scaleDownDimensions(dim: Dim, max: Dim): Dim {
return {width: dim.width * hScale, height: dim.height * hScale}
}
export const saveImageModal = async (_opts: {uri: string}) => {
export async function saveImageModal(_opts: {uri: string}) {
// 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}
}

141
src/lib/media/picker.tsx Normal file
View file

@ -0,0 +1,141 @@
import {
openPicker as openPickerFn,
openCamera as openCameraFn,
openCropper as openCropperFn,
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'
/**
* NOTE
* These methods all include the RootStoreModel as the first param
* because the web versions require it. The signatures have to remain
* equivalent between the different forms, but the store param is not
* used here.
* -prf
*/
export async function openPicker(
_store: RootStoreModel,
opts: PickerOpts,
): Promise<PickedMedia[]> {
const mediaType = opts.mediaType || 'photo'
const items = await openPickerFn({
mediaType,
multiple: opts.multiple,
maxFiles: opts.maxFiles,
})
const toMedia = (item: ImageOrVideo) => ({
mediaType,
path: item.path,
mime: item.mime,
size: item.size,
width: item.width,
height: item.height,
})
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 openCameraFn({
mediaType,
width: opts.width,
height: opts.height,
freeStyleCropEnabled: opts.freeStyleCropEnabled,
cropperCircleOverlay: opts.cropperCircleOverlay,
cropping: true,
forceJpg: true, // ios only
compressImageQuality: 1.0,
})
return {
mediaType,
path: item.path,
mime: item.mime,
size: item.size,
width: item.width,
height: item.height,
}
}
export async function openCropper(
_store: RootStoreModel,
opts: CropperOpts,
): Promise<PickedMedia> {
const mediaType = opts.mediaType || 'photo'
const item = await openCropperFn({
path: opts.path,
mediaType: opts.mediaType || 'photo',
width: opts.width,
height: opts.height,
freeStyleCropEnabled: opts.freeStyleCropEnabled,
cropperCircleOverlay: opts.cropperCircleOverlay,
forceJpg: true, // ios only
compressImageQuality: 1.0,
})
return {
mediaType,
path: item.path,
mime: item.mime,
size: item.size,
width: item.width,
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

@ -0,0 +1,143 @@
/// <reference lib="dom" />
import {CropImageModal} from 'state/models/shell-ui'
import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types'
export type {PickedMedia} from './types'
import {RootStoreModel} from 'state/index'
import {
scaleDownDimensions,
getImageDim,
Dim,
compressIfNeeded,
moveToPremanantPath,
} from 'lib/media/manip'
interface PickedFile {
uri: string
path: string
size: number
}
export async function openPicker(
_store: RootStoreModel,
opts: PickerOpts,
): Promise<PickedMedia[]> {
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,
width: dim.width,
height: dim.height,
},
]
}
export async function openCamera(
_store: RootStoreModel,
_opts: CameraOpts,
): Promise<PickedMedia> {
// const mediaType = opts.mediaType || 'photo' TODO
throw new Error('TODO')
}
export async function openCropper(
store: RootStoreModel,
opts: CropperOpts,
): Promise<PickedMedia> {
// TODO handle more opts
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> {
return new Promise((resolve, reject) => {
var input = document.createElement('input')
input.type = 'file'
input.accept = opts.mediaType === 'photo' ? 'image/*' : '*/*'
input.onchange = e => {
const target = e.target as HTMLInputElement
const file = target?.files?.[0]
if (!file) {
return reject(new Error('Canceled'))
}
var reader = new FileReader()
reader.readAsDataURL(file)
reader.onload = readerEvent => {
if (!readerEvent.target) {
return reject(new Error('Canceled'))
}
resolve({
uri: readerEvent.target.result as string,
path: file.name,
size: file.size,
})
}
}
input.click()
})
}
function extractDataUriMime(uri: string): string {
return uri.substring(uri.indexOf(':') + 1, uri.indexOf(';'))
}

31
src/lib/media/types.ts Normal file
View file

@ -0,0 +1,31 @@
export interface PickerOpts {
mediaType?: 'photo'
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
}