Implement image uploading in the web composer
parent
0f293ecf95
commit
4182edfd7e
|
@ -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'
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
|
@ -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}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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(';'))
|
||||||
|
}
|
|
@ -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,
|
||||||
|
|
|
@ -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])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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])
|
||||||
|
|
|
@ -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,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
Loading…
Reference in New Issue