about summary refs log tree commit diff
path: root/src/lib
diff options
context:
space:
mode:
authorMichael Staub <michael.staub@brightmachines.com>2023-02-23 16:34:25 -0800
committerMichael Staub <michael.staub@brightmachines.com>2023-02-23 16:34:25 -0800
commit693cbb9f18eeec48ea6ed3eb03ff3a96ca6ec7dc (patch)
tree192494fe0751aa279209f447587c311efcd33668 /src/lib
parent23f07d8def1f4384022c7fecd0d7eac0ba8b2efc (diff)
parentbbd0b03a46b1087ecca17219441d060c2be69de2 (diff)
downloadvoidsky-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.ts52
-rw-r--r--src/lib/constants.ts4
-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.tsx141
-rw-r--r--src/lib/media/picker.web.tsx144
-rw-r--r--src/lib/media/types.ts31
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
+}