Split image cropping into secondary step (#473)
* Split image cropping into secondary step * Use ImageModel and GalleryModel * Add fix for pasting image URLs * Move models to state folder * Fix things that broke after rebase * Latest -- has image display bug * Remove contentFit * Fix iOS display in gallery * Tuneup the api signatures and implement compress/resize on web * Fix await * Lint fix and remove unused function * Fix android image pathing * Fix external embed x button on android * Remove min-height from composer (no longer useful and was mispositioning the composer on android) * Fix e2e picker --------- Co-authored-by: Paul Frazee <pfrazee@gmail.com>zio/stable
parent
91fadadb58
commit
2509290fdd
|
@ -82,6 +82,9 @@ web-build/
|
||||||
# Temporary files created by Metro to check the health of the file watcher
|
# Temporary files created by Metro to check the health of the file watcher
|
||||||
.metro-health-check*
|
.metro-health-check*
|
||||||
|
|
||||||
|
# VSCode
|
||||||
|
.vscode
|
||||||
|
|
||||||
# gitignore and github actions
|
# gitignore and github actions
|
||||||
!.gitignore
|
!.gitignore
|
||||||
!.github
|
!.github
|
||||||
|
|
|
@ -10,8 +10,8 @@ import {
|
||||||
import {AtUri} from '@atproto/api'
|
import {AtUri} from '@atproto/api'
|
||||||
import {RootStoreModel} from 'state/models/root-store'
|
import {RootStoreModel} from 'state/models/root-store'
|
||||||
import {isNetworkError} from 'lib/strings/errors'
|
import {isNetworkError} from 'lib/strings/errors'
|
||||||
|
import {Image} from 'lib/media/types'
|
||||||
import {LinkMeta} from '../link-meta/link-meta'
|
import {LinkMeta} from '../link-meta/link-meta'
|
||||||
import {Image} from '../media/manip'
|
|
||||||
import {isWeb} from 'platform/detection'
|
import {isWeb} from 'platform/detection'
|
||||||
|
|
||||||
export interface ExternalEmbedDraft {
|
export interface ExternalEmbedDraft {
|
||||||
|
|
|
@ -161,6 +161,8 @@ export function SUGGESTED_FOLLOWS(serviceUrl: string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const POST_IMG_MAX_WIDTH = 2000
|
export const POST_IMG_MAX = {
|
||||||
export const POST_IMG_MAX_HEIGHT = 2000
|
width: 2000,
|
||||||
export const POST_IMG_MAX_SIZE = 1000000
|
height: 2000,
|
||||||
|
size: 1000000,
|
||||||
|
}
|
||||||
|
|
|
@ -1,13 +1,77 @@
|
||||||
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 {Image as RNImage, Share} from 'react-native'
|
import {Image as RNImage, Share} from 'react-native'
|
||||||
|
import {Image} from 'react-native-image-crop-picker'
|
||||||
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'
|
||||||
|
import {Dimensions} from './types'
|
||||||
|
import {POST_IMG_MAX} from 'lib/constants'
|
||||||
|
import {isAndroid} from 'platform/detection'
|
||||||
|
|
||||||
export interface Dim {
|
export async function compressAndResizeImageForPost(
|
||||||
width: number
|
image: Image,
|
||||||
height: number
|
): Promise<Image> {
|
||||||
|
const uri = `file://${image.path}`
|
||||||
|
let resized: Omit<Image, 'mime'>
|
||||||
|
|
||||||
|
for (let i = 0; i < 9; i++) {
|
||||||
|
const quality = 100 - i * 10
|
||||||
|
|
||||||
|
try {
|
||||||
|
resized = await ImageResizer.createResizedImage(
|
||||||
|
uri,
|
||||||
|
POST_IMG_MAX.width,
|
||||||
|
POST_IMG_MAX.height,
|
||||||
|
'JPEG',
|
||||||
|
quality,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
{mode: 'cover'},
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(`Failed to resize: ${err}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resized.size < POST_IMG_MAX.size) {
|
||||||
|
const path = await moveToPermanentPath(resized.path)
|
||||||
|
|
||||||
|
return {
|
||||||
|
path,
|
||||||
|
mime: 'image/jpeg',
|
||||||
|
size: resized.size,
|
||||||
|
height: resized.height,
|
||||||
|
width: resized.width,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`This image is too big! We couldn't compress it down to ${POST_IMG_MAX.size} bytes`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function compressIfNeeded(
|
||||||
|
img: Image,
|
||||||
|
maxSize: number = 1000000,
|
||||||
|
): Promise<Image> {
|
||||||
|
const origUri = `file://${img.path}`
|
||||||
|
if (img.size < maxSize) {
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
const resizedImage = await doResize(origUri, {
|
||||||
|
width: img.width,
|
||||||
|
height: img.height,
|
||||||
|
mode: 'stretch',
|
||||||
|
maxSize,
|
||||||
|
})
|
||||||
|
const finalImageMovedPath = await moveToPermanentPath(resizedImage.path)
|
||||||
|
const finalImg = {
|
||||||
|
...resizedImage,
|
||||||
|
path: finalImageMovedPath,
|
||||||
|
}
|
||||||
|
return finalImg
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DownloadAndResizeOpts {
|
export interface DownloadAndResizeOpts {
|
||||||
|
@ -19,14 +83,6 @@ export interface DownloadAndResizeOpts {
|
||||||
timeout: number
|
timeout: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Image {
|
|
||||||
path: string
|
|
||||||
mime: string
|
|
||||||
size: number
|
|
||||||
width: number
|
|
||||||
height: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function downloadAndResize(opts: DownloadAndResizeOpts) {
|
export async function downloadAndResize(opts: DownloadAndResizeOpts) {
|
||||||
let appendExt = 'jpeg'
|
let appendExt = 'jpeg'
|
||||||
try {
|
try {
|
||||||
|
@ -55,7 +111,7 @@ export async function downloadAndResize(opts: DownloadAndResizeOpts) {
|
||||||
localUri = `file://${localUri}`
|
localUri = `file://${localUri}`
|
||||||
}
|
}
|
||||||
|
|
||||||
return await resize(localUri, opts)
|
return await doResize(localUri, opts)
|
||||||
} finally {
|
} finally {
|
||||||
if (downloadRes) {
|
if (downloadRes) {
|
||||||
downloadRes.flush()
|
downloadRes.flush()
|
||||||
|
@ -63,79 +119,6 @@ export async function downloadAndResize(opts: DownloadAndResizeOpts) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResizeOpts {
|
|
||||||
width: number
|
|
||||||
height: number
|
|
||||||
mode: 'contain' | 'cover' | 'stretch'
|
|
||||||
maxSize: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resize(
|
|
||||||
localUri: string,
|
|
||||||
opts: ResizeOpts,
|
|
||||||
): Promise<Image> {
|
|
||||||
for (let i = 0; i < 9; i++) {
|
|
||||||
const quality = 100 - i * 10
|
|
||||||
const resizeRes = await ImageResizer.createResizedImage(
|
|
||||||
localUri,
|
|
||||||
opts.width,
|
|
||||||
opts.height,
|
|
||||||
'JPEG',
|
|
||||||
quality,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
{mode: opts.mode},
|
|
||||||
)
|
|
||||||
if (resizeRes.size < opts.maxSize) {
|
|
||||||
return {
|
|
||||||
path: resizeRes.path,
|
|
||||||
mime: 'image/jpeg',
|
|
||||||
size: resizeRes.size,
|
|
||||||
width: resizeRes.width,
|
|
||||||
height: resizeRes.height,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error(
|
|
||||||
`This image is too big! We couldn't compress it down to ${opts.maxSize} bytes`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function compressIfNeeded(
|
|
||||||
img: Image,
|
|
||||||
maxSize: number,
|
|
||||||
): Promise<Image> {
|
|
||||||
const origUri = `file://${img.path}`
|
|
||||||
if (img.size < maxSize) {
|
|
||||||
return img
|
|
||||||
}
|
|
||||||
const resizedImage = await resize(origUri, {
|
|
||||||
width: img.width,
|
|
||||||
height: img.height,
|
|
||||||
mode: 'stretch',
|
|
||||||
maxSize,
|
|
||||||
})
|
|
||||||
const finalImageMovedPath = await moveToPremanantPath(resizedImage.path)
|
|
||||||
const finalImg = {
|
|
||||||
...resizedImage,
|
|
||||||
path: finalImageMovedPath,
|
|
||||||
}
|
|
||||||
return finalImg
|
|
||||||
}
|
|
||||||
|
|
||||||
export function scaleDownDimensions(dim: Dim, max: Dim): Dim {
|
|
||||||
if (dim.width < max.width && dim.height < max.height) {
|
|
||||||
return dim
|
|
||||||
}
|
|
||||||
let wScale = dim.width > max.width ? max.width / dim.width : 1
|
|
||||||
let hScale = dim.height > max.height ? max.height / dim.height : 1
|
|
||||||
if (wScale < hScale) {
|
|
||||||
return {width: dim.width * wScale, height: dim.height * wScale}
|
|
||||||
}
|
|
||||||
return {width: dim.width * hScale, height: dim.height * hScale}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function saveImageModal({uri}: {uri: string}) {
|
export async function saveImageModal({uri}: {uri: string}) {
|
||||||
const downloadResponse = await RNFetchBlob.config({
|
const downloadResponse = await RNFetchBlob.config({
|
||||||
fileCache: true,
|
fileCache: true,
|
||||||
|
@ -154,19 +137,7 @@ export async function saveImageModal({uri}: {uri: string}) {
|
||||||
RNFS.unlink(imagePath)
|
RNFS.unlink(imagePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function moveToPremanantPath(path: string) {
|
export function getImageDim(path: string): Promise<Dimensions> {
|
||||||
/*
|
|
||||||
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:
|
|
||||||
https://github.com/ivpusic/react-native-image-crop-picker/issues/1199
|
|
||||||
*/
|
|
||||||
const filename = uuid.v4()
|
|
||||||
const destinationPath = `${RNFS.TemporaryDirectoryPath}/${filename}`
|
|
||||||
RNFS.moveFile(path, destinationPath)
|
|
||||||
return destinationPath
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getImageDim(path: string): Promise<Dim> {
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
RNImage.getSize(
|
RNImage.getSize(
|
||||||
path,
|
path,
|
||||||
|
@ -177,3 +148,64 @@ export function getImageDim(path: string): Promise<Dim> {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// internal methods
|
||||||
|
// =
|
||||||
|
|
||||||
|
interface DoResizeOpts {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
mode: 'contain' | 'cover' | 'stretch'
|
||||||
|
maxSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doResize(localUri: string, opts: DoResizeOpts): Promise<Image> {
|
||||||
|
for (let i = 0; i < 9; i++) {
|
||||||
|
const quality = 100 - i * 10
|
||||||
|
const resizeRes = await ImageResizer.createResizedImage(
|
||||||
|
localUri,
|
||||||
|
opts.width,
|
||||||
|
opts.height,
|
||||||
|
'JPEG',
|
||||||
|
quality,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
{mode: opts.mode},
|
||||||
|
)
|
||||||
|
if (resizeRes.size < opts.maxSize) {
|
||||||
|
return {
|
||||||
|
path: normalizePath(resizeRes.path),
|
||||||
|
mime: 'image/jpeg',
|
||||||
|
size: resizeRes.size,
|
||||||
|
width: resizeRes.width,
|
||||||
|
height: resizeRes.height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`This image is too big! We couldn't compress it down to ${opts.maxSize} bytes`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveToPermanentPath(path: string): Promise<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:
|
||||||
|
https://github.com/ivpusic/react-native-image-crop-picker/issues/1199
|
||||||
|
*/
|
||||||
|
const filename = uuid.v4()
|
||||||
|
|
||||||
|
const destinationPath = `${RNFS.TemporaryDirectoryPath}/${filename}`
|
||||||
|
await RNFS.moveFile(path, destinationPath)
|
||||||
|
return normalizePath(destinationPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePath(str: string): string {
|
||||||
|
if (isAndroid) {
|
||||||
|
if (!str.startsWith('file://')) {
|
||||||
|
return `file://${str}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
|
|
@ -1,6 +1,40 @@
|
||||||
// import {Share} from 'react-native'
|
import {Dimensions} from './types'
|
||||||
// import * as Toast from 'view/com/util/Toast'
|
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||||
import {extractDataUriMime, getDataUriSize} from './util'
|
import {getDataUriSize, blobToDataUri} from './util'
|
||||||
|
import {POST_IMG_MAX} from 'lib/constants'
|
||||||
|
|
||||||
|
export async function compressAndResizeImageForPost({
|
||||||
|
path,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
}: {
|
||||||
|
path: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}): Promise<RNImage> {
|
||||||
|
// Compression is handled in `doResize` via `quality`
|
||||||
|
return await doResize(path, {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
maxSize: POST_IMG_MAX.size,
|
||||||
|
mode: 'stretch',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function compressIfNeeded(
|
||||||
|
img: RNImage,
|
||||||
|
maxSize: number,
|
||||||
|
): Promise<RNImage> {
|
||||||
|
if (img.size < maxSize) {
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
return await doResize(img.path, {
|
||||||
|
width: img.width,
|
||||||
|
height: img.height,
|
||||||
|
mode: 'stretch',
|
||||||
|
maxSize,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export interface DownloadAndResizeOpts {
|
export interface DownloadAndResizeOpts {
|
||||||
uri: string
|
uri: string
|
||||||
|
@ -11,14 +45,6 @@ export interface DownloadAndResizeOpts {
|
||||||
timeout: number
|
timeout: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Image {
|
|
||||||
path: string
|
|
||||||
mime: string
|
|
||||||
size: number
|
|
||||||
width: number
|
|
||||||
height: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function downloadAndResize(opts: DownloadAndResizeOpts) {
|
export async function downloadAndResize(opts: DownloadAndResizeOpts) {
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const to = setTimeout(() => controller.abort(), opts.timeout || 5e3)
|
const to = setTimeout(() => controller.abort(), opts.timeout || 5e3)
|
||||||
|
@ -27,58 +53,7 @@ export async function downloadAndResize(opts: DownloadAndResizeOpts) {
|
||||||
clearTimeout(to)
|
clearTimeout(to)
|
||||||
|
|
||||||
const dataUri = await blobToDataUri(resBody)
|
const dataUri = await blobToDataUri(resBody)
|
||||||
return await resize(dataUri, opts)
|
return await doResize(dataUri, opts)
|
||||||
}
|
|
||||||
|
|
||||||
export interface ResizeOpts {
|
|
||||||
width: number
|
|
||||||
height: number
|
|
||||||
mode: 'contain' | 'cover' | 'stretch'
|
|
||||||
maxSize: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resize(
|
|
||||||
dataUri: string,
|
|
||||||
_opts: ResizeOpts,
|
|
||||||
): Promise<Image> {
|
|
||||||
const dim = await getImageDim(dataUri)
|
|
||||||
// TODO -- need to resize
|
|
||||||
return {
|
|
||||||
path: dataUri,
|
|
||||||
mime: extractDataUriMime(dataUri),
|
|
||||||
size: getDataUriSize(dataUri),
|
|
||||||
width: dim.width,
|
|
||||||
height: dim.height,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function compressIfNeeded(
|
|
||||||
img: Image,
|
|
||||||
maxSize: number,
|
|
||||||
): Promise<Image> {
|
|
||||||
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 {
|
|
||||||
width: number
|
|
||||||
height: number
|
|
||||||
}
|
|
||||||
export function scaleDownDimensions(dim: Dim, max: Dim): Dim {
|
|
||||||
if (dim.width < max.width && dim.height < max.height) {
|
|
||||||
return dim
|
|
||||||
}
|
|
||||||
let wScale = dim.width > max.width ? max.width / dim.width : 1
|
|
||||||
let hScale = dim.height > max.height ? max.height / dim.height : 1
|
|
||||||
if (wScale < hScale) {
|
|
||||||
return {width: dim.width * wScale, height: dim.height * wScale}
|
|
||||||
}
|
|
||||||
return {width: dim.width * hScale, height: dim.height * hScale}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveImageModal(_opts: {uri: string}) {
|
export async function saveImageModal(_opts: {uri: string}) {
|
||||||
|
@ -86,11 +61,7 @@ export async function saveImageModal(_opts: {uri: string}) {
|
||||||
throw new Error('TODO')
|
throw new Error('TODO')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function moveToPremanantPath(path: string) {
|
export async function getImageDim(path: string): Promise<Dimensions> {
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getImageDim(path: string): Promise<Dim> {
|
|
||||||
var img = document.createElement('img')
|
var img = document.createElement('img')
|
||||||
const promise = new Promise((resolve, reject) => {
|
const promise = new Promise((resolve, reject) => {
|
||||||
img.onload = resolve
|
img.onload = resolve
|
||||||
|
@ -101,17 +72,82 @@ export async function getImageDim(path: string): Promise<Dim> {
|
||||||
return {width: img.width, height: img.height}
|
return {width: img.width, height: img.height}
|
||||||
}
|
}
|
||||||
|
|
||||||
function blobToDataUri(blob: Blob): Promise<string> {
|
// internal methods
|
||||||
|
// =
|
||||||
|
|
||||||
|
interface DoResizeOpts {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
mode: 'contain' | 'cover' | 'stretch'
|
||||||
|
maxSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doResize(dataUri: string, opts: DoResizeOpts): Promise<RNImage> {
|
||||||
|
let newDataUri
|
||||||
|
|
||||||
|
for (let i = 0; i <= 10; i++) {
|
||||||
|
newDataUri = await createResizedImage(dataUri, {
|
||||||
|
width: opts.width,
|
||||||
|
height: opts.height,
|
||||||
|
quality: 1 - i * 0.1,
|
||||||
|
mode: opts.mode,
|
||||||
|
})
|
||||||
|
if (getDataUriSize(newDataUri) < opts.maxSize) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!newDataUri) {
|
||||||
|
throw new Error('Failed to compress image')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
path: newDataUri,
|
||||||
|
mime: 'image/jpeg',
|
||||||
|
size: getDataUriSize(newDataUri),
|
||||||
|
width: opts.width,
|
||||||
|
height: opts.height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createResizedImage(
|
||||||
|
dataUri: string,
|
||||||
|
{
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
quality,
|
||||||
|
mode,
|
||||||
|
}: {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
quality: number
|
||||||
|
mode: 'contain' | 'cover' | 'stretch'
|
||||||
|
},
|
||||||
|
): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const reader = new FileReader()
|
const img = document.createElement('img')
|
||||||
reader.onloadend = () => {
|
img.addEventListener('load', () => {
|
||||||
if (typeof reader.result === 'string') {
|
const canvas = document.createElement('canvas')
|
||||||
resolve(reader.result)
|
const ctx = canvas.getContext('2d')
|
||||||
} else {
|
if (!ctx) {
|
||||||
reject(new Error('Failed to read blob'))
|
return reject(new Error('Failed to resize image'))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
canvas.width = width
|
||||||
|
canvas.height = height
|
||||||
|
|
||||||
|
let scale = 1
|
||||||
|
if (mode === 'cover') {
|
||||||
|
scale = img.width < img.height ? width / img.width : height / img.height
|
||||||
|
} else if (mode === 'contain') {
|
||||||
|
scale = img.width > img.height ? width / img.width : height / img.height
|
||||||
}
|
}
|
||||||
reader.onerror = reject
|
let w = img.width * scale
|
||||||
reader.readAsDataURL(blob)
|
let h = img.height * scale
|
||||||
|
let x = (width - w) / 2
|
||||||
|
let y = (height - h) / 2
|
||||||
|
|
||||||
|
ctx.drawImage(img, x, y, w, h)
|
||||||
|
resolve(canvas.toDataURL('image/jpeg', quality))
|
||||||
|
})
|
||||||
|
img.src = dataUri
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,13 +1,8 @@
|
||||||
import {RootStoreModel} from 'state/index'
|
import {RootStoreModel} from 'state/index'
|
||||||
import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types'
|
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||||
import {
|
|
||||||
scaleDownDimensions,
|
|
||||||
Dim,
|
|
||||||
compressIfNeeded,
|
|
||||||
moveToPremanantPath,
|
|
||||||
} from 'lib/media/manip'
|
|
||||||
export type {PickedMedia} from './types'
|
|
||||||
import RNFS from 'react-native-fs'
|
import RNFS from 'react-native-fs'
|
||||||
|
import {CropperOptions} from './types'
|
||||||
|
import {compressAndResizeImageForPost} from './manip'
|
||||||
|
|
||||||
let _imageCounter = 0
|
let _imageCounter = 0
|
||||||
async function getFile() {
|
async function getFile() {
|
||||||
|
@ -17,100 +12,33 @@ async function getFile() {
|
||||||
.concat(['Media', 'DCIM', '100APPLE'])
|
.concat(['Media', 'DCIM', '100APPLE'])
|
||||||
.join('/'),
|
.join('/'),
|
||||||
)
|
)
|
||||||
return files[_imageCounter++ % files.length]
|
const file = files[_imageCounter++ % files.length]
|
||||||
}
|
return await compressAndResizeImageForPost({
|
||||||
|
path: file.path,
|
||||||
export async function openPicker(
|
|
||||||
_store: RootStoreModel,
|
|
||||||
opts: PickerOpts,
|
|
||||||
): Promise<PickedMedia[]> {
|
|
||||||
const mediaType = opts.mediaType || 'photo'
|
|
||||||
const items = await getFile()
|
|
||||||
const toMedia = (item: RNFS.ReadDirItem) => ({
|
|
||||||
mediaType,
|
|
||||||
path: item.path,
|
|
||||||
mime: 'image/jpeg',
|
mime: 'image/jpeg',
|
||||||
size: item.size,
|
size: file.size,
|
||||||
width: 4288,
|
width: 4288,
|
||||||
height: 2848,
|
height: 2848,
|
||||||
})
|
})
|
||||||
if (Array.isArray(items)) {
|
|
||||||
return items.map(toMedia)
|
|
||||||
}
|
|
||||||
return [toMedia(items)]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openCamera(
|
export async function openPicker(_store: RootStoreModel): Promise<RNImage[]> {
|
||||||
_store: RootStoreModel,
|
return [await getFile()]
|
||||||
opts: CameraOpts,
|
}
|
||||||
): Promise<PickedMedia> {
|
|
||||||
const mediaType = opts.mediaType || 'photo'
|
export async function openCamera(_store: RootStoreModel): Promise<RNImage> {
|
||||||
const item = await getFile()
|
return await getFile()
|
||||||
return {
|
|
||||||
mediaType,
|
|
||||||
path: item.path,
|
|
||||||
mime: 'image/jpeg',
|
|
||||||
size: item.size,
|
|
||||||
width: 4288,
|
|
||||||
height: 2848,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openCropper(
|
export async function openCropper(
|
||||||
_store: RootStoreModel,
|
_store: RootStoreModel,
|
||||||
opts: CropperOpts,
|
opts: CropperOptions,
|
||||||
): Promise<PickedMedia> {
|
): Promise<RNImage> {
|
||||||
const mediaType = opts.mediaType || 'photo'
|
|
||||||
const item = await getFile()
|
|
||||||
return {
|
return {
|
||||||
mediaType,
|
path: opts.path,
|
||||||
path: item.path,
|
|
||||||
mime: 'image/jpeg',
|
mime: 'image/jpeg',
|
||||||
size: item.size,
|
size: 123,
|
||||||
width: 4288,
|
width: 4288,
|
||||||
height: 2848,
|
height: 2848,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
|
@ -5,14 +5,8 @@ import {
|
||||||
ImageOrVideo,
|
ImageOrVideo,
|
||||||
} 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, CropperOptions} from './types'
|
||||||
import {
|
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||||
scaleDownDimensions,
|
|
||||||
Dim,
|
|
||||||
compressIfNeeded,
|
|
||||||
moveToPremanantPath,
|
|
||||||
} from 'lib/media/manip'
|
|
||||||
export type {PickedMedia} from './types'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NOTE
|
* NOTE
|
||||||
|
@ -25,18 +19,17 @@ export type {PickedMedia} from './types'
|
||||||
|
|
||||||
export async function openPicker(
|
export async function openPicker(
|
||||||
_store: RootStoreModel,
|
_store: RootStoreModel,
|
||||||
opts: PickerOpts,
|
opts?: PickerOpts,
|
||||||
): Promise<PickedMedia[]> {
|
): Promise<RNImage[]> {
|
||||||
const mediaType = opts.mediaType || 'photo'
|
|
||||||
const items = await openPickerFn({
|
const items = await openPickerFn({
|
||||||
mediaType,
|
mediaType: 'photo', // TODO: eventually add other media types
|
||||||
multiple: opts.multiple,
|
multiple: opts?.multiple,
|
||||||
maxFiles: opts.maxFiles,
|
maxFiles: opts?.maxFiles,
|
||||||
forceJpg: true, // ios only
|
forceJpg: true, // ios only
|
||||||
compressImageQuality: 0.8,
|
compressImageQuality: 0.8,
|
||||||
})
|
})
|
||||||
|
|
||||||
const toMedia = (item: ImageOrVideo) => ({
|
const toMedia = (item: ImageOrVideo) => ({
|
||||||
mediaType,
|
|
||||||
path: item.path,
|
path: item.path,
|
||||||
mime: item.mime,
|
mime: item.mime,
|
||||||
size: item.size,
|
size: item.size,
|
||||||
|
@ -52,20 +45,17 @@ export async function openPicker(
|
||||||
export async function openCamera(
|
export async function openCamera(
|
||||||
_store: RootStoreModel,
|
_store: RootStoreModel,
|
||||||
opts: CameraOpts,
|
opts: CameraOpts,
|
||||||
): Promise<PickedMedia> {
|
): Promise<RNImage> {
|
||||||
const mediaType = opts.mediaType || 'photo'
|
|
||||||
const item = await openCameraFn({
|
const item = await openCameraFn({
|
||||||
mediaType,
|
|
||||||
width: opts.width,
|
width: opts.width,
|
||||||
height: opts.height,
|
height: opts.height,
|
||||||
freeStyleCropEnabled: opts.freeStyleCropEnabled,
|
freeStyleCropEnabled: opts.freeStyleCropEnabled,
|
||||||
cropperCircleOverlay: opts.cropperCircleOverlay,
|
cropperCircleOverlay: opts.cropperCircleOverlay,
|
||||||
cropping: true,
|
cropping: false,
|
||||||
forceJpg: true, // ios only
|
forceJpg: true, // ios only
|
||||||
compressImageQuality: 0.8,
|
compressImageQuality: 0.8,
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
mediaType,
|
|
||||||
path: item.path,
|
path: item.path,
|
||||||
mime: item.mime,
|
mime: item.mime,
|
||||||
size: item.size,
|
size: item.size,
|
||||||
|
@ -76,21 +66,15 @@ export async function openCamera(
|
||||||
|
|
||||||
export async function openCropper(
|
export async function openCropper(
|
||||||
_store: RootStoreModel,
|
_store: RootStoreModel,
|
||||||
opts: CropperOpts,
|
opts: CropperOptions,
|
||||||
): Promise<PickedMedia> {
|
): Promise<RNImage> {
|
||||||
const mediaType = opts.mediaType || 'photo'
|
|
||||||
const item = await openCropperFn({
|
const item = await openCropperFn({
|
||||||
path: opts.path,
|
...opts,
|
||||||
mediaType: opts.mediaType || 'photo',
|
|
||||||
width: opts.width,
|
|
||||||
height: opts.height,
|
|
||||||
freeStyleCropEnabled: opts.freeStyleCropEnabled,
|
|
||||||
cropperCircleOverlay: opts.cropperCircleOverlay,
|
|
||||||
forceJpg: true, // ios only
|
forceJpg: true, // ios only
|
||||||
compressImageQuality: 0.8,
|
compressImageQuality: 0.8,
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mediaType,
|
|
||||||
path: item.path,
|
path: item.path,
|
||||||
mime: item.mime,
|
mime: item.mime,
|
||||||
size: item.size,
|
size: item.size,
|
||||||
|
@ -98,46 +82,3 @@ 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
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,16 +1,10 @@
|
||||||
/// <reference lib="dom" />
|
/// <reference lib="dom" />
|
||||||
|
|
||||||
import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types'
|
import {PickerOpts, CameraOpts, CropperOptions} from './types'
|
||||||
export type {PickedMedia} from './types'
|
|
||||||
import {RootStoreModel} from 'state/index'
|
import {RootStoreModel} from 'state/index'
|
||||||
import {
|
import {getImageDim} from 'lib/media/manip'
|
||||||
scaleDownDimensions,
|
|
||||||
getImageDim,
|
|
||||||
Dim,
|
|
||||||
compressIfNeeded,
|
|
||||||
moveToPremanantPath,
|
|
||||||
} from 'lib/media/manip'
|
|
||||||
import {extractDataUriMime} from './util'
|
import {extractDataUriMime} from './util'
|
||||||
|
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||||
|
|
||||||
interface PickedFile {
|
interface PickedFile {
|
||||||
uri: string
|
uri: string
|
||||||
|
@ -21,13 +15,12 @@ interface PickedFile {
|
||||||
export async function openPicker(
|
export async function openPicker(
|
||||||
_store: RootStoreModel,
|
_store: RootStoreModel,
|
||||||
opts: PickerOpts,
|
opts: PickerOpts,
|
||||||
): Promise<PickedMedia[]> {
|
): Promise<RNImage[]> {
|
||||||
const res = await selectFile(opts)
|
const res = await selectFile(opts)
|
||||||
const dim = await getImageDim(res.uri)
|
const dim = await getImageDim(res.uri)
|
||||||
const mime = extractDataUriMime(res.uri)
|
const mime = extractDataUriMime(res.uri)
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
mediaType: 'photo',
|
|
||||||
path: res.uri,
|
path: res.uri,
|
||||||
mime,
|
mime,
|
||||||
size: res.size,
|
size: res.size,
|
||||||
|
@ -40,21 +33,21 @@ export async function openPicker(
|
||||||
export async function openCamera(
|
export async function openCamera(
|
||||||
_store: RootStoreModel,
|
_store: RootStoreModel,
|
||||||
_opts: CameraOpts,
|
_opts: CameraOpts,
|
||||||
): Promise<PickedMedia> {
|
): Promise<RNImage> {
|
||||||
// const mediaType = opts.mediaType || 'photo' TODO
|
// const mediaType = opts.mediaType || 'photo' TODO
|
||||||
throw new Error('TODO')
|
throw new Error('TODO')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openCropper(
|
export async function openCropper(
|
||||||
store: RootStoreModel,
|
store: RootStoreModel,
|
||||||
opts: CropperOpts,
|
opts: CropperOptions,
|
||||||
): Promise<PickedMedia> {
|
): Promise<RNImage> {
|
||||||
// TODO handle more opts
|
// TODO handle more opts
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
store.shell.openModal({
|
store.shell.openModal({
|
||||||
name: 'crop-image',
|
name: 'crop-image',
|
||||||
uri: opts.path,
|
uri: opts.path,
|
||||||
onSelect: (img?: PickedMedia) => {
|
onSelect: (img?: RNImage) => {
|
||||||
if (img) {
|
if (img) {
|
||||||
resolve(img)
|
resolve(img)
|
||||||
} else {
|
} else {
|
||||||
|
@ -65,52 +58,6 @@ export async function openCropper(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
|
||||||
// =
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens the select file dialog in the browser.
|
* Opens the select file dialog in the browser.
|
||||||
* NOTE:
|
* NOTE:
|
||||||
|
|
|
@ -1,31 +1,21 @@
|
||||||
|
import {openCropper} from 'react-native-image-crop-picker'
|
||||||
|
|
||||||
|
export interface Dimensions {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface PickerOpts {
|
export interface PickerOpts {
|
||||||
mediaType?: 'photo'
|
mediaType?: string
|
||||||
multiple?: boolean
|
multiple?: boolean
|
||||||
maxFiles?: number
|
maxFiles?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CameraOpts {
|
export interface CameraOpts {
|
||||||
mediaType?: 'photo'
|
|
||||||
width: number
|
width: number
|
||||||
height: number
|
height: number
|
||||||
freeStyleCropEnabled?: boolean
|
freeStyleCropEnabled?: boolean
|
||||||
cropperCircleOverlay?: boolean
|
cropperCircleOverlay?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CropperOpts {
|
export type CropperOptions = Parameters<typeof openCropper>[0]
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,7 +1,45 @@
|
||||||
|
import {Dimensions} from './types'
|
||||||
|
|
||||||
export function extractDataUriMime(uri: string): string {
|
export function extractDataUriMime(uri: string): string {
|
||||||
return uri.substring(uri.indexOf(':') + 1, uri.indexOf(';'))
|
return uri.substring(uri.indexOf(':') + 1, uri.indexOf(';'))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fairly accurate estimate that is more performant
|
||||||
|
// than decoding and checking length of URI
|
||||||
export function getDataUriSize(uri: string): number {
|
export function getDataUriSize(uri: string): number {
|
||||||
return Math.round((uri.length * 3) / 4) // very rough estimate
|
return Math.round((uri.length * 3) / 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scaleDownDimensions(
|
||||||
|
dim: Dimensions,
|
||||||
|
max: Dimensions,
|
||||||
|
): Dimensions {
|
||||||
|
if (dim.width < max.width && dim.height < max.height) {
|
||||||
|
return dim
|
||||||
|
}
|
||||||
|
const wScale = dim.width > max.width ? max.width / dim.width : 1
|
||||||
|
const hScale = dim.height > max.height ? max.height / dim.height : 1
|
||||||
|
if (wScale < hScale) {
|
||||||
|
return {width: dim.width * wScale, height: dim.height * wScale}
|
||||||
|
}
|
||||||
|
return {width: dim.width * hScale, height: dim.height * hScale}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUriImage(uri: string) {
|
||||||
|
return /\.(jpg|jpeg|png).*$/.test(uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function blobToDataUri(blob: Blob): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onloadend = () => {
|
||||||
|
if (typeof reader.result === 'string') {
|
||||||
|
resolve(reader.result)
|
||||||
|
} else {
|
||||||
|
reject(new Error('Failed to read blob'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.onerror = reject
|
||||||
|
reader.readAsDataURL(blob)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,24 +1,24 @@
|
||||||
import {Image} from 'react-native'
|
import {Image} from 'react-native'
|
||||||
import {Dim} from 'lib/media/manip'
|
import type {Dimensions} from 'lib/media/types'
|
||||||
|
|
||||||
export class ImageSizesCache {
|
export class ImageSizesCache {
|
||||||
sizes: Map<string, Dim> = new Map()
|
sizes: Map<string, Dimensions> = new Map()
|
||||||
activeRequests: Map<string, Promise<Dim>> = new Map()
|
activeRequests: Map<string, Promise<Dimensions>> = new Map()
|
||||||
|
|
||||||
constructor() {}
|
constructor() {}
|
||||||
|
|
||||||
get(uri: string): Dim | undefined {
|
get(uri: string): Dimensions | undefined {
|
||||||
return this.sizes.get(uri)
|
return this.sizes.get(uri)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetch(uri: string): Promise<Dim> {
|
async fetch(uri: string): Promise<Dimensions> {
|
||||||
const dim = this.sizes.get(uri)
|
const Dimensions = this.sizes.get(uri)
|
||||||
if (dim) {
|
if (Dimensions) {
|
||||||
return dim
|
return Dimensions
|
||||||
}
|
}
|
||||||
const prom =
|
const prom =
|
||||||
this.activeRequests.get(uri) ||
|
this.activeRequests.get(uri) ||
|
||||||
new Promise<Dim>(resolve => {
|
new Promise<Dimensions>(resolve => {
|
||||||
Image.getSize(
|
Image.getSize(
|
||||||
uri,
|
uri,
|
||||||
(width: number, height: number) => resolve({width, height}),
|
(width: number, height: number) => resolve({width, height}),
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import {makeAutoObservable, runInAction} from 'mobx'
|
import {makeAutoObservable, runInAction} from 'mobx'
|
||||||
import {PickedMedia} from 'lib/media/picker'
|
|
||||||
import {
|
import {
|
||||||
ComAtprotoLabelDefs,
|
ComAtprotoLabelDefs,
|
||||||
AppBskyActorGetProfile as GetProfile,
|
AppBskyActorGetProfile as GetProfile,
|
||||||
|
@ -10,6 +9,7 @@ import {RootStoreModel} from '../root-store'
|
||||||
import * as apilib from 'lib/api/index'
|
import * as apilib from 'lib/api/index'
|
||||||
import {cleanError} from 'lib/strings/errors'
|
import {cleanError} from 'lib/strings/errors'
|
||||||
import {FollowState} from '../cache/my-follows'
|
import {FollowState} from '../cache/my-follows'
|
||||||
|
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||||
|
|
||||||
export const ACTOR_TYPE_USER = 'app.bsky.system.actorUser'
|
export const ACTOR_TYPE_USER = 'app.bsky.system.actorUser'
|
||||||
|
|
||||||
|
@ -122,8 +122,8 @@ export class ProfileModel {
|
||||||
|
|
||||||
async updateProfile(
|
async updateProfile(
|
||||||
updates: AppBskyActorProfile.Record,
|
updates: AppBskyActorProfile.Record,
|
||||||
newUserAvatar: PickedMedia | undefined | null,
|
newUserAvatar: RNImage | undefined | null,
|
||||||
newUserBanner: PickedMedia | undefined | null,
|
newUserBanner: RNImage | undefined | null,
|
||||||
) {
|
) {
|
||||||
await this.rootStore.agent.upsertProfile(async existing => {
|
await this.rootStore.agent.upsertProfile(async existing => {
|
||||||
existing = existing || {}
|
existing = existing || {}
|
||||||
|
|
|
@ -0,0 +1,85 @@
|
||||||
|
import {makeAutoObservable, runInAction} from 'mobx'
|
||||||
|
import {RootStoreModel} from 'state/index'
|
||||||
|
import {ImageModel} from './image'
|
||||||
|
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||||
|
import {openPicker} from 'lib/media/picker'
|
||||||
|
import {getImageDim} from 'lib/media/manip'
|
||||||
|
import {getDataUriSize} from 'lib/media/util'
|
||||||
|
|
||||||
|
export class GalleryModel {
|
||||||
|
images: ImageModel[] = []
|
||||||
|
|
||||||
|
constructor(public rootStore: RootStoreModel) {
|
||||||
|
makeAutoObservable(this, {
|
||||||
|
rootStore: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
get isEmpty() {
|
||||||
|
return this.size === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
get size() {
|
||||||
|
return this.images.length
|
||||||
|
}
|
||||||
|
|
||||||
|
get paths() {
|
||||||
|
return this.images.map(image =>
|
||||||
|
image.compressed === undefined ? image.path : image.compressed.path,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async add(image_: RNImage) {
|
||||||
|
if (this.size >= 4) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Temporarily enforce uniqueness but can eventually also use index
|
||||||
|
if (!this.images.some(i => i.path === image_.path)) {
|
||||||
|
const image = new ImageModel(this.rootStore, image_)
|
||||||
|
await image.compress()
|
||||||
|
|
||||||
|
runInAction(() => {
|
||||||
|
this.images.push(image)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async paste(uri: string) {
|
||||||
|
if (this.size >= 4) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const {width, height} = await getImageDim(uri)
|
||||||
|
|
||||||
|
const image: RNImage = {
|
||||||
|
path: uri,
|
||||||
|
height,
|
||||||
|
width,
|
||||||
|
size: getDataUriSize(uri),
|
||||||
|
mime: 'image/jpeg',
|
||||||
|
}
|
||||||
|
|
||||||
|
runInAction(() => {
|
||||||
|
this.add(image)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
crop(image: ImageModel) {
|
||||||
|
image.crop()
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(image: ImageModel) {
|
||||||
|
const index = this.images.findIndex(image_ => image_.path === image.path)
|
||||||
|
this.images.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async pick() {
|
||||||
|
const images = await openPicker(this.rootStore, {
|
||||||
|
multiple: true,
|
||||||
|
maxFiles: 4 - this.images.length,
|
||||||
|
})
|
||||||
|
|
||||||
|
await Promise.all(images.map(image => this.add(image)))
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,85 @@
|
||||||
|
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||||
|
import {RootStoreModel} from 'state/index'
|
||||||
|
import {compressAndResizeImageForPost} from 'lib/media/manip'
|
||||||
|
import {makeAutoObservable, runInAction} from 'mobx'
|
||||||
|
import {openCropper} from 'lib/media/picker'
|
||||||
|
import {POST_IMG_MAX} from 'lib/constants'
|
||||||
|
import {scaleDownDimensions} from 'lib/media/util'
|
||||||
|
|
||||||
|
// TODO: EXIF embed
|
||||||
|
// Cases to consider: ExternalEmbed
|
||||||
|
export class ImageModel implements RNImage {
|
||||||
|
path: string
|
||||||
|
mime = 'image/jpeg'
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
size: number
|
||||||
|
cropped?: RNImage = undefined
|
||||||
|
compressed?: RNImage = undefined
|
||||||
|
scaledWidth: number = POST_IMG_MAX.width
|
||||||
|
scaledHeight: number = POST_IMG_MAX.height
|
||||||
|
|
||||||
|
constructor(public rootStore: RootStoreModel, image: RNImage) {
|
||||||
|
makeAutoObservable(this, {
|
||||||
|
rootStore: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
this.path = image.path
|
||||||
|
this.width = image.width
|
||||||
|
this.height = image.height
|
||||||
|
this.size = image.size
|
||||||
|
this.calcScaledDimensions()
|
||||||
|
}
|
||||||
|
|
||||||
|
calcScaledDimensions() {
|
||||||
|
const {width, height} = scaleDownDimensions(
|
||||||
|
{width: this.width, height: this.height},
|
||||||
|
POST_IMG_MAX,
|
||||||
|
)
|
||||||
|
|
||||||
|
this.scaledWidth = width
|
||||||
|
this.scaledHeight = height
|
||||||
|
}
|
||||||
|
|
||||||
|
async crop() {
|
||||||
|
try {
|
||||||
|
const cropped = await openCropper(this.rootStore, {
|
||||||
|
mediaType: 'photo',
|
||||||
|
path: this.path,
|
||||||
|
freeStyleCropEnabled: true,
|
||||||
|
width: this.scaledWidth,
|
||||||
|
height: this.scaledHeight,
|
||||||
|
})
|
||||||
|
|
||||||
|
runInAction(() => {
|
||||||
|
this.cropped = cropped
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
this.rootStore.log.error('Failed to crop photo', err)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.compress()
|
||||||
|
}
|
||||||
|
|
||||||
|
async compress() {
|
||||||
|
try {
|
||||||
|
const {width, height} = scaleDownDimensions(
|
||||||
|
this.cropped
|
||||||
|
? {width: this.cropped.width, height: this.cropped.height}
|
||||||
|
: {width: this.width, height: this.height},
|
||||||
|
POST_IMG_MAX,
|
||||||
|
)
|
||||||
|
const compressed = await compressAndResizeImageForPost({
|
||||||
|
...(this.cropped === undefined ? this : this.cropped),
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
})
|
||||||
|
|
||||||
|
runInAction(() => {
|
||||||
|
this.compressed = compressed
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
this.rootStore.log.error('Failed to compress photo', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,7 +3,7 @@ import {RootStoreModel} from '../root-store'
|
||||||
import {makeAutoObservable} from 'mobx'
|
import {makeAutoObservable} from 'mobx'
|
||||||
import {ProfileModel} from '../content/profile'
|
import {ProfileModel} from '../content/profile'
|
||||||
import {isObj, hasProp} from 'lib/type-guards'
|
import {isObj, hasProp} from 'lib/type-guards'
|
||||||
import {PickedMedia} from 'lib/media/types'
|
import {Image} from 'lib/media/types'
|
||||||
|
|
||||||
export interface ConfirmModal {
|
export interface ConfirmModal {
|
||||||
name: 'confirm'
|
name: 'confirm'
|
||||||
|
@ -38,7 +38,7 @@ export interface ReportAccountModal {
|
||||||
export interface CropImageModal {
|
export interface CropImageModal {
|
||||||
name: 'crop-image'
|
name: 'crop-image'
|
||||||
uri: string
|
uri: string
|
||||||
onSelect: (img?: PickedMedia) => void
|
onSelect: (img?: Image) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeleteAccountModal {
|
export interface DeleteAccountModal {
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React from 'react'
|
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'
|
||||||
import {observer} from 'mobx-react-lite'
|
import {observer} from 'mobx-react-lite'
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
|
@ -30,47 +30,42 @@ import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||||
import {cleanError} from 'lib/strings/errors'
|
import {cleanError} from 'lib/strings/errors'
|
||||||
import {SelectPhotoBtn} from './photos/SelectPhotoBtn'
|
import {SelectPhotoBtn} from './photos/SelectPhotoBtn'
|
||||||
import {OpenCameraBtn} from './photos/OpenCameraBtn'
|
import {OpenCameraBtn} from './photos/OpenCameraBtn'
|
||||||
import {SelectedPhotos} from './photos/SelectedPhotos'
|
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import QuoteEmbed from '../util/post-embeds/QuoteEmbed'
|
import QuoteEmbed from '../util/post-embeds/QuoteEmbed'
|
||||||
import {useExternalLinkFetch} from './useExternalLinkFetch'
|
import {useExternalLinkFetch} from './useExternalLinkFetch'
|
||||||
import {isDesktopWeb} from 'platform/detection'
|
import {isDesktopWeb} from 'platform/detection'
|
||||||
|
import {GalleryModel} from 'state/models/media/gallery'
|
||||||
|
import {Gallery} from './photos/Gallery'
|
||||||
|
|
||||||
const MAX_GRAPHEME_LENGTH = 300
|
const MAX_GRAPHEME_LENGTH = 300
|
||||||
|
|
||||||
|
type Props = ComposerOpts & {
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
export const ComposePost = observer(function ComposePost({
|
export const ComposePost = observer(function ComposePost({
|
||||||
replyTo,
|
replyTo,
|
||||||
onPost,
|
onPost,
|
||||||
onClose,
|
onClose,
|
||||||
quote: initQuote,
|
quote: initQuote,
|
||||||
}: {
|
}: Props) {
|
||||||
replyTo?: ComposerOpts['replyTo']
|
|
||||||
onPost?: ComposerOpts['onPost']
|
|
||||||
onClose: () => void
|
|
||||||
quote?: ComposerOpts['quote']
|
|
||||||
}) {
|
|
||||||
const {track} = useAnalytics()
|
const {track} = useAnalytics()
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const textInput = React.useRef<TextInputRef>(null)
|
const textInput = useRef<TextInputRef>(null)
|
||||||
const [isProcessing, setIsProcessing] = React.useState(false)
|
const [isProcessing, setIsProcessing] = useState(false)
|
||||||
const [processingState, setProcessingState] = React.useState('')
|
const [processingState, setProcessingState] = useState('')
|
||||||
const [error, setError] = React.useState('')
|
const [error, setError] = useState('')
|
||||||
const [richtext, setRichText] = React.useState(new RichText({text: ''}))
|
const [richtext, setRichText] = useState(new RichText({text: ''}))
|
||||||
const graphemeLength = React.useMemo(
|
const graphemeLength = useMemo(() => richtext.graphemeLength, [richtext])
|
||||||
() => richtext.graphemeLength,
|
const [quote, setQuote] = useState<ComposerOpts['quote'] | undefined>(
|
||||||
[richtext],
|
|
||||||
)
|
|
||||||
const [quote, setQuote] = React.useState<ComposerOpts['quote'] | undefined>(
|
|
||||||
initQuote,
|
initQuote,
|
||||||
)
|
)
|
||||||
const {extLink, setExtLink} = useExternalLinkFetch({setQuote})
|
const {extLink, setExtLink} = useExternalLinkFetch({setQuote})
|
||||||
const [suggestedLinks, setSuggestedLinks] = React.useState<Set<string>>(
|
const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set())
|
||||||
new Set(),
|
const gallery = useMemo(() => new GalleryModel(store), [store])
|
||||||
)
|
|
||||||
const [selectedPhotos, setSelectedPhotos] = React.useState<string[]>([])
|
|
||||||
|
|
||||||
const autocompleteView = React.useMemo<UserAutocompleteModel>(
|
const autocompleteView = useMemo<UserAutocompleteModel>(
|
||||||
() => new UserAutocompleteModel(store),
|
() => new UserAutocompleteModel(store),
|
||||||
[store],
|
[store],
|
||||||
)
|
)
|
||||||
|
@ -82,17 +77,17 @@ export const ComposePost = observer(function ComposePost({
|
||||||
// is focused during unmount, an exception will throw (seems that a blur method isnt implemented)
|
// is focused during unmount, an exception will throw (seems that a blur method isnt implemented)
|
||||||
// manually blurring before closing gets around that
|
// manually blurring before closing gets around that
|
||||||
// -prf
|
// -prf
|
||||||
const hackfixOnClose = React.useCallback(() => {
|
const hackfixOnClose = useCallback(() => {
|
||||||
textInput.current?.blur()
|
textInput.current?.blur()
|
||||||
onClose()
|
onClose()
|
||||||
}, [textInput, onClose])
|
}, [textInput, onClose])
|
||||||
|
|
||||||
// initial setup
|
// initial setup
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
autocompleteView.setup()
|
autocompleteView.setup()
|
||||||
}, [autocompleteView])
|
}, [autocompleteView])
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
// HACK
|
// HACK
|
||||||
// wait a moment before focusing the input to resolve some layout bugs with the keyboard-avoiding-view
|
// wait a moment before focusing the input to resolve some layout bugs with the keyboard-avoiding-view
|
||||||
// -prf
|
// -prf
|
||||||
|
@ -109,60 +104,51 @@ export const ComposePost = observer(function ComposePost({
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const onPressContainer = React.useCallback(() => {
|
const onPressContainer = useCallback(() => {
|
||||||
textInput.current?.focus()
|
textInput.current?.focus()
|
||||||
}, [textInput])
|
}, [textInput])
|
||||||
|
|
||||||
const onSelectPhotos = React.useCallback(
|
const onPressAddLinkCard = useCallback(
|
||||||
(photos: string[]) => {
|
|
||||||
track('Composer:SelectedPhotos')
|
|
||||||
setSelectedPhotos(photos)
|
|
||||||
},
|
|
||||||
[track, setSelectedPhotos],
|
|
||||||
)
|
|
||||||
|
|
||||||
const onPressAddLinkCard = React.useCallback(
|
|
||||||
(uri: string) => {
|
(uri: string) => {
|
||||||
setExtLink({uri, isLoading: true})
|
setExtLink({uri, isLoading: true})
|
||||||
},
|
},
|
||||||
[setExtLink],
|
[setExtLink],
|
||||||
)
|
)
|
||||||
|
|
||||||
const onPhotoPasted = React.useCallback(
|
const onPhotoPasted = useCallback(
|
||||||
async (uri: string) => {
|
async (uri: string) => {
|
||||||
if (selectedPhotos.length >= 4) {
|
track('Composer:PastedPhotos')
|
||||||
return
|
gallery.paste(uri)
|
||||||
}
|
|
||||||
onSelectPhotos([...selectedPhotos, uri])
|
|
||||||
},
|
},
|
||||||
[selectedPhotos, onSelectPhotos],
|
[gallery, track],
|
||||||
)
|
)
|
||||||
|
|
||||||
const onPressPublish = React.useCallback(async () => {
|
const onPressPublish = useCallback(async () => {
|
||||||
if (isProcessing) {
|
if (isProcessing || richtext.graphemeLength > MAX_GRAPHEME_LENGTH) {
|
||||||
return
|
|
||||||
}
|
|
||||||
if (richtext.graphemeLength > MAX_GRAPHEME_LENGTH) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setError('')
|
setError('')
|
||||||
if (richtext.text.trim().length === 0 && selectedPhotos.length === 0) {
|
|
||||||
|
if (richtext.text.trim().length === 0 && gallery.isEmpty) {
|
||||||
setError('Did you want to say anything?')
|
setError('Did you want to say anything?')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsProcessing(true)
|
setIsProcessing(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await apilib.post(store, {
|
await apilib.post(store, {
|
||||||
rawText: richtext.text,
|
rawText: richtext.text,
|
||||||
replyTo: replyTo?.uri,
|
replyTo: replyTo?.uri,
|
||||||
images: selectedPhotos,
|
images: gallery.paths,
|
||||||
quote: quote,
|
quote: quote,
|
||||||
extLink: extLink,
|
extLink: extLink,
|
||||||
onStateChange: setProcessingState,
|
onStateChange: setProcessingState,
|
||||||
knownHandles: autocompleteView.knownHandles,
|
knownHandles: autocompleteView.knownHandles,
|
||||||
})
|
})
|
||||||
track('Create Post', {
|
track('Create Post', {
|
||||||
imageCount: selectedPhotos.length,
|
imageCount: gallery.size,
|
||||||
})
|
})
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (extLink) {
|
if (extLink) {
|
||||||
|
@ -191,34 +177,33 @@ export const ComposePost = observer(function ComposePost({
|
||||||
hackfixOnClose,
|
hackfixOnClose,
|
||||||
onPost,
|
onPost,
|
||||||
quote,
|
quote,
|
||||||
selectedPhotos,
|
|
||||||
setExtLink,
|
setExtLink,
|
||||||
store,
|
store,
|
||||||
track,
|
track,
|
||||||
|
gallery,
|
||||||
])
|
])
|
||||||
|
|
||||||
const canPost = graphemeLength <= MAX_GRAPHEME_LENGTH
|
const canPost = graphemeLength <= MAX_GRAPHEME_LENGTH
|
||||||
|
|
||||||
const selectTextInputPlaceholder = replyTo
|
const selectTextInputPlaceholder = replyTo
|
||||||
? 'Write your reply'
|
? 'Write your reply'
|
||||||
: selectedPhotos.length !== 0
|
: gallery.isEmpty
|
||||||
? 'Write a comment'
|
? 'Write a comment'
|
||||||
: "What's up?"
|
: "What's up?"
|
||||||
|
|
||||||
|
const canSelectImages = gallery.size <= 4
|
||||||
|
const viewStyles = {
|
||||||
|
paddingBottom: Platform.OS === 'android' ? insets.bottom : 0,
|
||||||
|
paddingTop: Platform.OS === 'android' ? insets.top : 15,
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
testID="composePostView"
|
testID="composePostView"
|
||||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
style={styles.outer}>
|
style={styles.outer}>
|
||||||
<TouchableWithoutFeedback onPressIn={onPressContainer}>
|
<TouchableWithoutFeedback onPressIn={onPressContainer}>
|
||||||
<View
|
<View style={[s.flex1, viewStyles]}>
|
||||||
style={[
|
|
||||||
s.flex1,
|
|
||||||
{
|
|
||||||
paddingBottom: Platform.OS === 'android' ? insets.bottom : 0,
|
|
||||||
paddingTop: Platform.OS === 'android' ? insets.top : 15,
|
|
||||||
},
|
|
||||||
]}>
|
|
||||||
<View style={styles.topbar}>
|
<View style={styles.topbar}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
testID="composerCancelButton"
|
testID="composerCancelButton"
|
||||||
|
@ -301,11 +286,8 @@ export const ComposePost = observer(function ComposePost({
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<SelectedPhotos
|
<Gallery gallery={gallery} />
|
||||||
selectedPhotos={selectedPhotos}
|
{gallery.isEmpty && extLink && (
|
||||||
onSelectPhotos={onSelectPhotos}
|
|
||||||
/>
|
|
||||||
{selectedPhotos.length === 0 && extLink && (
|
|
||||||
<ExternalEmbed
|
<ExternalEmbed
|
||||||
link={extLink}
|
link={extLink}
|
||||||
onRemove={() => setExtLink(undefined)}
|
onRemove={() => setExtLink(undefined)}
|
||||||
|
@ -317,9 +299,7 @@ export const ComposePost = observer(function ComposePost({
|
||||||
</View>
|
</View>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
{!extLink &&
|
{!extLink && suggestedLinks.size > 0 ? (
|
||||||
selectedPhotos.length === 0 &&
|
|
||||||
suggestedLinks.size > 0 ? (
|
|
||||||
<View style={s.mb5}>
|
<View style={s.mb5}>
|
||||||
{Array.from(suggestedLinks).map(url => (
|
{Array.from(suggestedLinks).map(url => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
@ -335,16 +315,12 @@ export const ComposePost = observer(function ComposePost({
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
<View style={[pal.border, styles.bottomBar]}>
|
<View style={[pal.border, styles.bottomBar]}>
|
||||||
<SelectPhotoBtn
|
{canSelectImages ? (
|
||||||
enabled={selectedPhotos.length < 4}
|
<>
|
||||||
selectedPhotos={selectedPhotos}
|
<SelectPhotoBtn gallery={gallery} />
|
||||||
onSelectPhotos={setSelectedPhotos}
|
<OpenCameraBtn gallery={gallery} />
|
||||||
/>
|
</>
|
||||||
<OpenCameraBtn
|
) : null}
|
||||||
enabled={selectedPhotos.length < 4}
|
|
||||||
selectedPhotos={selectedPhotos}
|
|
||||||
onSelectPhotos={setSelectedPhotos}
|
|
||||||
/>
|
|
||||||
<View style={s.flex1} />
|
<View style={s.flex1} />
|
||||||
<CharProgress count={graphemeLength} />
|
<CharProgress count={graphemeLength} />
|
||||||
</View>
|
</View>
|
||||||
|
|
|
@ -2,11 +2,10 @@ import React from 'react'
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
TouchableWithoutFeedback,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||||
import {BlurView} from '../util/BlurView'
|
|
||||||
import {AutoSizedImage} from '../util/images/AutoSizedImage'
|
import {AutoSizedImage} from '../util/images/AutoSizedImage'
|
||||||
import {Text} from '../util/text/Text'
|
import {Text} from '../util/text/Text'
|
||||||
import {s} from 'lib/styles'
|
import {s} from 'lib/styles'
|
||||||
|
@ -61,11 +60,9 @@ export const ExternalEmbed = ({
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<TouchableWithoutFeedback onPress={onRemove}>
|
<TouchableOpacity style={styles.removeBtn} onPress={onRemove}>
|
||||||
<BlurView style={styles.removeBtn} blurType="dark">
|
|
||||||
<FontAwesomeIcon size={18} icon="xmark" style={s.white} />
|
<FontAwesomeIcon size={18} icon="xmark" style={s.white} />
|
||||||
</BlurView>
|
</TouchableOpacity>
|
||||||
</TouchableWithoutFeedback>
|
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -92,6 +89,7 @@ const styles = StyleSheet.create({
|
||||||
right: 10,
|
right: 10,
|
||||||
width: 36,
|
width: 36,
|
||||||
height: 36,
|
height: 36,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||||
borderRadius: 18,
|
borderRadius: 18,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
|
|
@ -0,0 +1,130 @@
|
||||||
|
import React, {useCallback} from 'react'
|
||||||
|
import {GalleryModel} from 'state/models/media/gallery'
|
||||||
|
import {observer} from 'mobx-react-lite'
|
||||||
|
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||||
|
import {colors} from 'lib/styles'
|
||||||
|
import {StyleSheet, TouchableOpacity, View} from 'react-native'
|
||||||
|
import {ImageModel} from 'state/models/media/image'
|
||||||
|
import {Image} from 'expo-image'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
gallery: GalleryModel
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Gallery = observer(function ({gallery}: Props) {
|
||||||
|
const getImageStyle = useCallback(() => {
|
||||||
|
switch (gallery.size) {
|
||||||
|
case 1:
|
||||||
|
return styles.image250
|
||||||
|
case 2:
|
||||||
|
return styles.image175
|
||||||
|
default:
|
||||||
|
return styles.image85
|
||||||
|
}
|
||||||
|
}, [gallery])
|
||||||
|
|
||||||
|
const imageStyle = getImageStyle()
|
||||||
|
const handleRemovePhoto = useCallback(
|
||||||
|
(image: ImageModel) => {
|
||||||
|
gallery.remove(image)
|
||||||
|
},
|
||||||
|
[gallery],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleEditPhoto = useCallback(
|
||||||
|
(image: ImageModel) => {
|
||||||
|
gallery.crop(image)
|
||||||
|
},
|
||||||
|
[gallery],
|
||||||
|
)
|
||||||
|
|
||||||
|
return !gallery.isEmpty ? (
|
||||||
|
<View testID="selectedPhotosView" style={styles.gallery}>
|
||||||
|
{gallery.images.map(image =>
|
||||||
|
image.compressed !== undefined ? (
|
||||||
|
<View
|
||||||
|
key={`selected-image-${image.path}`}
|
||||||
|
style={[styles.imageContainer, imageStyle]}>
|
||||||
|
<View style={styles.imageControls}>
|
||||||
|
<TouchableOpacity
|
||||||
|
testID="cropPhotoButton"
|
||||||
|
onPress={() => {
|
||||||
|
handleEditPhoto(image)
|
||||||
|
}}
|
||||||
|
style={styles.imageControl}>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon="pen"
|
||||||
|
size={12}
|
||||||
|
style={{color: colors.white}}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
testID="removePhotoButton"
|
||||||
|
onPress={() => handleRemovePhoto(image)}
|
||||||
|
style={styles.imageControl}>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon="xmark"
|
||||||
|
size={16}
|
||||||
|
style={{color: colors.white}}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Image
|
||||||
|
testID="selectedPhotoImage"
|
||||||
|
style={[styles.image, imageStyle]}
|
||||||
|
source={{
|
||||||
|
uri: image.compressed.path,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
) : null
|
||||||
|
})
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
gallery: {
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: 'row',
|
||||||
|
marginTop: 16,
|
||||||
|
},
|
||||||
|
imageContainer: {
|
||||||
|
margin: 2,
|
||||||
|
},
|
||||||
|
image: {
|
||||||
|
resizeMode: 'cover',
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
image250: {
|
||||||
|
width: 250,
|
||||||
|
height: 250,
|
||||||
|
},
|
||||||
|
image175: {
|
||||||
|
width: 175,
|
||||||
|
height: 175,
|
||||||
|
},
|
||||||
|
image85: {
|
||||||
|
width: 85,
|
||||||
|
height: 85,
|
||||||
|
},
|
||||||
|
imageControls: {
|
||||||
|
position: 'absolute',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 4,
|
||||||
|
top: 8,
|
||||||
|
right: 8,
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
|
imageControl: {
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||||
|
borderWidth: 0.5,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
})
|
|
@ -1,4 +1,4 @@
|
||||||
import React from 'react'
|
import React, {useCallback} from 'react'
|
||||||
import {TouchableOpacity} from 'react-native'
|
import {TouchableOpacity} from 'react-native'
|
||||||
import {
|
import {
|
||||||
FontAwesomeIcon,
|
FontAwesomeIcon,
|
||||||
|
@ -10,62 +10,44 @@ import {useStores} from 'state/index'
|
||||||
import {s} from 'lib/styles'
|
import {s} from 'lib/styles'
|
||||||
import {isDesktopWeb} from 'platform/detection'
|
import {isDesktopWeb} from 'platform/detection'
|
||||||
import {openCamera} from 'lib/media/picker'
|
import {openCamera} from 'lib/media/picker'
|
||||||
import {compressIfNeeded} from 'lib/media/manip'
|
|
||||||
import {useCameraPermission} from 'lib/hooks/usePermissions'
|
import {useCameraPermission} from 'lib/hooks/usePermissions'
|
||||||
import {
|
import {POST_IMG_MAX} from 'lib/constants'
|
||||||
POST_IMG_MAX_WIDTH,
|
import {GalleryModel} from 'state/models/media/gallery'
|
||||||
POST_IMG_MAX_HEIGHT,
|
|
||||||
POST_IMG_MAX_SIZE,
|
|
||||||
} from 'lib/constants'
|
|
||||||
|
|
||||||
const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10}
|
const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10}
|
||||||
|
|
||||||
export function OpenCameraBtn({
|
type Props = {
|
||||||
enabled,
|
gallery: GalleryModel
|
||||||
selectedPhotos,
|
}
|
||||||
onSelectPhotos,
|
|
||||||
}: {
|
export function OpenCameraBtn({gallery}: Props) {
|
||||||
enabled: boolean
|
|
||||||
selectedPhotos: string[]
|
|
||||||
onSelectPhotos: (v: string[]) => void
|
|
||||||
}) {
|
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const {track} = useAnalytics()
|
const {track} = useAnalytics()
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const {requestCameraAccessIfNeeded} = useCameraPermission()
|
const {requestCameraAccessIfNeeded} = useCameraPermission()
|
||||||
|
|
||||||
const onPressTakePicture = React.useCallback(async () => {
|
const onPressTakePicture = useCallback(async () => {
|
||||||
track('Composer:CameraOpened')
|
track('Composer:CameraOpened')
|
||||||
if (!enabled) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
if (!(await requestCameraAccessIfNeeded())) {
|
if (!(await requestCameraAccessIfNeeded())) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const cameraRes = await openCamera(store, {
|
|
||||||
mediaType: 'photo',
|
const img = await openCamera(store, {
|
||||||
width: POST_IMG_MAX_WIDTH,
|
width: POST_IMG_MAX.width,
|
||||||
height: POST_IMG_MAX_HEIGHT,
|
height: POST_IMG_MAX.height,
|
||||||
freeStyleCropEnabled: true,
|
freeStyleCropEnabled: true,
|
||||||
})
|
})
|
||||||
const img = await compressIfNeeded(cameraRes, POST_IMG_MAX_SIZE)
|
|
||||||
onSelectPhotos([...selectedPhotos, img.path])
|
gallery.add(img)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
// ignore
|
// ignore
|
||||||
store.log.warn('Error using camera', err)
|
store.log.warn('Error using camera', err)
|
||||||
}
|
}
|
||||||
}, [
|
}, [gallery, track, store, requestCameraAccessIfNeeded])
|
||||||
track,
|
|
||||||
store,
|
|
||||||
onSelectPhotos,
|
|
||||||
selectedPhotos,
|
|
||||||
enabled,
|
|
||||||
requestCameraAccessIfNeeded,
|
|
||||||
])
|
|
||||||
|
|
||||||
if (isDesktopWeb) {
|
if (isDesktopWeb) {
|
||||||
return <></>
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -76,11 +58,7 @@ export function OpenCameraBtn({
|
||||||
hitSlop={HITSLOP}>
|
hitSlop={HITSLOP}>
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
icon="camera"
|
icon="camera"
|
||||||
style={
|
style={pal.link as FontAwesomeIconStyle}
|
||||||
(enabled
|
|
||||||
? pal.link
|
|
||||||
: [pal.textLight, s.dimmed]) as FontAwesomeIconStyle
|
|
||||||
}
|
|
||||||
size={24}
|
size={24}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
export function OpenCameraBtn() {
|
||||||
|
return null
|
||||||
|
}
|
|
@ -1,86 +1,36 @@
|
||||||
import React from 'react'
|
import React, {useCallback} from 'react'
|
||||||
import {Platform, TouchableOpacity} from 'react-native'
|
import {TouchableOpacity} from 'react-native'
|
||||||
import {
|
import {
|
||||||
FontAwesomeIcon,
|
FontAwesomeIcon,
|
||||||
FontAwesomeIconStyle,
|
FontAwesomeIconStyle,
|
||||||
} from '@fortawesome/react-native-fontawesome'
|
} from '@fortawesome/react-native-fontawesome'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {useAnalytics} from 'lib/analytics'
|
import {useAnalytics} from 'lib/analytics'
|
||||||
import {useStores} from 'state/index'
|
|
||||||
import {s} from 'lib/styles'
|
import {s} from 'lib/styles'
|
||||||
import {isDesktopWeb} from 'platform/detection'
|
import {isDesktopWeb} from 'platform/detection'
|
||||||
import {openPicker, cropAndCompressFlow, pickImagesFlow} from 'lib/media/picker'
|
|
||||||
import {usePhotoLibraryPermission} from 'lib/hooks/usePermissions'
|
import {usePhotoLibraryPermission} from 'lib/hooks/usePermissions'
|
||||||
import {
|
import {GalleryModel} from 'state/models/media/gallery'
|
||||||
POST_IMG_MAX_WIDTH,
|
|
||||||
POST_IMG_MAX_HEIGHT,
|
|
||||||
POST_IMG_MAX_SIZE,
|
|
||||||
} from 'lib/constants'
|
|
||||||
|
|
||||||
const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10}
|
const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10}
|
||||||
|
|
||||||
export function SelectPhotoBtn({
|
type Props = {
|
||||||
enabled,
|
gallery: GalleryModel
|
||||||
selectedPhotos,
|
}
|
||||||
onSelectPhotos,
|
|
||||||
}: {
|
export function SelectPhotoBtn({gallery}: Props) {
|
||||||
enabled: boolean
|
|
||||||
selectedPhotos: string[]
|
|
||||||
onSelectPhotos: (v: string[]) => void
|
|
||||||
}) {
|
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const {track} = useAnalytics()
|
const {track} = useAnalytics()
|
||||||
const store = useStores()
|
|
||||||
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
|
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
|
||||||
|
|
||||||
const onPressSelectPhotos = React.useCallback(async () => {
|
const onPressSelectPhotos = useCallback(async () => {
|
||||||
track('Composer:GalleryOpened')
|
track('Composer:GalleryOpened')
|
||||||
if (!enabled) {
|
|
||||||
|
if (!isDesktopWeb && !(await requestPhotoAccessIfNeeded())) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (isDesktopWeb) {
|
|
||||||
const images = await pickImagesFlow(
|
gallery.pick()
|
||||||
store,
|
}, [track, gallery, requestPhotoAccessIfNeeded])
|
||||||
4 - selectedPhotos.length,
|
|
||||||
{width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT},
|
|
||||||
POST_IMG_MAX_SIZE,
|
|
||||||
)
|
|
||||||
onSelectPhotos([...selectedPhotos, ...images])
|
|
||||||
} else {
|
|
||||||
if (!(await requestPhotoAccessIfNeeded())) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const items = await openPicker(store, {
|
|
||||||
multiple: true,
|
|
||||||
maxFiles: 4 - selectedPhotos.length,
|
|
||||||
mediaType: 'photo',
|
|
||||||
})
|
|
||||||
const result = []
|
|
||||||
for (const image of items) {
|
|
||||||
if (Platform.OS === 'android') {
|
|
||||||
result.push(image.path)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
result.push(
|
|
||||||
await cropAndCompressFlow(
|
|
||||||
store,
|
|
||||||
image.path,
|
|
||||||
image,
|
|
||||||
{width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT},
|
|
||||||
POST_IMG_MAX_SIZE,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onSelectPhotos([...selectedPhotos, ...result])
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
track,
|
|
||||||
store,
|
|
||||||
onSelectPhotos,
|
|
||||||
selectedPhotos,
|
|
||||||
enabled,
|
|
||||||
requestPhotoAccessIfNeeded,
|
|
||||||
])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
@ -90,11 +40,7 @@ export function SelectPhotoBtn({
|
||||||
hitSlop={HITSLOP}>
|
hitSlop={HITSLOP}>
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
icon={['far', 'image']}
|
icon={['far', 'image']}
|
||||||
style={
|
style={pal.link as FontAwesomeIconStyle}
|
||||||
(enabled
|
|
||||||
? pal.link
|
|
||||||
: [pal.textLight, s.dimmed]) as FontAwesomeIconStyle
|
|
||||||
}
|
|
||||||
size={24}
|
size={24}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
|
@ -1,96 +0,0 @@
|
||||||
import React, {useCallback} from 'react'
|
|
||||||
import {StyleSheet, TouchableOpacity, View} from 'react-native'
|
|
||||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
|
||||||
import {Image} from 'expo-image'
|
|
||||||
import {colors} from 'lib/styles'
|
|
||||||
|
|
||||||
export const SelectedPhotos = ({
|
|
||||||
selectedPhotos,
|
|
||||||
onSelectPhotos,
|
|
||||||
}: {
|
|
||||||
selectedPhotos: string[]
|
|
||||||
onSelectPhotos: (v: string[]) => void
|
|
||||||
}) => {
|
|
||||||
const imageStyle =
|
|
||||||
selectedPhotos.length === 1
|
|
||||||
? styles.image250
|
|
||||||
: selectedPhotos.length === 2
|
|
||||||
? styles.image175
|
|
||||||
: styles.image85
|
|
||||||
|
|
||||||
const handleRemovePhoto = useCallback(
|
|
||||||
item => {
|
|
||||||
onSelectPhotos(selectedPhotos.filter(filterItem => filterItem !== item))
|
|
||||||
},
|
|
||||||
[selectedPhotos, onSelectPhotos],
|
|
||||||
)
|
|
||||||
|
|
||||||
return selectedPhotos.length !== 0 ? (
|
|
||||||
<View testID="selectedPhotosView" style={styles.gallery}>
|
|
||||||
{selectedPhotos.length !== 0 &&
|
|
||||||
selectedPhotos.map((item, index) => (
|
|
||||||
<View
|
|
||||||
key={`selected-image-${index}`}
|
|
||||||
style={[styles.imageContainer, imageStyle]}>
|
|
||||||
<TouchableOpacity
|
|
||||||
testID="removePhotoButton"
|
|
||||||
onPress={() => handleRemovePhoto(item)}
|
|
||||||
style={styles.removePhotoButton}>
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon="xmark"
|
|
||||||
size={16}
|
|
||||||
style={{color: colors.white}}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<Image
|
|
||||||
testID="selectedPhotoImage"
|
|
||||||
style={[styles.image, imageStyle]}
|
|
||||||
source={{uri: item}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
gallery: {
|
|
||||||
flex: 1,
|
|
||||||
flexDirection: 'row',
|
|
||||||
marginTop: 16,
|
|
||||||
},
|
|
||||||
imageContainer: {
|
|
||||||
margin: 2,
|
|
||||||
},
|
|
||||||
image: {
|
|
||||||
resizeMode: 'cover',
|
|
||||||
borderRadius: 8,
|
|
||||||
},
|
|
||||||
image250: {
|
|
||||||
width: 250,
|
|
||||||
height: 250,
|
|
||||||
},
|
|
||||||
image175: {
|
|
||||||
width: 175,
|
|
||||||
height: 175,
|
|
||||||
},
|
|
||||||
image85: {
|
|
||||||
width: 85,
|
|
||||||
height: 85,
|
|
||||||
},
|
|
||||||
removePhotoButton: {
|
|
||||||
position: 'absolute',
|
|
||||||
top: 8,
|
|
||||||
right: 8,
|
|
||||||
width: 24,
|
|
||||||
height: 24,
|
|
||||||
borderRadius: 12,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
backgroundColor: colors.black,
|
|
||||||
zIndex: 1,
|
|
||||||
borderColor: colors.gray4,
|
|
||||||
borderWidth: 0.5,
|
|
||||||
},
|
|
||||||
})
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React from 'react'
|
import React, {forwardRef, useCallback, useEffect, useRef, useMemo} from 'react'
|
||||||
import {
|
import {
|
||||||
NativeSyntheticEvent,
|
NativeSyntheticEvent,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
|
@ -14,18 +14,13 @@ import isEqual from 'lodash.isequal'
|
||||||
import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
|
import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
|
||||||
import {Autocomplete} from './mobile/Autocomplete'
|
import {Autocomplete} from './mobile/Autocomplete'
|
||||||
import {Text} from 'view/com/util/text/Text'
|
import {Text} from 'view/com/util/text/Text'
|
||||||
import {useStores} from 'state/index'
|
|
||||||
import {cleanError} from 'lib/strings/errors'
|
import {cleanError} from 'lib/strings/errors'
|
||||||
import {getImageDim} from 'lib/media/manip'
|
|
||||||
import {cropAndCompressFlow} from 'lib/media/picker'
|
|
||||||
import {getMentionAt, insertMentionAt} from 'lib/strings/mention-manip'
|
import {getMentionAt, insertMentionAt} from 'lib/strings/mention-manip'
|
||||||
import {
|
|
||||||
POST_IMG_MAX_WIDTH,
|
|
||||||
POST_IMG_MAX_HEIGHT,
|
|
||||||
POST_IMG_MAX_SIZE,
|
|
||||||
} from 'lib/constants'
|
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {useTheme} from 'lib/ThemeContext'
|
import {useTheme} from 'lib/ThemeContext'
|
||||||
|
import {isUriImage} from 'lib/media/util'
|
||||||
|
import {downloadAndResize} from 'lib/media/manip'
|
||||||
|
import {POST_IMG_MAX} from 'lib/constants'
|
||||||
|
|
||||||
export interface TextInputRef {
|
export interface TextInputRef {
|
||||||
focus: () => void
|
focus: () => void
|
||||||
|
@ -48,7 +43,7 @@ interface Selection {
|
||||||
end: number
|
end: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TextInput = React.forwardRef(
|
export const TextInput = forwardRef(
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
richtext,
|
richtext,
|
||||||
|
@ -63,9 +58,8 @@ export const TextInput = React.forwardRef(
|
||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const store = useStores()
|
const textInput = useRef<PasteInputRef>(null)
|
||||||
const textInput = React.useRef<PasteInputRef>(null)
|
const textInputSelection = useRef<Selection>({start: 0, end: 0})
|
||||||
const textInputSelection = React.useRef<Selection>({start: 0, end: 0})
|
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
|
|
||||||
React.useImperativeHandle(ref, () => ({
|
React.useImperativeHandle(ref, () => ({
|
||||||
|
@ -73,7 +67,7 @@ export const TextInput = React.forwardRef(
|
||||||
blur: () => textInput.current?.blur(),
|
blur: () => textInput.current?.blur(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
// HACK
|
// HACK
|
||||||
// wait a moment before focusing the input to resolve some layout bugs with the keyboard-avoiding-view
|
// wait a moment before focusing the input to resolve some layout bugs with the keyboard-avoiding-view
|
||||||
// -prf
|
// -prf
|
||||||
|
@ -90,8 +84,8 @@ export const TextInput = React.forwardRef(
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const onChangeText = React.useCallback(
|
const onChangeText = useCallback(
|
||||||
(newText: string) => {
|
async (newText: string) => {
|
||||||
const newRt = new RichText({text: newText})
|
const newRt = new RichText({text: newText})
|
||||||
newRt.detectFacetsWithoutResolution()
|
newRt.detectFacetsWithoutResolution()
|
||||||
setRichText(newRt)
|
setRichText(newRt)
|
||||||
|
@ -108,50 +102,62 @@ export const TextInput = React.forwardRef(
|
||||||
}
|
}
|
||||||
|
|
||||||
const set: Set<string> = new Set()
|
const set: Set<string> = new Set()
|
||||||
|
|
||||||
if (newRt.facets) {
|
if (newRt.facets) {
|
||||||
for (const facet of newRt.facets) {
|
for (const facet of newRt.facets) {
|
||||||
for (const feature of facet.features) {
|
for (const feature of facet.features) {
|
||||||
if (AppBskyRichtextFacet.isLink(feature)) {
|
if (AppBskyRichtextFacet.isLink(feature)) {
|
||||||
|
if (isUriImage(feature.uri)) {
|
||||||
|
const res = await downloadAndResize({
|
||||||
|
uri: feature.uri,
|
||||||
|
width: POST_IMG_MAX.width,
|
||||||
|
height: POST_IMG_MAX.height,
|
||||||
|
mode: 'contain',
|
||||||
|
maxSize: POST_IMG_MAX.size,
|
||||||
|
timeout: 15e3,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res !== undefined) {
|
||||||
|
onPhotoPasted(res.path)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
set.add(feature.uri)
|
set.add(feature.uri)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!isEqual(set, suggestedLinks)) {
|
if (!isEqual(set, suggestedLinks)) {
|
||||||
onSuggestedLinksChanged(set)
|
onSuggestedLinksChanged(set)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[setRichText, autocompleteView, suggestedLinks, onSuggestedLinksChanged],
|
[
|
||||||
|
setRichText,
|
||||||
|
autocompleteView,
|
||||||
|
suggestedLinks,
|
||||||
|
onSuggestedLinksChanged,
|
||||||
|
onPhotoPasted,
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
const onPaste = React.useCallback(
|
const onPaste = useCallback(
|
||||||
async (err: string | undefined, files: PastedFile[]) => {
|
async (err: string | undefined, files: PastedFile[]) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
return onError(cleanError(err))
|
return onError(cleanError(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
const uris = files.map(f => f.uri)
|
const uris = files.map(f => f.uri)
|
||||||
const imgUri = uris.find(uri => /\.(jpe?g|png)$/.test(uri))
|
const uri = uris.find(isUriImage)
|
||||||
if (imgUri) {
|
|
||||||
let imgDim
|
if (uri) {
|
||||||
try {
|
onPhotoPasted(uri)
|
||||||
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,
|
|
||||||
)
|
|
||||||
onPhotoPasted(finalImgPath)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[store, onError, onPhotoPasted],
|
[onError, onPhotoPasted],
|
||||||
)
|
)
|
||||||
|
|
||||||
const onSelectionChange = React.useCallback(
|
const onSelectionChange = useCallback(
|
||||||
(evt: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => {
|
(evt: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => {
|
||||||
// NOTE we track the input selection using a ref to avoid excessive renders -prf
|
// NOTE we track the input selection using a ref to avoid excessive renders -prf
|
||||||
textInputSelection.current = evt.nativeEvent.selection
|
textInputSelection.current = evt.nativeEvent.selection
|
||||||
|
@ -159,7 +165,7 @@ export const TextInput = React.forwardRef(
|
||||||
[textInputSelection],
|
[textInputSelection],
|
||||||
)
|
)
|
||||||
|
|
||||||
const onSelectAutocompleteItem = React.useCallback(
|
const onSelectAutocompleteItem = useCallback(
|
||||||
(item: string) => {
|
(item: string) => {
|
||||||
onChangeText(
|
onChangeText(
|
||||||
insertMentionAt(
|
insertMentionAt(
|
||||||
|
@ -173,23 +179,19 @@ export const TextInput = React.forwardRef(
|
||||||
[onChangeText, richtext, autocompleteView],
|
[onChangeText, richtext, autocompleteView],
|
||||||
)
|
)
|
||||||
|
|
||||||
const textDecorated = React.useMemo(() => {
|
const textDecorated = useMemo(() => {
|
||||||
let i = 0
|
let i = 0
|
||||||
return Array.from(richtext.segments()).map(segment => {
|
|
||||||
if (!segment.facet) {
|
return Array.from(richtext.segments()).map(segment => (
|
||||||
return (
|
<Text
|
||||||
<Text key={i++} style={[pal.text, styles.textInputFormatting]}>
|
key={i++}
|
||||||
|
style={[
|
||||||
|
!segment.facet ? pal.text : pal.link,
|
||||||
|
styles.textInputFormatting,
|
||||||
|
]}>
|
||||||
{segment.text}
|
{segment.text}
|
||||||
</Text>
|
</Text>
|
||||||
)
|
))
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<Text key={i++} style={[pal.link, styles.textInputFormatting]}>
|
|
||||||
{segment.text}
|
|
||||||
</Text>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}, [richtext, pal.link, pal.text])
|
}, [richtext, pal.link, pal.text])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -223,7 +225,6 @@ const styles = StyleSheet.create({
|
||||||
textInput: {
|
textInput: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
minHeight: 80,
|
|
||||||
padding: 5,
|
padding: 5,
|
||||||
paddingBottom: 20,
|
paddingBottom: 20,
|
||||||
marginLeft: 8,
|
marginLeft: 8,
|
||||||
|
|
|
@ -12,6 +12,7 @@ import isEqual from 'lodash.isequal'
|
||||||
import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
|
import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete'
|
||||||
import {createSuggestion} from './web/Autocomplete'
|
import {createSuggestion} from './web/Autocomplete'
|
||||||
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
|
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
|
||||||
|
import {isUriImage, blobToDataUri} from 'lib/media/util'
|
||||||
|
|
||||||
export interface TextInputRef {
|
export interface TextInputRef {
|
||||||
focus: () => void
|
focus: () => void
|
||||||
|
@ -37,7 +38,7 @@ export const TextInput = React.forwardRef(
|
||||||
suggestedLinks,
|
suggestedLinks,
|
||||||
autocompleteView,
|
autocompleteView,
|
||||||
setRichText,
|
setRichText,
|
||||||
// onPhotoPasted, TODO
|
onPhotoPasted,
|
||||||
onSuggestedLinksChanged,
|
onSuggestedLinksChanged,
|
||||||
}: // onError, TODO
|
}: // onError, TODO
|
||||||
TextInputProps,
|
TextInputProps,
|
||||||
|
@ -72,6 +73,15 @@ export const TextInput = React.forwardRef(
|
||||||
attributes: {
|
attributes: {
|
||||||
class: modeClass,
|
class: modeClass,
|
||||||
},
|
},
|
||||||
|
handlePaste: (_, event) => {
|
||||||
|
const items = event.clipboardData?.items
|
||||||
|
|
||||||
|
if (items === undefined) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
getImageFromUri(items, onPhotoPasted)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
content: richtext.text.toString(),
|
content: richtext.text.toString(),
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
|
@ -147,3 +157,33 @@ const styles = StyleSheet.create({
|
||||||
marginBottom: 10,
|
marginBottom: 10,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function getImageFromUri(
|
||||||
|
items: DataTransferItemList,
|
||||||
|
callback: (uri: string) => void,
|
||||||
|
) {
|
||||||
|
for (let index = 0; index < items.length; index++) {
|
||||||
|
const item = items[index]
|
||||||
|
const {kind, type} = item
|
||||||
|
|
||||||
|
if (type === 'text/plain') {
|
||||||
|
item.getAsString(async itemString => {
|
||||||
|
if (isUriImage(itemString)) {
|
||||||
|
const response = await fetch(itemString)
|
||||||
|
const blob = await response.blob()
|
||||||
|
blobToDataUri(blob).then(callback, err => console.error(err))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === 'file') {
|
||||||
|
const file = item.getAsFile()
|
||||||
|
|
||||||
|
if (file instanceof Blob) {
|
||||||
|
blobToDataUri(new Blob([file], {type: item.type})).then(callback, err =>
|
||||||
|
console.error(err),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@ import {getPostAsQuote} from 'lib/link-meta/bsky'
|
||||||
import {downloadAndResize} from 'lib/media/manip'
|
import {downloadAndResize} from 'lib/media/manip'
|
||||||
import {isBskyPostUrl} from 'lib/strings/url-helpers'
|
import {isBskyPostUrl} from 'lib/strings/url-helpers'
|
||||||
import {ComposerOpts} from 'state/models/ui/shell'
|
import {ComposerOpts} from 'state/models/ui/shell'
|
||||||
|
import {POST_IMG_MAX} from 'lib/constants'
|
||||||
|
|
||||||
export function useExternalLinkFetch({
|
export function useExternalLinkFetch({
|
||||||
setQuote,
|
setQuote,
|
||||||
|
@ -55,13 +56,12 @@ export function useExternalLinkFetch({
|
||||||
return cleanup
|
return cleanup
|
||||||
}
|
}
|
||||||
if (extLink.isLoading && extLink.meta?.image && !extLink.localThumb) {
|
if (extLink.isLoading && extLink.meta?.image && !extLink.localThumb) {
|
||||||
console.log('attempting download')
|
|
||||||
downloadAndResize({
|
downloadAndResize({
|
||||||
uri: extLink.meta.image,
|
uri: extLink.meta.image,
|
||||||
width: 2000,
|
width: POST_IMG_MAX.width,
|
||||||
height: 2000,
|
height: POST_IMG_MAX.height,
|
||||||
mode: 'contain',
|
mode: 'contain',
|
||||||
maxSize: 1000000,
|
maxSize: POST_IMG_MAX.size,
|
||||||
timeout: 15e3,
|
timeout: 15e3,
|
||||||
})
|
})
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
|
|
|
@ -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 '../../../lib/media/picker'
|
import {Image as RNImage} from 'react-native-image-crop-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'
|
||||||
|
@ -53,15 +53,15 @@ export function Component({
|
||||||
profileView.avatar,
|
profileView.avatar,
|
||||||
)
|
)
|
||||||
const [newUserBanner, setNewUserBanner] = useState<
|
const [newUserBanner, setNewUserBanner] = useState<
|
||||||
PickedMedia | undefined | null
|
RNImage | undefined | null
|
||||||
>()
|
>()
|
||||||
const [newUserAvatar, setNewUserAvatar] = useState<
|
const [newUserAvatar, setNewUserAvatar] = useState<
|
||||||
PickedMedia | undefined | null
|
RNImage | undefined | null
|
||||||
>()
|
>()
|
||||||
const onPressCancel = () => {
|
const onPressCancel = () => {
|
||||||
store.shell.closeModal()
|
store.shell.closeModal()
|
||||||
}
|
}
|
||||||
const onSelectNewAvatar = async (img: PickedMedia | null) => {
|
const onSelectNewAvatar = async (img: RNImage | null) => {
|
||||||
track('EditProfile:AvatarSelected')
|
track('EditProfile:AvatarSelected')
|
||||||
try {
|
try {
|
||||||
// if img is null, user selected "remove avatar"
|
// if img is null, user selected "remove avatar"
|
||||||
|
@ -71,13 +71,13 @@ export function Component({
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const finalImg = await compressIfNeeded(img, 1000000)
|
const finalImg = await compressIfNeeded(img, 1000000)
|
||||||
setNewUserAvatar({mediaType: 'photo', ...finalImg})
|
setNewUserAvatar(finalImg)
|
||||||
setUserAvatar(finalImg.path)
|
setUserAvatar(finalImg.path)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(cleanError(e))
|
setError(cleanError(e))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const onSelectNewBanner = async (img: PickedMedia | null) => {
|
const onSelectNewBanner = async (img: RNImage | null) => {
|
||||||
if (!img) {
|
if (!img) {
|
||||||
setNewUserBanner(null)
|
setNewUserBanner(null)
|
||||||
setUserBanner(null)
|
setUserBanner(null)
|
||||||
|
@ -86,7 +86,7 @@ export function Component({
|
||||||
track('EditProfile:BannerSelected')
|
track('EditProfile:BannerSelected')
|
||||||
try {
|
try {
|
||||||
const finalImg = await compressIfNeeded(img, 1000000)
|
const finalImg = await compressIfNeeded(img, 1000000)
|
||||||
setNewUserBanner({mediaType: 'photo', ...finalImg})
|
setNewUserBanner(finalImg)
|
||||||
setUserBanner(finalImg.path)
|
setUserBanner(finalImg.path)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(cleanError(e))
|
setError(cleanError(e))
|
||||||
|
|
|
@ -4,7 +4,7 @@ import ImageEditor from 'react-avatar-editor'
|
||||||
import {Slider} from '@miblanchard/react-native-slider'
|
import {Slider} from '@miblanchard/react-native-slider'
|
||||||
import LinearGradient from 'react-native-linear-gradient'
|
import LinearGradient from 'react-native-linear-gradient'
|
||||||
import {Text} from 'view/com/util/text/Text'
|
import {Text} from 'view/com/util/text/Text'
|
||||||
import {PickedMedia} from 'lib/media/types'
|
import {Dimensions, Image} from 'lib/media/types'
|
||||||
import {getDataUriSize} from 'lib/media/util'
|
import {getDataUriSize} from 'lib/media/util'
|
||||||
import {s, gradients} from 'lib/styles'
|
import {s, gradients} from 'lib/styles'
|
||||||
import {useStores} from 'state/index'
|
import {useStores} from 'state/index'
|
||||||
|
@ -16,11 +16,8 @@ enum AspectRatio {
|
||||||
Wide = 'wide',
|
Wide = 'wide',
|
||||||
Tall = 'tall',
|
Tall = 'tall',
|
||||||
}
|
}
|
||||||
interface Dim {
|
|
||||||
width: number
|
const DIMS: Record<string, Dimensions> = {
|
||||||
height: number
|
|
||||||
}
|
|
||||||
const DIMS: Record<string, Dim> = {
|
|
||||||
[AspectRatio.Square]: {width: 1000, height: 1000},
|
[AspectRatio.Square]: {width: 1000, height: 1000},
|
||||||
[AspectRatio.Wide]: {width: 1000, height: 750},
|
[AspectRatio.Wide]: {width: 1000, height: 750},
|
||||||
[AspectRatio.Tall]: {width: 750, height: 1000},
|
[AspectRatio.Tall]: {width: 750, height: 1000},
|
||||||
|
@ -33,7 +30,7 @@ export function Component({
|
||||||
onSelect,
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
uri: string
|
uri: string
|
||||||
onSelect: (img?: PickedMedia) => void
|
onSelect: (img?: Image) => void
|
||||||
}) {
|
}) {
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
|
@ -52,7 +49,6 @@ export function Component({
|
||||||
if (canvas) {
|
if (canvas) {
|
||||||
const dataUri = canvas.toDataURL('image/jpeg')
|
const dataUri = canvas.toDataURL('image/jpeg')
|
||||||
onSelect({
|
onSelect({
|
||||||
mediaType: 'photo',
|
|
||||||
path: dataUri,
|
path: dataUri,
|
||||||
mime: 'image/jpeg',
|
mime: 'image/jpeg',
|
||||||
size: getDataUriSize(dataUri),
|
size: getDataUriSize(dataUri),
|
||||||
|
|
|
@ -4,12 +4,7 @@ import Svg, {Circle, Path} from 'react-native-svg'
|
||||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||||
import {IconProp} from '@fortawesome/fontawesome-svg-core'
|
import {IconProp} from '@fortawesome/fontawesome-svg-core'
|
||||||
import {HighPriorityImage} from 'view/com/util/images/Image'
|
import {HighPriorityImage} from 'view/com/util/images/Image'
|
||||||
import {
|
import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
|
||||||
openCamera,
|
|
||||||
openCropper,
|
|
||||||
openPicker,
|
|
||||||
PickedMedia,
|
|
||||||
} from '../../../lib/media/picker'
|
|
||||||
import {
|
import {
|
||||||
usePhotoLibraryPermission,
|
usePhotoLibraryPermission,
|
||||||
useCameraPermission,
|
useCameraPermission,
|
||||||
|
@ -19,6 +14,7 @@ import {colors} from 'lib/styles'
|
||||||
import {DropdownButton} from './forms/DropdownButton'
|
import {DropdownButton} from './forms/DropdownButton'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {isWeb} from 'platform/detection'
|
import {isWeb} from 'platform/detection'
|
||||||
|
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||||
|
|
||||||
function DefaultAvatar({size}: {size: number}) {
|
function DefaultAvatar({size}: {size: number}) {
|
||||||
return (
|
return (
|
||||||
|
@ -50,7 +46,7 @@ export function UserAvatar({
|
||||||
size: number
|
size: number
|
||||||
avatar?: string | null
|
avatar?: string | null
|
||||||
hasWarning?: boolean
|
hasWarning?: boolean
|
||||||
onSelectNewAvatar?: (img: PickedMedia | null) => void
|
onSelectNewAvatar?: (img: RNImage | null) => void
|
||||||
}) {
|
}) {
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
|
@ -68,7 +64,6 @@ export function UserAvatar({
|
||||||
}
|
}
|
||||||
onSelectNewAvatar?.(
|
onSelectNewAvatar?.(
|
||||||
await openCamera(store, {
|
await openCamera(store, {
|
||||||
mediaType: 'photo',
|
|
||||||
width: 1000,
|
width: 1000,
|
||||||
height: 1000,
|
height: 1000,
|
||||||
cropperCircleOverlay: true,
|
cropperCircleOverlay: true,
|
||||||
|
@ -84,9 +79,8 @@ export function UserAvatar({
|
||||||
if (!(await requestPhotoAccessIfNeeded())) {
|
if (!(await requestPhotoAccessIfNeeded())) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const items = await openPicker(store, {
|
const items = await openPicker(store)
|
||||||
mediaType: 'photo',
|
|
||||||
})
|
|
||||||
onSelectNewAvatar?.(
|
onSelectNewAvatar?.(
|
||||||
await openCropper(store, {
|
await openCropper(store, {
|
||||||
mediaType: 'photo',
|
mediaType: 'photo',
|
||||||
|
|
|
@ -4,12 +4,8 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||||
import {IconProp} from '@fortawesome/fontawesome-svg-core'
|
import {IconProp} from '@fortawesome/fontawesome-svg-core'
|
||||||
import {Image} from 'expo-image'
|
import {Image} from 'expo-image'
|
||||||
import {colors} from 'lib/styles'
|
import {colors} from 'lib/styles'
|
||||||
import {
|
import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
|
||||||
openCamera,
|
import {Image as TImage} from 'lib/media/types'
|
||||||
openCropper,
|
|
||||||
openPicker,
|
|
||||||
PickedMedia,
|
|
||||||
} from '../../../lib/media/picker'
|
|
||||||
import {useStores} from 'state/index'
|
import {useStores} from 'state/index'
|
||||||
import {
|
import {
|
||||||
usePhotoLibraryPermission,
|
usePhotoLibraryPermission,
|
||||||
|
@ -24,7 +20,7 @@ export function UserBanner({
|
||||||
onSelectNewBanner,
|
onSelectNewBanner,
|
||||||
}: {
|
}: {
|
||||||
banner?: string | null
|
banner?: string | null
|
||||||
onSelectNewBanner?: (img: PickedMedia | null) => void
|
onSelectNewBanner?: (img: TImage | null) => void
|
||||||
}) {
|
}) {
|
||||||
const store = useStores()
|
const store = useStores()
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
|
@ -42,7 +38,6 @@ export function UserBanner({
|
||||||
}
|
}
|
||||||
onSelectNewBanner?.(
|
onSelectNewBanner?.(
|
||||||
await openCamera(store, {
|
await openCamera(store, {
|
||||||
mediaType: 'photo',
|
|
||||||
// compressImageMaxWidth: 3000, TODO needed?
|
// compressImageMaxWidth: 3000, TODO needed?
|
||||||
width: 3000,
|
width: 3000,
|
||||||
// compressImageMaxHeight: 1000, TODO needed?
|
// compressImageMaxHeight: 1000, TODO needed?
|
||||||
|
@ -59,9 +54,7 @@ export function UserBanner({
|
||||||
if (!(await requestPhotoAccessIfNeeded())) {
|
if (!(await requestPhotoAccessIfNeeded())) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const items = await openPicker(store, {
|
const items = await openPicker(store)
|
||||||
mediaType: 'photo',
|
|
||||||
})
|
|
||||||
onSelectNewBanner?.(
|
onSelectNewBanner?.(
|
||||||
await openCropper(store, {
|
await openCropper(store, {
|
||||||
mediaType: 'photo',
|
mediaType: 'photo',
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import React from 'react'
|
import {Dimensions} from 'lib/media/types'
|
||||||
|
import React, {useState} from 'react'
|
||||||
import {
|
import {
|
||||||
LayoutChangeEvent,
|
LayoutChangeEvent,
|
||||||
StyleProp,
|
StyleProp,
|
||||||
|
@ -11,11 +12,6 @@ import {Image, ImageStyle} from 'expo-image'
|
||||||
|
|
||||||
export const DELAY_PRESS_IN = 500
|
export const DELAY_PRESS_IN = 500
|
||||||
|
|
||||||
interface Dim {
|
|
||||||
width: number
|
|
||||||
height: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ImageLayoutGridType = 'two' | 'three' | 'four'
|
export type ImageLayoutGridType = 'two' | 'three' | 'four'
|
||||||
|
|
||||||
export function ImageLayoutGrid({
|
export function ImageLayoutGrid({
|
||||||
|
@ -33,7 +29,7 @@ export function ImageLayoutGrid({
|
||||||
onPressIn?: (index: number) => void
|
onPressIn?: (index: number) => void
|
||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
}) {
|
}) {
|
||||||
const [containerInfo, setContainerInfo] = React.useState<Dim | undefined>()
|
const [containerInfo, setContainerInfo] = useState<Dimensions | undefined>()
|
||||||
|
|
||||||
const onLayout = (evt: LayoutChangeEvent) => {
|
const onLayout = (evt: LayoutChangeEvent) => {
|
||||||
setContainerInfo({
|
setContainerInfo({
|
||||||
|
@ -71,7 +67,7 @@ function ImageLayoutGridInner({
|
||||||
onPress?: (index: number) => void
|
onPress?: (index: number) => void
|
||||||
onLongPress?: (index: number) => void
|
onLongPress?: (index: number) => void
|
||||||
onPressIn?: (index: number) => void
|
onPressIn?: (index: number) => void
|
||||||
containerInfo: Dim
|
containerInfo: Dimensions
|
||||||
}) {
|
}) {
|
||||||
const size1 = React.useMemo<ImageStyle>(() => {
|
const size1 = React.useMemo<ImageStyle>(() => {
|
||||||
if (type === 'three') {
|
if (type === 'three') {
|
||||||
|
|
Loading…
Reference in New Issue