diff options
author | Michael Staub <michael.staub@brightmachines.com> | 2023-02-23 16:34:25 -0800 |
---|---|---|
committer | Michael Staub <michael.staub@brightmachines.com> | 2023-02-23 16:34:25 -0800 |
commit | 693cbb9f18eeec48ea6ed3eb03ff3a96ca6ec7dc (patch) | |
tree | 192494fe0751aa279209f447587c311efcd33668 /src/lib | |
parent | 23f07d8def1f4384022c7fecd0d7eac0ba8b2efc (diff) | |
parent | bbd0b03a46b1087ecca17219441d060c2be69de2 (diff) | |
download | voidsky-693cbb9f18eeec48ea6ed3eb03ff3a96ca6ec7dc.tar.zst |
Merge branch 'rnw' of github.com:bluesky-social/social-app into rnw
Diffstat (limited to 'src/lib')
-rw-r--r-- | src/lib/api/index.ts | 52 | ||||
-rw-r--r-- | src/lib/constants.ts | 4 | ||||
-rw-r--r-- | src/lib/media/manip.ts (renamed from src/lib/images.ts) | 18 | ||||
-rw-r--r-- | src/lib/media/manip.web.ts (renamed from src/lib/images.web.ts) | 30 | ||||
-rw-r--r-- | src/lib/media/picker.tsx | 141 | ||||
-rw-r--r-- | src/lib/media/picker.web.tsx | 144 | ||||
-rw-r--r-- | src/lib/media/types.ts | 31 |
7 files changed, 403 insertions, 17 deletions
diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index d800c376c..ae156928e 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -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 +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 2a3043c06..72cba0b63 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -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 diff --git a/src/lib/images.ts b/src/lib/media/manip.ts index 609e03bda..e44ee3907 100644 --- a/src/lib/images.ts +++ b/src/lib/media/manip.ts @@ -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, + ) + }) +} diff --git a/src/lib/images.web.ts b/src/lib/media/manip.web.ts index 4b6d93af2..e617d01af 100644 --- a/src/lib/images.web.ts +++ b/src/lib/media/manip.web.ts @@ -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} +} diff --git a/src/lib/media/picker.tsx b/src/lib/media/picker.tsx new file mode 100644 index 000000000..940366035 --- /dev/null +++ b/src/lib/media/picker.tsx @@ -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 +} diff --git a/src/lib/media/picker.web.tsx b/src/lib/media/picker.web.tsx new file mode 100644 index 000000000..746feaedd --- /dev/null +++ b/src/lib/media/picker.web.tsx @@ -0,0 +1,144 @@ +/// <reference lib="dom" /> + +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({ + name: 'crop-image', + uri: opts.path, + onSelect: (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(';')) +} diff --git a/src/lib/media/types.ts b/src/lib/media/types.ts new file mode 100644 index 000000000..3197b4d3e --- /dev/null +++ b/src/lib/media/types.ts @@ -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 +} |