Implement image uploading in the web composer
This commit is contained in:
parent
0f293ecf95
commit
4182edfd7e
19 changed files with 338 additions and 281 deletions
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
})
|
||||
}
|
|
@ -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
141
src/lib/media/picker.tsx
Normal 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
|
||||
}
|
143
src/lib/media/picker.web.tsx
Normal file
143
src/lib/media/picker.web.tsx
Normal 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
31
src/lib/media/types.ts
Normal 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
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue