Merge branch 'rnw' of github.com:bluesky-social/social-app into rnw
commit
693cbb9f18
|
@ -1,4 +1,7 @@
|
|||
import {downloadAndResize, DownloadAndResizeOpts} from '../../src/lib/images'
|
||||
import {
|
||||
downloadAndResize,
|
||||
DownloadAndResizeOpts,
|
||||
} from '../../src/lib/media/manip'
|
||||
import ImageResizer from '@bam.tech/react-native-image-resizer'
|
||||
import RNFetchBlob from 'rn-fetch-blob'
|
||||
|
||||
|
|
1
index.js
1
index.js
|
@ -2,6 +2,7 @@
|
|||
* @format
|
||||
*/
|
||||
|
||||
import 'platform/polyfills'
|
||||
import {AppRegistry} from 'react-native'
|
||||
import App from './src/App'
|
||||
import {name as appName} from './src/app.json'
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
// index.web.js
|
||||
|
||||
import 'platform/polyfills'
|
||||
import {AppRegistry} from 'react-native'
|
||||
import App from './src/App'
|
||||
import {name as appName} from './src/app.json'
|
||||
|
|
|
@ -1,11 +1,16 @@
|
|||
import {AppBskyEmbedImages, AppBskyEmbedExternal} from '@atproto/api'
|
||||
import {
|
||||
AppBskyEmbedImages,
|
||||
AppBskyEmbedExternal,
|
||||
ComAtprotoBlobUpload,
|
||||
} from '@atproto/api'
|
||||
import {AtUri} from '../../third-party/uri'
|
||||
import {RootStoreModel} from 'state/models/root-store'
|
||||
import {extractEntities} from 'lib/strings/rich-text-detection'
|
||||
import {isNetworkError} from 'lib/strings/errors'
|
||||
import {LinkMeta} from '../link-meta/link-meta'
|
||||
import {Image} from '../images'
|
||||
import {Image} from '../media/manip'
|
||||
import {RichText} from '../strings/rich-text'
|
||||
import {isWeb} from 'platform/detection'
|
||||
|
||||
export interface ExternalEmbedDraft {
|
||||
uri: string
|
||||
|
@ -27,6 +32,25 @@ export async function resolveName(store: RootStoreModel, didOrHandle: string) {
|
|||
return res.data.did
|
||||
}
|
||||
|
||||
export async function uploadBlob(
|
||||
store: RootStoreModel,
|
||||
blob: string,
|
||||
encoding: string,
|
||||
): Promise<ComAtprotoBlobUpload.Response> {
|
||||
if (isWeb) {
|
||||
// `blob` should be a data uri
|
||||
return store.api.com.atproto.blob.upload(convertDataURIToUint8Array(blob), {
|
||||
encoding,
|
||||
})
|
||||
} else {
|
||||
// `blob` should be a path to a file in the local FS
|
||||
return store.api.com.atproto.blob.upload(
|
||||
blob, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts
|
||||
{encoding},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function post(
|
||||
store: RootStoreModel,
|
||||
rawText: string,
|
||||
|
@ -61,10 +85,7 @@ export async function post(
|
|||
let i = 1
|
||||
for (const image of images) {
|
||||
onStateChange?.(`Uploading image #${i++}...`)
|
||||
const res = await store.api.com.atproto.blob.upload(
|
||||
image, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts
|
||||
{encoding: 'image/jpeg'},
|
||||
)
|
||||
const res = await uploadBlob(store, image, 'image/jpeg')
|
||||
embed.images.push({
|
||||
image: {
|
||||
cid: res.data.cid,
|
||||
|
@ -94,9 +115,10 @@ export async function post(
|
|||
)
|
||||
}
|
||||
if (encoding) {
|
||||
const thumbUploadRes = await store.api.com.atproto.blob.upload(
|
||||
extLink.localThumb.path, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts
|
||||
{encoding},
|
||||
const thumbUploadRes = await uploadBlob(
|
||||
store,
|
||||
extLink.localThumb.path,
|
||||
encoding,
|
||||
)
|
||||
thumb = {
|
||||
cid: thumbUploadRes.data.cid,
|
||||
|
@ -199,3 +221,15 @@ export async function unfollow(store: RootStoreModel, followUri: string) {
|
|||
rkey: followUrip.rkey,
|
||||
})
|
||||
}
|
||||
|
||||
// helpers
|
||||
// =
|
||||
|
||||
function convertDataURIToUint8Array(uri: string): Uint8Array {
|
||||
var raw = window.atob(uri.substring(uri.indexOf(';base64,') + 8))
|
||||
var binary = new Uint8Array(new ArrayBuffer(raw.length))
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
binary[i] = raw.charCodeAt(i)
|
||||
}
|
||||
return binary
|
||||
}
|
||||
|
|
|
@ -63,3 +63,7 @@ export const STAGING_SUGGESTED_FOLLOWS = ['arcalinea', 'paul', 'paul2'].map(
|
|||
export const DEV_SUGGESTED_FOLLOWS = ['alice', 'bob', 'carla'].map(
|
||||
handle => `${handle}.test`,
|
||||
)
|
||||
|
||||
export const POST_IMG_MAX_WIDTH = 2000
|
||||
export const POST_IMG_MAX_HEIGHT = 2000
|
||||
export const POST_IMG_MAX_SIZE = 1000000
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import RNFetchBlob from 'rn-fetch-blob'
|
||||
import ImageResizer from '@bam.tech/react-native-image-resizer'
|
||||
import {Share} from 'react-native'
|
||||
import {Image as RNImage, Share} from 'react-native'
|
||||
import RNFS from 'react-native-fs'
|
||||
import uuid from 'react-native-uuid'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
|
@ -135,7 +135,7 @@ export function scaleDownDimensions(dim: Dim, max: Dim): Dim {
|
|||
return {width: dim.width * hScale, height: dim.height * hScale}
|
||||
}
|
||||
|
||||
export const saveImageModal = async ({uri}: {uri: string}) => {
|
||||
export async function saveImageModal({uri}: {uri: string}) {
|
||||
const downloadResponse = await RNFetchBlob.config({
|
||||
fileCache: true,
|
||||
}).fetch('GET', uri)
|
||||
|
@ -153,7 +153,7 @@ export const saveImageModal = async ({uri}: {uri: string}) => {
|
|||
RNFS.unlink(imagePath)
|
||||
}
|
||||
|
||||
export const moveToPremanantPath = async (path: string) => {
|
||||
export async function moveToPremanantPath(path: string) {
|
||||
/*
|
||||
Since this package stores images in a temp directory, we need to move the file to a permanent location.
|
||||
Relevant: IOS bug when trying to open a second time:
|
||||
|
@ -164,3 +164,15 @@ export const moveToPremanantPath = async (path: string) => {
|
|||
RNFS.moveFile(path, destinationPath)
|
||||
return destinationPath
|
||||
}
|
||||
|
||||
export function getImageDim(path: string): Promise<Dim> {
|
||||
return new Promise((resolve, reject) => {
|
||||
RNImage.getSize(
|
||||
path,
|
||||
(width, height) => {
|
||||
resolve({width, height})
|
||||
},
|
||||
reject,
|
||||
)
|
||||
})
|
||||
}
|
|
@ -39,11 +39,16 @@ export async function resize(
|
|||
}
|
||||
|
||||
export async function compressIfNeeded(
|
||||
_img: Image,
|
||||
_maxSize: number,
|
||||
img: Image,
|
||||
maxSize: number,
|
||||
): Promise<Image> {
|
||||
if (img.size > maxSize) {
|
||||
// TODO
|
||||
throw new Error('TODO')
|
||||
throw new Error(
|
||||
"This image is too large and we haven't implemented compression yet -- sorry!",
|
||||
)
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
export interface Dim {
|
||||
|
@ -62,7 +67,22 @@ export function scaleDownDimensions(dim: Dim, max: Dim): Dim {
|
|||
return {width: dim.width * hScale, height: dim.height * hScale}
|
||||
}
|
||||
|
||||
export const saveImageModal = async (_opts: {uri: string}) => {
|
||||
export async function saveImageModal(_opts: {uri: string}) {
|
||||
// TODO
|
||||
throw new Error('TODO')
|
||||
}
|
||||
|
||||
export async function moveToPremanantPath(path: string) {
|
||||
return path
|
||||
}
|
||||
|
||||
export async function getImageDim(path: string): Promise<Dim> {
|
||||
var img = document.createElement('img')
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
img.onload = resolve
|
||||
img.onerror = reject
|
||||
})
|
||||
img.src = path
|
||||
await promise
|
||||
return {width: img.width, height: img.height}
|
||||
}
|
|
@ -6,6 +6,12 @@ import {
|
|||
} from 'react-native-image-crop-picker'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types'
|
||||
import {
|
||||
scaleDownDimensions,
|
||||
Dim,
|
||||
compressIfNeeded,
|
||||
moveToPremanantPath,
|
||||
} from 'lib/media/manip'
|
||||
export type {PickedMedia} from './types'
|
||||
|
||||
/**
|
||||
|
@ -90,3 +96,46 @@ export async function openCropper(
|
|||
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
|
||||
}
|
|
@ -0,0 +1,144 @@
|
|||
/// <reference lib="dom" />
|
||||
|
||||
import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types'
|
||||
export type {PickedMedia} from './types'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
import {
|
||||
scaleDownDimensions,
|
||||
getImageDim,
|
||||
Dim,
|
||||
compressIfNeeded,
|
||||
moveToPremanantPath,
|
||||
} from 'lib/media/manip'
|
||||
|
||||
interface PickedFile {
|
||||
uri: string
|
||||
path: string
|
||||
size: number
|
||||
}
|
||||
|
||||
export async function openPicker(
|
||||
_store: RootStoreModel,
|
||||
opts: PickerOpts,
|
||||
): Promise<PickedMedia[]> {
|
||||
const res = await selectFile(opts)
|
||||
const dim = await getImageDim(res.uri)
|
||||
const mime = extractDataUriMime(res.uri)
|
||||
return [
|
||||
{
|
||||
mediaType: 'photo',
|
||||
path: res.uri,
|
||||
mime,
|
||||
size: res.size,
|
||||
width: dim.width,
|
||||
height: dim.height,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export async function openCamera(
|
||||
_store: RootStoreModel,
|
||||
_opts: CameraOpts,
|
||||
): Promise<PickedMedia> {
|
||||
// const mediaType = opts.mediaType || 'photo' TODO
|
||||
throw new Error('TODO')
|
||||
}
|
||||
|
||||
export async function openCropper(
|
||||
store: RootStoreModel,
|
||||
opts: CropperOpts,
|
||||
): Promise<PickedMedia> {
|
||||
// TODO handle more opts
|
||||
return new Promise((resolve, reject) => {
|
||||
store.shell.openModal({
|
||||
name: 'crop-image',
|
||||
uri: opts.path,
|
||||
onSelect: (img?: PickedMedia) => {
|
||||
if (img) {
|
||||
resolve(img)
|
||||
} else {
|
||||
reject(new Error('Canceled'))
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function pickImagesFlow(
|
||||
store: RootStoreModel,
|
||||
maxFiles: number,
|
||||
maxDim: Dim,
|
||||
maxSize: number,
|
||||
) {
|
||||
const items = await openPicker(store, {
|
||||
multiple: true,
|
||||
maxFiles,
|
||||
mediaType: 'photo',
|
||||
})
|
||||
const result = []
|
||||
for (const image of items) {
|
||||
result.push(
|
||||
await cropAndCompressFlow(store, image.path, image, maxDim, maxSize),
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export async function cropAndCompressFlow(
|
||||
store: RootStoreModel,
|
||||
path: string,
|
||||
imgDim: Dim,
|
||||
maxDim: Dim,
|
||||
maxSize: number,
|
||||
) {
|
||||
// choose target dimensions based on the original
|
||||
// this causes the photo cropper to start with the full image "selected"
|
||||
const {width, height} = scaleDownDimensions(imgDim, maxDim)
|
||||
const cropperRes = await openCropper(store, {
|
||||
mediaType: 'photo',
|
||||
path,
|
||||
freeStyleCropEnabled: true,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
|
||||
const img = await compressIfNeeded(cropperRes, maxSize)
|
||||
const permanentPath = await moveToPremanantPath(img.path)
|
||||
return permanentPath
|
||||
}
|
||||
|
||||
// helpers
|
||||
// =
|
||||
|
||||
function selectFile(opts: PickerOpts): Promise<PickedFile> {
|
||||
return new Promise((resolve, reject) => {
|
||||
var input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = opts.mediaType === 'photo' ? 'image/*' : '*/*'
|
||||
input.onchange = e => {
|
||||
const target = e.target as HTMLInputElement
|
||||
const file = target?.files?.[0]
|
||||
if (!file) {
|
||||
return reject(new Error('Canceled'))
|
||||
}
|
||||
|
||||
var reader = new FileReader()
|
||||
reader.readAsDataURL(file)
|
||||
reader.onload = readerEvent => {
|
||||
if (!readerEvent.target) {
|
||||
return reject(new Error('Canceled'))
|
||||
}
|
||||
resolve({
|
||||
uri: readerEvent.target.result as string,
|
||||
path: file.name,
|
||||
size: file.size,
|
||||
})
|
||||
}
|
||||
}
|
||||
input.click()
|
||||
})
|
||||
}
|
||||
|
||||
function extractDataUriMime(uri: string): string {
|
||||
return uri.substring(uri.indexOf(':') + 1, uri.indexOf(';'))
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
import '@zxing/text-encoding' // TextEncoder / TextDecoder
|
|
@ -1,2 +1 @@
|
|||
// do nothing
|
||||
export {}
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
/// <reference lib="dom" />
|
||||
|
||||
// @ts-ignore whatever typescript wants to complain about here, I dont care about -prf
|
||||
window.setImmediate = (cb: () => void) => setTimeout(cb, 0)
|
|
@ -1,5 +1,5 @@
|
|||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import {PickedMedia} from 'view/com/util/images/image-crop-picker/ImageCropPicker'
|
||||
import {PickedMedia} from 'lib/media/picker'
|
||||
import {
|
||||
AppBskyActorGetProfile as GetProfile,
|
||||
AppBskyActorProfile as Profile,
|
||||
|
@ -137,11 +137,10 @@ export class ProfileViewModel {
|
|||
newUserBanner: PickedMedia | undefined,
|
||||
) {
|
||||
if (newUserAvatar) {
|
||||
const res = await this.rootStore.api.com.atproto.blob.upload(
|
||||
newUserAvatar.path, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts
|
||||
{
|
||||
encoding: newUserAvatar.mime,
|
||||
},
|
||||
const res = await apilib.uploadBlob(
|
||||
this.rootStore,
|
||||
newUserAvatar.path,
|
||||
newUserAvatar.mime,
|
||||
)
|
||||
updates.avatar = {
|
||||
cid: res.data.cid,
|
||||
|
@ -149,11 +148,10 @@ export class ProfileViewModel {
|
|||
}
|
||||
}
|
||||
if (newUserBanner) {
|
||||
const res = await this.rootStore.api.com.atproto.blob.upload(
|
||||
newUserBanner.path, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts
|
||||
{
|
||||
encoding: newUserBanner.mime,
|
||||
},
|
||||
const res = await apilib.uploadBlob(
|
||||
this.rootStore,
|
||||
newUserBanner.path,
|
||||
newUserBanner.mime,
|
||||
)
|
||||
updates.banner = {
|
||||
cid: res.data.cid,
|
||||
|
|
|
@ -2,74 +2,56 @@ import {RootStoreModel} from './root-store'
|
|||
import {makeAutoObservable} from 'mobx'
|
||||
import {ProfileViewModel} from './profile-view'
|
||||
import {isObj, hasProp} from 'lib/type-guards'
|
||||
import {PickedMedia} from 'view/com/util/images/image-crop-picker/types'
|
||||
import {PickedMedia} from 'lib/media/types'
|
||||
|
||||
export class ConfirmModal {
|
||||
name = 'confirm'
|
||||
|
||||
constructor(
|
||||
public title: string,
|
||||
public message: string | (() => JSX.Element),
|
||||
public onPressConfirm: () => void | Promise<void>,
|
||||
) {
|
||||
makeAutoObservable(this)
|
||||
}
|
||||
export interface ConfirmModal {
|
||||
name: 'confirm'
|
||||
title: string
|
||||
message: string | (() => JSX.Element)
|
||||
onPressConfirm: () => void | Promise<void>
|
||||
}
|
||||
|
||||
export class EditProfileModal {
|
||||
name = 'edit-profile'
|
||||
|
||||
constructor(
|
||||
public profileView: ProfileViewModel,
|
||||
public onUpdate?: () => void,
|
||||
) {
|
||||
makeAutoObservable(this)
|
||||
}
|
||||
export interface EditProfileModal {
|
||||
name: 'edit-profile'
|
||||
profileView: ProfileViewModel
|
||||
onUpdate?: () => void
|
||||
}
|
||||
|
||||
export class ServerInputModal {
|
||||
name = 'server-input'
|
||||
|
||||
constructor(
|
||||
public initialService: string,
|
||||
public onSelect: (url: string) => void,
|
||||
) {
|
||||
makeAutoObservable(this)
|
||||
}
|
||||
export interface ServerInputModal {
|
||||
name: 'server-input'
|
||||
initialService: string
|
||||
onSelect: (url: string) => void
|
||||
}
|
||||
|
||||
export class ReportPostModal {
|
||||
name = 'report-post'
|
||||
|
||||
constructor(public postUri: string, public postCid: string) {
|
||||
makeAutoObservable(this)
|
||||
}
|
||||
export interface ReportPostModal {
|
||||
name: 'report-post'
|
||||
postUri: string
|
||||
postCid: string
|
||||
}
|
||||
|
||||
export class ReportAccountModal {
|
||||
name = 'report-account'
|
||||
|
||||
constructor(public did: string) {
|
||||
makeAutoObservable(this)
|
||||
}
|
||||
export interface ReportAccountModal {
|
||||
name: 'report-account'
|
||||
did: string
|
||||
}
|
||||
|
||||
export class CropImageModal {
|
||||
name = 'crop-image'
|
||||
|
||||
constructor(
|
||||
public uri: string,
|
||||
public onSelect: (img?: PickedMedia) => void,
|
||||
) {}
|
||||
export interface CropImageModal {
|
||||
name: 'crop-image'
|
||||
uri: string
|
||||
onSelect: (img?: PickedMedia) => void
|
||||
}
|
||||
|
||||
export class DeleteAccountModal {
|
||||
name = 'delete-account'
|
||||
export interface DeleteAccountModal {
|
||||
name: 'delete-account'
|
||||
}
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(this)
|
||||
}
|
||||
}
|
||||
export type Modal =
|
||||
| ConfirmModal
|
||||
| EditProfileModal
|
||||
| ServerInputModal
|
||||
| ReportPostModal
|
||||
| ReportAccountModal
|
||||
| CropImageModal
|
||||
| DeleteAccountModal
|
||||
|
||||
interface LightboxModel {}
|
||||
|
||||
|
@ -111,15 +93,7 @@ export class ShellUiModel {
|
|||
minimalShellMode = false
|
||||
isMainMenuOpen = false
|
||||
isModalActive = false
|
||||
activeModal:
|
||||
| ConfirmModal
|
||||
| EditProfileModal
|
||||
| ServerInputModal
|
||||
| ReportPostModal
|
||||
| ReportAccountModal
|
||||
| CropImageModal
|
||||
| DeleteAccountModal
|
||||
| undefined
|
||||
activeModals: Modal[] = []
|
||||
isLightboxActive = false
|
||||
activeLightbox: ProfileImageLightbox | ImagesLightbox | undefined
|
||||
isComposerActive = false
|
||||
|
@ -159,24 +133,15 @@ export class ShellUiModel {
|
|||
this.isMainMenuOpen = v
|
||||
}
|
||||
|
||||
openModal(
|
||||
modal:
|
||||
| ConfirmModal
|
||||
| EditProfileModal
|
||||
| ServerInputModal
|
||||
| ReportPostModal
|
||||
| ReportAccountModal
|
||||
| CropImageModal
|
||||
| DeleteAccountModal,
|
||||
) {
|
||||
openModal(modal: Modal) {
|
||||
this.rootStore.emitNavigation()
|
||||
this.isModalActive = true
|
||||
this.activeModal = modal
|
||||
this.activeModals.push(modal)
|
||||
}
|
||||
|
||||
closeModal() {
|
||||
this.isModalActive = false
|
||||
this.activeModal = undefined
|
||||
this.activeModals.pop()
|
||||
this.isModalActive = this.activeModals.length > 0
|
||||
}
|
||||
|
||||
openLightbox(lightbox: ProfileImageLightbox | ImagesLightbox) {
|
||||
|
|
|
@ -36,11 +36,18 @@ import {s, colors, gradients} from 'lib/styles'
|
|||
import {cleanError} from 'lib/strings/errors'
|
||||
import {detectLinkables, extractEntities} from 'lib/strings/rich-text-detection'
|
||||
import {getLinkMeta} from 'lib/link-meta/link-meta'
|
||||
import {downloadAndResize} from 'lib/images'
|
||||
import {PhotoCarouselPicker, cropPhoto} from './photos/PhotoCarouselPicker'
|
||||
import {getImageDim, downloadAndResize} from 'lib/media/manip'
|
||||
import {PhotoCarouselPicker} from './photos/PhotoCarouselPicker'
|
||||
import {cropAndCompressFlow, pickImagesFlow} from '../../../lib/media/picker'
|
||||
import {getMentionAt, insertMentionAt} from 'lib/strings/mention-manip'
|
||||
import {SelectedPhoto} from './SelectedPhoto'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {
|
||||
POST_IMG_MAX_WIDTH,
|
||||
POST_IMG_MAX_HEIGHT,
|
||||
POST_IMG_MAX_SIZE,
|
||||
} from 'lib/constants'
|
||||
import {isWeb} from 'platform/detection'
|
||||
|
||||
const MAX_TEXT_LENGTH = 256
|
||||
const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10}
|
||||
|
@ -61,7 +68,7 @@ export const ComposePost = observer(function ComposePost({
|
|||
onPost?: ComposerOpts['onPost']
|
||||
onClose: () => void
|
||||
}) {
|
||||
const {track, screen} = useAnalytics()
|
||||
const {track} = useAnalytics()
|
||||
const pal = usePalette('default')
|
||||
const store = useStores()
|
||||
const textInput = useRef<TextInputRef>(null)
|
||||
|
@ -174,14 +181,26 @@ export const ComposePost = observer(function ComposePost({
|
|||
const onPressContainer = () => {
|
||||
textInput.current?.focus()
|
||||
}
|
||||
const onPressSelectPhotos = () => {
|
||||
const onPressSelectPhotos = async () => {
|
||||
track('ComposePost:SelectPhotos')
|
||||
if (isWeb) {
|
||||
if (selectedPhotos.length < 4) {
|
||||
const images = await pickImagesFlow(
|
||||
store,
|
||||
4 - selectedPhotos.length,
|
||||
{width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT},
|
||||
POST_IMG_MAX_SIZE,
|
||||
)
|
||||
setSelectedPhotos([...selectedPhotos, ...images])
|
||||
}
|
||||
} else {
|
||||
if (isSelectingPhotos) {
|
||||
setIsSelectingPhotos(false)
|
||||
} else if (selectedPhotos.length < 4) {
|
||||
setIsSelectingPhotos(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
const onSelectPhotos = (photos: string[]) => {
|
||||
track('ComposePost:SelectPhotos:Done')
|
||||
setSelectedPhotos(photos)
|
||||
|
@ -220,7 +239,19 @@ export const ComposePost = observer(function ComposePost({
|
|||
}
|
||||
const imgUri = uris.find(uri => /\.(jpe?g|png)$/.test(uri))
|
||||
if (imgUri) {
|
||||
const finalImgPath = await cropPhoto(store, imgUri)
|
||||
let imgDim
|
||||
try {
|
||||
imgDim = await getImageDim(imgUri)
|
||||
} catch (e) {
|
||||
imgDim = {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT}
|
||||
}
|
||||
const finalImgPath = await cropAndCompressFlow(
|
||||
store,
|
||||
imgUri,
|
||||
imgDim,
|
||||
{width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT},
|
||||
POST_IMG_MAX_SIZE,
|
||||
)
|
||||
onSelectPhotos([...selectedPhotos, finalImgPath])
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,57 +9,24 @@ import {
|
|||
openPicker,
|
||||
openCamera,
|
||||
openCropper,
|
||||
} from '../../util/images/image-crop-picker/ImageCropPicker'
|
||||
cropAndCompressFlow,
|
||||
} from '../../../../lib/media/picker'
|
||||
import {
|
||||
UserLocalPhotosModel,
|
||||
PhotoIdentifier,
|
||||
} from 'state/models/user-local-photos'
|
||||
import {
|
||||
compressIfNeeded,
|
||||
moveToPremanantPath,
|
||||
scaleDownDimensions,
|
||||
} from 'lib/images'
|
||||
import {compressIfNeeded} from 'lib/media/manip'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useStores, RootStoreModel} from 'state/index'
|
||||
import {useStores} from 'state/index'
|
||||
import {
|
||||
requestPhotoAccessIfNeeded,
|
||||
requestCameraAccessIfNeeded,
|
||||
} from 'lib/permissions'
|
||||
|
||||
const MAX_WIDTH = 2000
|
||||
const MAX_HEIGHT = 2000
|
||||
const MAX_SIZE = 1000000
|
||||
|
||||
const IMAGE_PARAMS = {
|
||||
width: 2000,
|
||||
height: 2000,
|
||||
freeStyleCropEnabled: true,
|
||||
}
|
||||
|
||||
export async function cropPhoto(
|
||||
store: RootStoreModel,
|
||||
path: string,
|
||||
imgWidth = MAX_WIDTH,
|
||||
imgHeight = MAX_HEIGHT,
|
||||
) {
|
||||
// choose target dimensions based on the original
|
||||
// this causes the photo cropper to start with the full image "selected"
|
||||
const {width, height} = scaleDownDimensions(
|
||||
{width: imgWidth, height: imgHeight},
|
||||
{width: MAX_WIDTH, height: MAX_HEIGHT},
|
||||
)
|
||||
const cropperRes = await openCropper(store, {
|
||||
mediaType: 'photo',
|
||||
path,
|
||||
freeStyleCropEnabled: true,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
|
||||
const img = await compressIfNeeded(cropperRes, MAX_SIZE)
|
||||
const permanentPath = await moveToPremanantPath(img.path)
|
||||
return permanentPath
|
||||
}
|
||||
import {
|
||||
POST_IMG_MAX_WIDTH,
|
||||
POST_IMG_MAX_HEIGHT,
|
||||
POST_IMG_MAX_SIZE,
|
||||
} from 'lib/constants'
|
||||
|
||||
export const PhotoCarouselPicker = ({
|
||||
selectedPhotos,
|
||||
|
@ -92,9 +59,11 @@ export const PhotoCarouselPicker = ({
|
|||
}
|
||||
const cameraRes = await openCamera(store, {
|
||||
mediaType: 'photo',
|
||||
...IMAGE_PARAMS,
|
||||
width: POST_IMG_MAX_WIDTH,
|
||||
height: POST_IMG_MAX_HEIGHT,
|
||||
freeStyleCropEnabled: true,
|
||||
})
|
||||
const img = await compressIfNeeded(cameraRes, MAX_SIZE)
|
||||
const img = await compressIfNeeded(cameraRes, POST_IMG_MAX_SIZE)
|
||||
onSelectPhotos([...selectedPhotos, img.path])
|
||||
} catch (err: any) {
|
||||
// ignore
|
||||
|
@ -106,11 +75,15 @@ export const PhotoCarouselPicker = ({
|
|||
async (item: PhotoIdentifier) => {
|
||||
track('PhotoCarouselPicker:PhotoSelected')
|
||||
try {
|
||||
const imgPath = await cropPhoto(
|
||||
const imgPath = await cropAndCompressFlow(
|
||||
store,
|
||||
item.node.image.uri,
|
||||
item.node.image.width,
|
||||
item.node.image.height,
|
||||
{
|
||||
width: item.node.image.width,
|
||||
height: item.node.image.height,
|
||||
},
|
||||
{width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT},
|
||||
POST_IMG_MAX_SIZE,
|
||||
)
|
||||
onSelectPhotos([...selectedPhotos, imgPath])
|
||||
} catch (err: any) {
|
||||
|
@ -132,24 +105,16 @@ export const PhotoCarouselPicker = ({
|
|||
mediaType: 'photo',
|
||||
})
|
||||
const result = []
|
||||
|
||||
for (const image of items) {
|
||||
// choose target dimensions based on the original
|
||||
// this causes the photo cropper to start with the full image "selected"
|
||||
const {width, height} = scaleDownDimensions(
|
||||
{width: image.width, height: image.height},
|
||||
{width: MAX_WIDTH, height: MAX_HEIGHT},
|
||||
result.push(
|
||||
await cropAndCompressFlow(
|
||||
store,
|
||||
image.path,
|
||||
image,
|
||||
{width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT},
|
||||
POST_IMG_MAX_SIZE,
|
||||
),
|
||||
)
|
||||
const cropperRes = await openCropper(store, {
|
||||
mediaType: 'photo',
|
||||
path: image.path,
|
||||
...IMAGE_PARAMS,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
const finalImg = await compressIfNeeded(cropperRes, MAX_SIZE)
|
||||
const permanentPath = await moveToPremanantPath(finalImg.path)
|
||||
result.push(permanentPath)
|
||||
}
|
||||
onSelectPhotos([...selectedPhotos, ...result])
|
||||
}, [track, store, selectedPhotos, onSelectPhotos])
|
||||
|
|
|
@ -1,158 +1,10 @@
|
|||
import React, {useCallback} from 'react'
|
||||
import {StyleSheet, TouchableOpacity, ScrollView} from 'react-native'
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconStyle,
|
||||
} from '@fortawesome/react-native-fontawesome'
|
||||
import {
|
||||
openPicker,
|
||||
openCamera,
|
||||
openCropper,
|
||||
} from '../../util/images/image-crop-picker/ImageCropPicker'
|
||||
import {compressIfNeeded, scaleDownDimensions} from 'lib/images'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useStores, RootStoreModel} from 'state/index'
|
||||
import React from 'react'
|
||||
|
||||
const MAX_WIDTH = 1000
|
||||
const MAX_HEIGHT = 1000
|
||||
const MAX_SIZE = 300000
|
||||
// Not used on Web
|
||||
|
||||
const IMAGE_PARAMS = {
|
||||
width: 1000,
|
||||
height: 1000,
|
||||
freeStyleCropEnabled: true,
|
||||
}
|
||||
|
||||
export async function cropPhoto(
|
||||
store: RootStoreModel,
|
||||
path: string,
|
||||
imgWidth = MAX_WIDTH,
|
||||
imgHeight = MAX_HEIGHT,
|
||||
) {
|
||||
// choose target dimensions based on the original
|
||||
// this causes the photo cropper to start with the full image "selected"
|
||||
const {width, height} = scaleDownDimensions(
|
||||
{width: imgWidth, height: imgHeight},
|
||||
{width: MAX_WIDTH, height: MAX_HEIGHT},
|
||||
)
|
||||
const cropperRes = await openCropper(store, {
|
||||
mediaType: 'photo',
|
||||
path,
|
||||
freeStyleCropEnabled: true,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
const img = await compressIfNeeded(cropperRes, MAX_SIZE)
|
||||
return img.path
|
||||
}
|
||||
|
||||
export const PhotoCarouselPicker = ({
|
||||
selectedPhotos,
|
||||
onSelectPhotos,
|
||||
}: {
|
||||
export const PhotoCarouselPicker = (_opts: {
|
||||
selectedPhotos: string[]
|
||||
onSelectPhotos: (v: string[]) => void
|
||||
}) => {
|
||||
const pal = usePalette('default')
|
||||
const store = useStores()
|
||||
|
||||
const handleOpenCamera = useCallback(async () => {
|
||||
try {
|
||||
const cameraRes = await openCamera(store, {
|
||||
mediaType: 'photo',
|
||||
...IMAGE_PARAMS,
|
||||
})
|
||||
const img = await compressIfNeeded(cameraRes, MAX_SIZE)
|
||||
onSelectPhotos([...selectedPhotos, img.path])
|
||||
} catch (err: any) {
|
||||
// ignore
|
||||
store.log.warn('Error using camera', err)
|
||||
return <></>
|
||||
}
|
||||
}, [store, selectedPhotos, onSelectPhotos])
|
||||
|
||||
const handleOpenGallery = useCallback(() => {
|
||||
openPicker(store, {
|
||||
multiple: true,
|
||||
maxFiles: 4 - selectedPhotos.length,
|
||||
mediaType: 'photo',
|
||||
}).then(async items => {
|
||||
const result = []
|
||||
|
||||
for (const image of items) {
|
||||
// choose target dimensions based on the original
|
||||
// this causes the photo cropper to start with the full image "selected"
|
||||
const {width, height} = scaleDownDimensions(
|
||||
{width: image.width, height: image.height},
|
||||
{width: MAX_WIDTH, height: MAX_HEIGHT},
|
||||
)
|
||||
const cropperRes = await openCropper(store, {
|
||||
mediaType: 'photo',
|
||||
path: image.path,
|
||||
freeStyleCropEnabled: true,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
const finalImg = await compressIfNeeded(cropperRes, MAX_SIZE)
|
||||
result.push(finalImg.path)
|
||||
}
|
||||
onSelectPhotos([...selectedPhotos, ...result])
|
||||
})
|
||||
}, [store, selectedPhotos, onSelectPhotos])
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
testID="photoCarouselPickerView"
|
||||
horizontal
|
||||
style={[pal.view, styles.photosContainer]}
|
||||
keyboardShouldPersistTaps="always"
|
||||
showsHorizontalScrollIndicator={false}>
|
||||
<TouchableOpacity
|
||||
testID="openCameraButton"
|
||||
style={[styles.galleryButton, pal.border, styles.photo]}
|
||||
onPress={handleOpenCamera}>
|
||||
<FontAwesomeIcon
|
||||
icon="camera"
|
||||
size={24}
|
||||
style={pal.link as FontAwesomeIconStyle}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
testID="openGalleryButton"
|
||||
style={[styles.galleryButton, pal.border, styles.photo]}
|
||||
onPress={handleOpenGallery}>
|
||||
<FontAwesomeIcon
|
||||
icon="image"
|
||||
style={pal.link as FontAwesomeIconStyle}
|
||||
size={24}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
photosContainer: {
|
||||
width: '100%',
|
||||
maxHeight: 96,
|
||||
padding: 8,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
galleryButton: {
|
||||
borderWidth: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
photoButton: {
|
||||
width: 75,
|
||||
height: 75,
|
||||
marginRight: 8,
|
||||
borderWidth: 1,
|
||||
borderRadius: 16,
|
||||
},
|
||||
photo: {
|
||||
width: 75,
|
||||
height: 75,
|
||||
marginRight: 8,
|
||||
borderRadius: 16,
|
||||
},
|
||||
})
|
||||
|
|
|
@ -17,7 +17,7 @@ import {
|
|||
VirtualizedList,
|
||||
ModalProps,
|
||||
} from 'react-native'
|
||||
import {Modal} from '../../modals/Modal'
|
||||
import {ModalsContainer} from '../../modals/Modal'
|
||||
|
||||
import ImageItem from './components/ImageItem/ImageItem'
|
||||
import ImageDefaultHeader from './components/ImageDefaultHeader'
|
||||
|
@ -98,7 +98,7 @@ function ImageViewing({
|
|||
|
||||
return (
|
||||
<View style={styles.screen} onLayout={onLayout}>
|
||||
<Modal />
|
||||
<ModalsContainer />
|
||||
<View style={[styles.container, {opacity, backgroundColor}]}>
|
||||
<Animated.View style={[styles.header, {transform: headerTransform}]}>
|
||||
{typeof HeaderComponent !== 'undefined' ? (
|
||||
|
|
|
@ -4,7 +4,7 @@ import {observer} from 'mobx-react-lite'
|
|||
import ImageView from './ImageViewing'
|
||||
import {useStores} from 'state/index'
|
||||
import * as models from 'state/models/shell-ui'
|
||||
import {saveImageModal} from 'lib/images'
|
||||
import {saveImageModal} from 'lib/media/manip'
|
||||
import {ImageSource} from './ImageViewing/@types'
|
||||
|
||||
export const Lightbox = observer(function Lightbox() {
|
||||
|
|
|
@ -25,7 +25,6 @@ import {makeValidHandle, createFullHandle} from 'lib/strings/handles'
|
|||
import {toNiceDomain} from 'lib/strings/url-helpers'
|
||||
import {useStores, DEFAULT_SERVICE} from 'state/index'
|
||||
import {ServiceDescription} from 'state/models/session'
|
||||
import {ServerInputModal} from 'state/models/shell-ui'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
|
||||
|
@ -84,7 +83,11 @@ export const CreateAccount = ({onPressBack}: {onPressBack: () => void}) => {
|
|||
const onPressRetryConnect = () => setRetryDescribeTrigger({})
|
||||
|
||||
const onPressSelectService = () => {
|
||||
store.shell.openModal(new ServerInputModal(serviceUrl, setServiceUrl))
|
||||
store.shell.openModal({
|
||||
name: 'server-input',
|
||||
initialService: serviceUrl,
|
||||
onSelect: setServiceUrl,
|
||||
})
|
||||
Keyboard.dismiss()
|
||||
}
|
||||
|
||||
|
|
|
@ -279,7 +279,11 @@ const LoginForm = ({
|
|||
const [password, setPassword] = useState<string>('')
|
||||
|
||||
const onPressSelectService = () => {
|
||||
store.shell.openModal(new ServerInputModal(serviceUrl, setServiceUrl))
|
||||
store.shell.openModal({
|
||||
name: 'server-input',
|
||||
initialService: serviceUrl,
|
||||
onSelect: setServiceUrl,
|
||||
})
|
||||
Keyboard.dismiss()
|
||||
track('Signin:PressedSelectService')
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@ import {
|
|||
} from 'react-native'
|
||||
import LinearGradient from 'react-native-linear-gradient'
|
||||
import {ScrollView, TextInput} from './util'
|
||||
import {PickedMedia} from '../util/images/image-crop-picker/ImageCropPicker'
|
||||
import {PickedMedia} from '../../../lib/media/picker'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {ErrorMessage} from '../util/error/ErrorMessage'
|
||||
import {useStores} from 'state/index'
|
||||
|
@ -16,7 +16,7 @@ import {ProfileViewModel} from 'state/models/profile-view'
|
|||
import {s, colors, gradients} from 'lib/styles'
|
||||
import {enforceLen} from 'lib/strings/helpers'
|
||||
import {MAX_DISPLAY_NAME, MAX_DESCRIPTION} from 'lib/constants'
|
||||
import {compressIfNeeded} from 'lib/images'
|
||||
import {compressIfNeeded} from 'lib/media/manip'
|
||||
import {UserBanner} from '../util/UserBanner'
|
||||
import {UserAvatar} from '../util/UserAvatar'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
|
|
|
@ -5,8 +5,6 @@ import BottomSheet from '@gorhom/bottom-sheet'
|
|||
import {useStores} from 'state/index'
|
||||
import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop'
|
||||
|
||||
import * as models from 'state/models/shell-ui'
|
||||
|
||||
import * as ConfirmModal from './Confirm'
|
||||
import * as EditProfileModal from './EditProfile'
|
||||
import * as ServerInputModal from './ServerInput'
|
||||
|
@ -18,7 +16,7 @@ import {StyleSheet} from 'react-native'
|
|||
|
||||
const CLOSED_SNAPPOINTS = ['10%']
|
||||
|
||||
export const Modal = observer(function Modal() {
|
||||
export const ModalsContainer = observer(function ModalsContainer() {
|
||||
const store = useStores()
|
||||
const bottomSheetRef = useRef<BottomSheet>(null)
|
||||
const pal = usePalette('default')
|
||||
|
@ -32,52 +30,37 @@ export const Modal = observer(function Modal() {
|
|||
store.shell.closeModal()
|
||||
}
|
||||
|
||||
const activeModal = React.useMemo(
|
||||
() => store.shell.activeModals.at(-1),
|
||||
[store.shell.activeModals],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (store.shell.isModalActive) {
|
||||
bottomSheetRef.current?.expand()
|
||||
} else {
|
||||
bottomSheetRef.current?.close()
|
||||
}
|
||||
}, [store.shell.isModalActive, bottomSheetRef, store.shell.activeModal?.name])
|
||||
}, [store.shell.isModalActive, bottomSheetRef, activeModal?.name])
|
||||
|
||||
let snapPoints: (string | number)[] = CLOSED_SNAPPOINTS
|
||||
let element
|
||||
if (store.shell.activeModal?.name === 'confirm') {
|
||||
if (activeModal?.name === 'confirm') {
|
||||
snapPoints = ConfirmModal.snapPoints
|
||||
element = (
|
||||
<ConfirmModal.Component
|
||||
{...(store.shell.activeModal as models.ConfirmModal)}
|
||||
/>
|
||||
)
|
||||
} else if (store.shell.activeModal?.name === 'edit-profile') {
|
||||
element = <ConfirmModal.Component {...activeModal} />
|
||||
} else if (activeModal?.name === 'edit-profile') {
|
||||
snapPoints = EditProfileModal.snapPoints
|
||||
element = (
|
||||
<EditProfileModal.Component
|
||||
{...(store.shell.activeModal as models.EditProfileModal)}
|
||||
/>
|
||||
)
|
||||
} else if (store.shell.activeModal?.name === 'server-input') {
|
||||
element = <EditProfileModal.Component {...activeModal} />
|
||||
} else if (activeModal?.name === 'server-input') {
|
||||
snapPoints = ServerInputModal.snapPoints
|
||||
element = (
|
||||
<ServerInputModal.Component
|
||||
{...(store.shell.activeModal as models.ServerInputModal)}
|
||||
/>
|
||||
)
|
||||
} else if (store.shell.activeModal?.name === 'report-post') {
|
||||
element = <ServerInputModal.Component {...activeModal} />
|
||||
} else if (activeModal?.name === 'report-post') {
|
||||
snapPoints = ReportPostModal.snapPoints
|
||||
element = (
|
||||
<ReportPostModal.Component
|
||||
{...(store.shell.activeModal as models.ReportPostModal)}
|
||||
/>
|
||||
)
|
||||
} else if (store.shell.activeModal?.name === 'report-account') {
|
||||
element = <ReportPostModal.Component {...activeModal} />
|
||||
} else if (activeModal?.name === 'report-account') {
|
||||
snapPoints = ReportAccountModal.snapPoints
|
||||
element = (
|
||||
<ReportAccountModal.Component
|
||||
{...(store.shell.activeModal as models.ReportAccountModal)}
|
||||
/>
|
||||
)
|
||||
} else if (store.shell.activeModal?.name === 'delete-account') {
|
||||
element = <ReportAccountModal.Component {...activeModal} />
|
||||
} else if (activeModal?.name === 'delete-account') {
|
||||
snapPoints = DeleteAccountModal.snapPoints
|
||||
element = <DeleteAccountModal.Component />
|
||||
} else {
|
||||
|
|
|
@ -3,8 +3,7 @@ import {TouchableWithoutFeedback, StyleSheet, View} from 'react-native'
|
|||
import {observer} from 'mobx-react-lite'
|
||||
import {useStores} from 'state/index'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
|
||||
import * as models from 'state/models/shell-ui'
|
||||
import type {Modal as ModalIface} from 'state/models/shell-ui'
|
||||
|
||||
import * as ConfirmModal from './Confirm'
|
||||
import * as EditProfileModal from './EditProfile'
|
||||
|
@ -13,7 +12,23 @@ import * as ReportPostModal from './ReportPost'
|
|||
import * as ReportAccountModal from './ReportAccount'
|
||||
import * as CropImageModal from './crop-image/CropImage.web'
|
||||
|
||||
export const Modal = observer(function Modal() {
|
||||
export const ModalsContainer = observer(function ModalsContainer() {
|
||||
const store = useStores()
|
||||
|
||||
if (!store.shell.isModalActive) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{store.shell.activeModals.map((modal, i) => (
|
||||
<Modal key={`modal-${i}`} modal={modal} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
function Modal({modal}: {modal: ModalIface}) {
|
||||
const store = useStores()
|
||||
const pal = usePalette('default')
|
||||
|
||||
|
@ -21,7 +36,10 @@ export const Modal = observer(function Modal() {
|
|||
return null
|
||||
}
|
||||
|
||||
const onClose = () => {
|
||||
const onPressMask = () => {
|
||||
if (modal.name === 'crop-image') {
|
||||
return // dont close on mask presses during crop
|
||||
}
|
||||
store.shell.closeModal()
|
||||
}
|
||||
const onInnerPress = () => {
|
||||
|
@ -29,48 +47,24 @@ export const Modal = observer(function Modal() {
|
|||
}
|
||||
|
||||
let element
|
||||
if (store.shell.activeModal?.name === 'confirm') {
|
||||
element = (
|
||||
<ConfirmModal.Component
|
||||
{...(store.shell.activeModal as models.ConfirmModal)}
|
||||
/>
|
||||
)
|
||||
} else if (store.shell.activeModal?.name === 'edit-profile') {
|
||||
element = (
|
||||
<EditProfileModal.Component
|
||||
{...(store.shell.activeModal as models.EditProfileModal)}
|
||||
/>
|
||||
)
|
||||
} else if (store.shell.activeModal?.name === 'server-input') {
|
||||
element = (
|
||||
<ServerInputModal.Component
|
||||
{...(store.shell.activeModal as models.ServerInputModal)}
|
||||
/>
|
||||
)
|
||||
} else if (store.shell.activeModal?.name === 'report-post') {
|
||||
element = (
|
||||
<ReportPostModal.Component
|
||||
{...(store.shell.activeModal as models.ReportPostModal)}
|
||||
/>
|
||||
)
|
||||
} else if (store.shell.activeModal?.name === 'report-account') {
|
||||
element = (
|
||||
<ReportAccountModal.Component
|
||||
{...(store.shell.activeModal as models.ReportAccountModal)}
|
||||
/>
|
||||
)
|
||||
} else if (store.shell.activeModal?.name === 'crop-image') {
|
||||
element = (
|
||||
<CropImageModal.Component
|
||||
{...(store.shell.activeModal as models.CropImageModal)}
|
||||
/>
|
||||
)
|
||||
if (modal.name === 'confirm') {
|
||||
element = <ConfirmModal.Component {...modal} />
|
||||
} else if (modal.name === 'edit-profile') {
|
||||
element = <EditProfileModal.Component {...modal} />
|
||||
} else if (modal.name === 'server-input') {
|
||||
element = <ServerInputModal.Component {...modal} />
|
||||
} else if (modal.name === 'report-post') {
|
||||
element = <ReportPostModal.Component {...modal} />
|
||||
} else if (modal.name === 'report-account') {
|
||||
element = <ReportAccountModal.Component {...modal} />
|
||||
} else if (modal.name === 'crop-image') {
|
||||
element = <CropImageModal.Component {...modal} />
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableWithoutFeedback onPress={onClose}>
|
||||
<TouchableWithoutFeedback onPress={onPressMask}>
|
||||
<View style={styles.mask}>
|
||||
<TouchableWithoutFeedback onPress={onInnerPress}>
|
||||
<View style={[styles.container, pal.view]}>{element}</View>
|
||||
|
@ -78,7 +72,7 @@ export const Modal = observer(function Modal() {
|
|||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
mask: {
|
||||
|
|
|
@ -38,6 +38,7 @@ export function Component({
|
|||
const pal = usePalette('default')
|
||||
const [as, setAs] = React.useState<AspectRatio>(AspectRatio.Square)
|
||||
const [scale, setScale] = React.useState<number>(1)
|
||||
const editorRef = React.useRef<ImageEditor>(null)
|
||||
|
||||
const doSetAs = (v: AspectRatio) => () => setAs(v)
|
||||
|
||||
|
@ -46,8 +47,20 @@ export function Component({
|
|||
store.shell.closeModal()
|
||||
}
|
||||
const onPressDone = () => {
|
||||
console.log('TODO')
|
||||
onSelect(undefined) // TODO
|
||||
const canvas = editorRef.current?.getImageScaledToCanvas()
|
||||
if (canvas) {
|
||||
const dataUri = canvas.toDataURL('image/jpeg')
|
||||
onSelect({
|
||||
mediaType: 'photo',
|
||||
path: dataUri,
|
||||
mime: 'image/jpeg',
|
||||
size: Math.round((dataUri.length * 3) / 4), // very rough estimate
|
||||
width: DIMS[as].width,
|
||||
height: DIMS[as].height,
|
||||
})
|
||||
} else {
|
||||
onSelect(undefined)
|
||||
}
|
||||
store.shell.closeModal()
|
||||
}
|
||||
|
||||
|
@ -61,13 +74,15 @@ export function Component({
|
|||
}
|
||||
return (
|
||||
<View>
|
||||
<View style={[styles.cropper, cropperStyle]}>
|
||||
<View style={[styles.cropper, pal.borderDark, cropperStyle]}>
|
||||
<ImageEditor
|
||||
ref={editorRef}
|
||||
style={styles.imageEditor}
|
||||
image={uri}
|
||||
width={DIMS[as].width}
|
||||
height={DIMS[as].height}
|
||||
scale={scale}
|
||||
border={0}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.ctrls}>
|
||||
|
@ -126,6 +141,9 @@ const styles = StyleSheet.create({
|
|||
cropper: {
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
borderWidth: 1,
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
cropperSquare: {
|
||||
width: 400,
|
||||
|
|
|
@ -15,11 +15,7 @@ import {
|
|||
import {BlurView} from '../util/BlurView'
|
||||
import {ProfileViewModel} from 'state/models/profile-view'
|
||||
import {useStores} from 'state/index'
|
||||
import {
|
||||
EditProfileModal,
|
||||
ReportAccountModal,
|
||||
ProfileImageLightbox,
|
||||
} from 'state/models/shell-ui'
|
||||
import {ProfileImageLightbox} from 'state/models/shell-ui'
|
||||
import {pluralize} from 'lib/strings/helpers'
|
||||
import {toShareUrl} from 'lib/strings/url-helpers'
|
||||
import {s, gradients} from 'lib/styles'
|
||||
|
@ -65,7 +61,11 @@ export const ProfileHeader = observer(function ProfileHeader({
|
|||
}
|
||||
const onPressEditProfile = () => {
|
||||
track('ProfileHeader:EditProfileButtonClicked')
|
||||
store.shell.openModal(new EditProfileModal(view, onRefreshAll))
|
||||
store.shell.openModal({
|
||||
name: 'edit-profile',
|
||||
profileView: view,
|
||||
onUpdate: onRefreshAll,
|
||||
})
|
||||
}
|
||||
const onPressFollowers = () => {
|
||||
track('ProfileHeader:FollowersButtonClicked')
|
||||
|
@ -101,7 +101,10 @@ export const ProfileHeader = observer(function ProfileHeader({
|
|||
}
|
||||
const onPressReportAccount = () => {
|
||||
track('ProfileHeader:ReportAccountButtonClicked')
|
||||
store.shell.openModal(new ReportAccountModal(view.did))
|
||||
store.shell.openModal({
|
||||
name: 'report-account',
|
||||
did: view.did,
|
||||
})
|
||||
}
|
||||
|
||||
// loading
|
||||
|
|
|
@ -13,7 +13,7 @@ import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
|
|||
import {ImagesLightbox} from 'state/models/shell-ui'
|
||||
import {useStores} from 'state/index'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {saveImageModal} from 'lib/images'
|
||||
import {saveImageModal} from 'lib/media/manip'
|
||||
import YoutubeEmbed from './YoutubeEmbed'
|
||||
import ExternalLinkEmbed from './ExternalLinkEmbed'
|
||||
import {getYoutubeVideoId} from 'lib/strings/url-helpers'
|
||||
|
|
|
@ -9,7 +9,7 @@ import {
|
|||
openCropper,
|
||||
openPicker,
|
||||
PickedMedia,
|
||||
} from './images/image-crop-picker/ImageCropPicker'
|
||||
} from '../../../lib/media/picker'
|
||||
import {
|
||||
requestPhotoAccessIfNeeded,
|
||||
requestCameraAccessIfNeeded,
|
||||
|
@ -18,6 +18,7 @@ import {useStores} from 'state/index'
|
|||
import {colors, gradients} from 'lib/styles'
|
||||
import {DropdownButton} from './forms/DropdownButton'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {isWeb} from 'platform/detection'
|
||||
|
||||
export function UserAvatar({
|
||||
size,
|
||||
|
@ -58,7 +59,7 @@ export function UserAvatar({
|
|||
)
|
||||
|
||||
const dropdownItems = [
|
||||
{
|
||||
!isWeb && {
|
||||
label: 'Camera',
|
||||
icon: 'camera' as IconProp,
|
||||
onPress: async () => {
|
||||
|
|
|
@ -10,7 +10,7 @@ import {
|
|||
openCropper,
|
||||
openPicker,
|
||||
PickedMedia,
|
||||
} from './images/image-crop-picker/ImageCropPicker'
|
||||
} from '../../../lib/media/picker'
|
||||
import {useStores} from 'state/index'
|
||||
import {
|
||||
requestPhotoAccessIfNeeded,
|
||||
|
@ -18,6 +18,7 @@ import {
|
|||
} from 'lib/permissions'
|
||||
import {DropdownButton} from './forms/DropdownButton'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {isWeb} from 'platform/detection'
|
||||
|
||||
export function UserBanner({
|
||||
banner,
|
||||
|
@ -29,7 +30,7 @@ export function UserBanner({
|
|||
const store = useStores()
|
||||
const pal = usePalette('default')
|
||||
const dropdownItems = [
|
||||
{
|
||||
!isWeb && {
|
||||
label: 'Camera',
|
||||
icon: 'camera' as IconProp,
|
||||
onPress: async () => {
|
||||
|
|
|
@ -35,7 +35,7 @@ export const ViewHeader = observer(function ViewHeader({
|
|||
canGoBack = store.nav.tab.canGoBack
|
||||
}
|
||||
if (isDesktopWeb) {
|
||||
return undefined
|
||||
return <></>
|
||||
}
|
||||
return (
|
||||
<Container hideOnScroll={hideOnScroll || false}>
|
||||
|
|
|
@ -16,7 +16,6 @@ import {Button, ButtonType} from './Button'
|
|||
import {colors} from 'lib/styles'
|
||||
import {toShareUrl} from 'lib/strings/url-helpers'
|
||||
import {useStores} from 'state/index'
|
||||
import {ReportPostModal, ConfirmModal} from 'state/models/shell-ui'
|
||||
import {TABS_ENABLED} from 'lib/build-flags'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useTheme} from 'lib/ThemeContext'
|
||||
|
@ -28,6 +27,7 @@ export interface DropdownItem {
|
|||
label: string
|
||||
onPress: () => void
|
||||
}
|
||||
type MaybeDropdownItem = DropdownItem | false | undefined
|
||||
|
||||
export type DropdownButtonType = ButtonType | 'bare'
|
||||
|
||||
|
@ -44,7 +44,7 @@ export function DropdownButton({
|
|||
}: {
|
||||
type?: DropdownButtonType
|
||||
style?: StyleProp<ViewStyle>
|
||||
items: DropdownItem[]
|
||||
items: MaybeDropdownItem[]
|
||||
label?: string
|
||||
menuWidth?: number
|
||||
children?: React.ReactNode
|
||||
|
@ -71,7 +71,12 @@ export function DropdownButton({
|
|||
? pageX + width + rightOffset
|
||||
: pageX + width - menuWidth
|
||||
const newY = pageY + height + bottomOffset
|
||||
createDropdownMenu(newX, newY, menuWidth, items)
|
||||
createDropdownMenu(
|
||||
newX,
|
||||
newY,
|
||||
menuWidth,
|
||||
items.filter(v => !!v) as DropdownItem[],
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
@ -151,7 +156,11 @@ export function PostDropdownBtn({
|
|||
icon: 'circle-exclamation',
|
||||
label: 'Report post',
|
||||
onPress() {
|
||||
store.shell.openModal(new ReportPostModal(itemUri, itemCid))
|
||||
store.shell.openModal({
|
||||
name: 'report-post',
|
||||
postUri: itemUri,
|
||||
postCid: itemCid,
|
||||
})
|
||||
},
|
||||
},
|
||||
isAuthor
|
||||
|
@ -159,13 +168,12 @@ export function PostDropdownBtn({
|
|||
icon: ['far', 'trash-can'],
|
||||
label: 'Delete post',
|
||||
onPress() {
|
||||
store.shell.openModal(
|
||||
new ConfirmModal(
|
||||
'Delete this post?',
|
||||
'Are you sure? This can not be undone.',
|
||||
onDeletePost,
|
||||
),
|
||||
)
|
||||
store.shell.openModal({
|
||||
name: 'confirm',
|
||||
title: 'Delete this post?',
|
||||
message: 'Are you sure? This can not be undone.',
|
||||
onPressConfirm: onDeletePost,
|
||||
})
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
|
|
|
@ -1,75 +0,0 @@
|
|||
/// <reference lib="dom" />
|
||||
|
||||
import {CropImageModal} from 'state/models/shell-ui'
|
||||
import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types'
|
||||
export type {PickedMedia} from './types'
|
||||
import {RootStoreModel} from 'state/index'
|
||||
|
||||
interface PickedFile {
|
||||
uri: string
|
||||
path: string
|
||||
size: number
|
||||
}
|
||||
|
||||
export async function openPicker(
|
||||
store: RootStoreModel,
|
||||
opts: PickerOpts,
|
||||
): Promise<PickedMedia[] | PickedMedia> {
|
||||
const res = await selectFile(opts)
|
||||
return new Promise((resolve, reject) => {
|
||||
store.shell.openModal(
|
||||
new CropImageModal(res.uri, (img?: PickedMedia) => {
|
||||
if (img) {
|
||||
resolve(img)
|
||||
} else {
|
||||
reject(new Error('Canceled'))
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export async function openCamera(
|
||||
_store: RootStoreModel,
|
||||
_opts: CameraOpts,
|
||||
): Promise<PickedMedia> {
|
||||
// const mediaType = opts.mediaType || 'photo' TODO
|
||||
throw new Error('TODO')
|
||||
}
|
||||
|
||||
export async function openCropper(
|
||||
_store: RootStoreModel,
|
||||
_opts: CropperOpts,
|
||||
): Promise<PickedMedia> {
|
||||
// const mediaType = opts.mediaType || 'photo' TODO
|
||||
throw new Error('TODO')
|
||||
}
|
||||
|
||||
function selectFile(opts: PickerOpts): Promise<PickedFile> {
|
||||
return new Promise((resolve, reject) => {
|
||||
var input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = opts.mediaType === 'photo' ? 'image/*' : '*/*'
|
||||
input.onchange = e => {
|
||||
const target = e.target as HTMLInputElement
|
||||
const file = target?.files?.[0]
|
||||
if (!file) {
|
||||
return reject(new Error('Canceled'))
|
||||
}
|
||||
|
||||
var reader = new FileReader()
|
||||
reader.readAsDataURL(file)
|
||||
reader.onload = readerEvent => {
|
||||
if (!readerEvent.target) {
|
||||
return reject(new Error('Canceled'))
|
||||
}
|
||||
resolve({
|
||||
uri: readerEvent.target.result as string,
|
||||
path: file.name,
|
||||
size: file.size,
|
||||
})
|
||||
}
|
||||
}
|
||||
input.click()
|
||||
})
|
||||
}
|
|
@ -28,7 +28,7 @@ import {Login} from '../../screens/Login'
|
|||
import {Menu} from './Menu'
|
||||
import {Onboard} from '../../screens/Onboard'
|
||||
import {HorzSwipe} from '../../com/util/gestures/HorzSwipe'
|
||||
import {Modal} from '../../com/modals/Modal'
|
||||
import {ModalsContainer} from '../../com/modals/Modal'
|
||||
import {Lightbox} from '../../com/lightbox/Lightbox'
|
||||
import {Text} from '../../com/util/text/Text'
|
||||
import {ErrorBoundary} from '../../com/util/ErrorBoundary'
|
||||
|
@ -366,7 +366,7 @@ export const MobileShell: React.FC = observer(() => {
|
|||
return (
|
||||
<View style={styles.outerContainer}>
|
||||
<Login />
|
||||
<Modal />
|
||||
<ModalsContainer />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
@ -515,7 +515,7 @@ export const MobileShell: React.FC = observer(() => {
|
|||
notificationCount={store.me.notifications.unreadCount}
|
||||
/>
|
||||
</Animated.View>
|
||||
<Modal />
|
||||
<ModalsContainer />
|
||||
<Lightbox />
|
||||
<Composer
|
||||
active={store.shell.isComposerActive}
|
||||
|
|
|
@ -11,21 +11,28 @@ import {Onboard} from '../../screens/Onboard'
|
|||
import {Login} from '../../screens/Login'
|
||||
import {ErrorBoundary} from '../../com/util/ErrorBoundary'
|
||||
import {Lightbox} from '../../com/lightbox/Lightbox'
|
||||
import {Modal} from '../../com/modals/Modal'
|
||||
import {ModalsContainer} from '../../com/modals/Modal'
|
||||
import {Text} from 'view/com/util/text/Text'
|
||||
import {Composer} from './Composer'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
|
||||
import {s, colors} from 'lib/styles'
|
||||
import {isMobileWeb} from 'platform/detection'
|
||||
|
||||
export const WebShell: React.FC = observer(() => {
|
||||
const pageBg = useColorSchemeStyle(styles.bgLight, styles.bgDark)
|
||||
const store = useStores()
|
||||
const screenRenderDesc = constructScreenRenderDesc(store.nav)
|
||||
|
||||
if (isMobileWeb) {
|
||||
return <NoMobileWeb />
|
||||
}
|
||||
|
||||
if (!store.session.hasSession) {
|
||||
return (
|
||||
<View style={styles.outerContainer}>
|
||||
<Login />
|
||||
<Modal />
|
||||
<ModalsContainer />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
@ -60,21 +67,10 @@ export const WebShell: React.FC = observer(() => {
|
|||
imagesOpen={store.shell.composerOpts?.imagesOpen}
|
||||
onPost={store.shell.composerOpts?.onPost}
|
||||
/>
|
||||
<Modal />
|
||||
<ModalsContainer />
|
||||
<Lightbox />
|
||||
</View>
|
||||
)
|
||||
// TODO
|
||||
// <Modal />
|
||||
// <Lightbox />
|
||||
// <Composer
|
||||
// active={store.shell.isComposerActive}
|
||||
// onClose={() => store.shell.closeComposer()}
|
||||
// winHeight={winDim.height}
|
||||
// replyTo={store.shell.composerOpts?.replyTo}
|
||||
// imagesOpen={store.shell.composerOpts?.imagesOpen}
|
||||
// onPost={store.shell.composerOpts?.onPost}
|
||||
// />
|
||||
})
|
||||
|
||||
/**
|
||||
|
@ -126,6 +122,21 @@ function constructScreenRenderDesc(nav: NavigationModel): {
|
|||
}
|
||||
}
|
||||
|
||||
function NoMobileWeb() {
|
||||
const pal = usePalette('default')
|
||||
return (
|
||||
<View style={[pal.view, styles.noMobileWeb]}>
|
||||
<Text type="title-2xl" style={{paddingBottom: 20}}>
|
||||
We're so sorry!
|
||||
</Text>
|
||||
<Text type="lg">
|
||||
This app is not available for mobile Web yet. Please open it on your
|
||||
desktop or download the iOS app.
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
outerContainer: {
|
||||
height: '100%',
|
||||
|
@ -142,4 +153,10 @@ const styles = StyleSheet.create({
|
|||
hidden: {
|
||||
display: 'none',
|
||||
},
|
||||
noMobileWeb: {
|
||||
height: '100%',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
})
|
||||
|
|
Loading…
Reference in New Issue