diff options
Diffstat (limited to 'src')
32 files changed, 567 insertions, 534 deletions
diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index d800c376c..ae156928e 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -1,11 +1,16 @@ -import {AppBskyEmbedImages, AppBskyEmbedExternal} from '@atproto/api' +import { + AppBskyEmbedImages, + AppBskyEmbedExternal, + ComAtprotoBlobUpload, +} from '@atproto/api' import {AtUri} from '../../third-party/uri' import {RootStoreModel} from 'state/models/root-store' import {extractEntities} from 'lib/strings/rich-text-detection' import {isNetworkError} from 'lib/strings/errors' import {LinkMeta} from '../link-meta/link-meta' -import {Image} from '../images' +import {Image} from '../media/manip' import {RichText} from '../strings/rich-text' +import {isWeb} from 'platform/detection' export interface ExternalEmbedDraft { uri: string @@ -27,6 +32,25 @@ export async function resolveName(store: RootStoreModel, didOrHandle: string) { return res.data.did } +export async function uploadBlob( + store: RootStoreModel, + blob: string, + encoding: string, +): Promise<ComAtprotoBlobUpload.Response> { + if (isWeb) { + // `blob` should be a data uri + return store.api.com.atproto.blob.upload(convertDataURIToUint8Array(blob), { + encoding, + }) + } else { + // `blob` should be a path to a file in the local FS + return store.api.com.atproto.blob.upload( + blob, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts + {encoding}, + ) + } +} + export async function post( store: RootStoreModel, rawText: string, @@ -61,10 +85,7 @@ export async function post( let i = 1 for (const image of images) { onStateChange?.(`Uploading image #${i++}...`) - const res = await store.api.com.atproto.blob.upload( - image, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts - {encoding: 'image/jpeg'}, - ) + const res = await uploadBlob(store, image, 'image/jpeg') embed.images.push({ image: { cid: res.data.cid, @@ -94,9 +115,10 @@ export async function post( ) } if (encoding) { - const thumbUploadRes = await store.api.com.atproto.blob.upload( - extLink.localThumb.path, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts - {encoding}, + const thumbUploadRes = await uploadBlob( + store, + extLink.localThumb.path, + encoding, ) thumb = { cid: thumbUploadRes.data.cid, @@ -199,3 +221,15 @@ export async function unfollow(store: RootStoreModel, followUri: string) { rkey: followUrip.rkey, }) } + +// helpers +// = + +function convertDataURIToUint8Array(uri: string): Uint8Array { + var raw = window.atob(uri.substring(uri.indexOf(';base64,') + 8)) + var binary = new Uint8Array(new ArrayBuffer(raw.length)) + for (let i = 0; i < raw.length; i++) { + binary[i] = raw.charCodeAt(i) + } + return binary +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 2a3043c06..72cba0b63 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -63,3 +63,7 @@ export const STAGING_SUGGESTED_FOLLOWS = ['arcalinea', 'paul', 'paul2'].map( export const DEV_SUGGESTED_FOLLOWS = ['alice', 'bob', 'carla'].map( handle => `${handle}.test`, ) + +export const POST_IMG_MAX_WIDTH = 2000 +export const POST_IMG_MAX_HEIGHT = 2000 +export const POST_IMG_MAX_SIZE = 1000000 diff --git a/src/lib/images.ts b/src/lib/media/manip.ts index 609e03bda..e44ee3907 100644 --- a/src/lib/images.ts +++ b/src/lib/media/manip.ts @@ -1,6 +1,6 @@ import RNFetchBlob from 'rn-fetch-blob' import ImageResizer from '@bam.tech/react-native-image-resizer' -import {Share} from 'react-native' +import {Image as RNImage, Share} from 'react-native' import RNFS from 'react-native-fs' import uuid from 'react-native-uuid' import * as Toast from 'view/com/util/Toast' @@ -135,7 +135,7 @@ export function scaleDownDimensions(dim: Dim, max: Dim): Dim { return {width: dim.width * hScale, height: dim.height * hScale} } -export const saveImageModal = async ({uri}: {uri: string}) => { +export async function saveImageModal({uri}: {uri: string}) { const downloadResponse = await RNFetchBlob.config({ fileCache: true, }).fetch('GET', uri) @@ -153,7 +153,7 @@ export const saveImageModal = async ({uri}: {uri: string}) => { RNFS.unlink(imagePath) } -export const moveToPremanantPath = async (path: string) => { +export async function moveToPremanantPath(path: string) { /* Since this package stores images in a temp directory, we need to move the file to a permanent location. Relevant: IOS bug when trying to open a second time: @@ -164,3 +164,15 @@ export const moveToPremanantPath = async (path: string) => { RNFS.moveFile(path, destinationPath) return destinationPath } + +export function getImageDim(path: string): Promise<Dim> { + return new Promise((resolve, reject) => { + RNImage.getSize( + path, + (width, height) => { + resolve({width, height}) + }, + reject, + ) + }) +} diff --git a/src/lib/images.web.ts b/src/lib/media/manip.web.ts index 4b6d93af2..e617d01af 100644 --- a/src/lib/images.web.ts +++ b/src/lib/media/manip.web.ts @@ -39,11 +39,16 @@ export async function resize( } export async function compressIfNeeded( - _img: Image, - _maxSize: number, + img: Image, + maxSize: number, ): Promise<Image> { - // TODO - throw new Error('TODO') + if (img.size > maxSize) { + // TODO + throw new Error( + "This image is too large and we haven't implemented compression yet -- sorry!", + ) + } + return img } export interface Dim { @@ -62,7 +67,22 @@ export function scaleDownDimensions(dim: Dim, max: Dim): Dim { return {width: dim.width * hScale, height: dim.height * hScale} } -export const saveImageModal = async (_opts: {uri: string}) => { +export async function saveImageModal(_opts: {uri: string}) { // TODO throw new Error('TODO') } + +export async function moveToPremanantPath(path: string) { + return path +} + +export async function getImageDim(path: string): Promise<Dim> { + var img = document.createElement('img') + const promise = new Promise((resolve, reject) => { + img.onload = resolve + img.onerror = reject + }) + img.src = path + await promise + return {width: img.width, height: img.height} +} diff --git a/src/view/com/util/images/image-crop-picker/ImageCropPicker.tsx b/src/lib/media/picker.tsx index d723fef99..940366035 100644 --- a/src/view/com/util/images/image-crop-picker/ImageCropPicker.tsx +++ b/src/lib/media/picker.tsx @@ -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 +} diff --git a/src/lib/media/picker.web.tsx b/src/lib/media/picker.web.tsx new file mode 100644 index 000000000..746feaedd --- /dev/null +++ b/src/lib/media/picker.web.tsx @@ -0,0 +1,144 @@ +/// <reference lib="dom" /> + +import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types' +export type {PickedMedia} from './types' +import {RootStoreModel} from 'state/index' +import { + scaleDownDimensions, + getImageDim, + Dim, + compressIfNeeded, + moveToPremanantPath, +} from 'lib/media/manip' + +interface PickedFile { + uri: string + path: string + size: number +} + +export async function openPicker( + _store: RootStoreModel, + opts: PickerOpts, +): Promise<PickedMedia[]> { + const res = await selectFile(opts) + const dim = await getImageDim(res.uri) + const mime = extractDataUriMime(res.uri) + return [ + { + mediaType: 'photo', + path: res.uri, + mime, + size: res.size, + width: dim.width, + height: dim.height, + }, + ] +} + +export async function openCamera( + _store: RootStoreModel, + _opts: CameraOpts, +): Promise<PickedMedia> { + // const mediaType = opts.mediaType || 'photo' TODO + throw new Error('TODO') +} + +export async function openCropper( + store: RootStoreModel, + opts: CropperOpts, +): Promise<PickedMedia> { + // TODO handle more opts + return new Promise((resolve, reject) => { + store.shell.openModal({ + name: 'crop-image', + uri: opts.path, + onSelect: (img?: PickedMedia) => { + if (img) { + resolve(img) + } else { + reject(new Error('Canceled')) + } + }, + }) + }) +} + +export async function pickImagesFlow( + store: RootStoreModel, + maxFiles: number, + maxDim: Dim, + maxSize: number, +) { + const items = await openPicker(store, { + multiple: true, + maxFiles, + mediaType: 'photo', + }) + const result = [] + for (const image of items) { + result.push( + await cropAndCompressFlow(store, image.path, image, maxDim, maxSize), + ) + } + return result +} + +export async function cropAndCompressFlow( + store: RootStoreModel, + path: string, + imgDim: Dim, + maxDim: Dim, + maxSize: number, +) { + // choose target dimensions based on the original + // this causes the photo cropper to start with the full image "selected" + const {width, height} = scaleDownDimensions(imgDim, maxDim) + const cropperRes = await openCropper(store, { + mediaType: 'photo', + path, + freeStyleCropEnabled: true, + width, + height, + }) + + const img = await compressIfNeeded(cropperRes, maxSize) + const permanentPath = await moveToPremanantPath(img.path) + return permanentPath +} + +// helpers +// = + +function selectFile(opts: PickerOpts): Promise<PickedFile> { + return new Promise((resolve, reject) => { + var input = document.createElement('input') + input.type = 'file' + input.accept = opts.mediaType === 'photo' ? 'image/*' : '*/*' + input.onchange = e => { + const target = e.target as HTMLInputElement + const file = target?.files?.[0] + if (!file) { + return reject(new Error('Canceled')) + } + + var reader = new FileReader() + reader.readAsDataURL(file) + reader.onload = readerEvent => { + if (!readerEvent.target) { + return reject(new Error('Canceled')) + } + resolve({ + uri: readerEvent.target.result as string, + path: file.name, + size: file.size, + }) + } + } + input.click() + }) +} + +function extractDataUriMime(uri: string): string { + return uri.substring(uri.indexOf(':') + 1, uri.indexOf(';')) +} diff --git a/src/view/com/util/images/image-crop-picker/types.ts b/src/lib/media/types.ts index 3197b4d3e..3197b4d3e 100644 --- a/src/view/com/util/images/image-crop-picker/types.ts +++ b/src/lib/media/types.ts diff --git a/src/platform/polyfills.native.ts b/src/platform/polyfills.native.ts deleted file mode 100644 index 26a535cca..000000000 --- a/src/platform/polyfills.native.ts +++ /dev/null @@ -1 +0,0 @@ -import '@zxing/text-encoding' // TextEncoder / TextDecoder diff --git a/src/platform/polyfills.ts b/src/platform/polyfills.ts index ac31e55ef..336ce12bb 100644 --- a/src/platform/polyfills.ts +++ b/src/platform/polyfills.ts @@ -1,2 +1 @@ -// do nothing export {} diff --git a/src/platform/polyfills.web.ts b/src/platform/polyfills.web.ts new file mode 100644 index 000000000..7a42f4887 --- /dev/null +++ b/src/platform/polyfills.web.ts @@ -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) diff --git a/src/state/models/profile-view.ts b/src/state/models/profile-view.ts index 8630eae52..0988367b6 100644 --- a/src/state/models/profile-view.ts +++ b/src/state/models/profile-view.ts @@ -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, diff --git a/src/state/models/shell-ui.ts b/src/state/models/shell-ui.ts index b9f480ecd..640bed0b3 100644 --- a/src/state/models/shell-ui.ts +++ b/src/state/models/shell-ui.ts @@ -2,75 +2,57 @@ 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' - - constructor() { - makeAutoObservable(this) - } +export interface DeleteAccountModal { + name: 'delete-account' } +export type Modal = + | ConfirmModal + | EditProfileModal + | ServerInputModal + | ReportPostModal + | ReportAccountModal + | CropImageModal + | DeleteAccountModal + interface LightboxModel {} export class ProfileImageLightbox implements 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) { diff --git a/src/view/com/composer/ComposePost.tsx b/src/view/com/composer/ComposePost.tsx index 6431a11aa..6f8fe460d 100644 --- a/src/view/com/composer/ComposePost.tsx +++ b/src/view/com/composer/ComposePost.tsx @@ -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,12 +181,24 @@ export const ComposePost = observer(function ComposePost({ const onPressContainer = () => { textInput.current?.focus() } - const onPressSelectPhotos = () => { + const onPressSelectPhotos = async () => { track('ComposePost:SelectPhotos') - if (isSelectingPhotos) { - setIsSelectingPhotos(false) - } else if (selectedPhotos.length < 4) { - setIsSelectingPhotos(true) + 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[]) => { @@ -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]) } } diff --git a/src/view/com/composer/photos/PhotoCarouselPicker.tsx b/src/view/com/composer/photos/PhotoCarouselPicker.tsx index 406f8b04c..ab2fdc478 100644 --- a/src/view/com/composer/photos/PhotoCarouselPicker.tsx +++ b/src/view/com/composer/photos/PhotoCarouselPicker.tsx @@ -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]) diff --git a/src/view/com/composer/photos/PhotoCarouselPicker.web.tsx b/src/view/com/composer/photos/PhotoCarouselPicker.web.tsx index 607f8e724..ff4350b0c 100644 --- a/src/view/com/composer/photos/PhotoCarouselPicker.web.tsx +++ b/src/view/com/composer/photos/PhotoCarouselPicker.web.tsx @@ -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) - } - }, [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> - ) + return <></> } - -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, - }, -}) diff --git a/src/view/com/lightbox/ImageViewing/index.tsx b/src/view/com/lightbox/ImageViewing/index.tsx index 83259330f..722540a58 100644 --- a/src/view/com/lightbox/ImageViewing/index.tsx +++ b/src/view/com/lightbox/ImageViewing/index.tsx @@ -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' ? ( diff --git a/src/view/com/lightbox/Lightbox.tsx b/src/view/com/lightbox/Lightbox.tsx index 894c6b118..7509e07c4 100644 --- a/src/view/com/lightbox/Lightbox.tsx +++ b/src/view/com/lightbox/Lightbox.tsx @@ -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() { diff --git a/src/view/com/login/CreateAccount.tsx b/src/view/com/login/CreateAccount.tsx index 6dc93f5e3..870013f3b 100644 --- a/src/view/com/login/CreateAccount.tsx +++ b/src/view/com/login/CreateAccount.tsx @@ -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() } diff --git a/src/view/com/login/Signin.tsx b/src/view/com/login/Signin.tsx index eed173119..a311e2999 100644 --- a/src/view/com/login/Signin.tsx +++ b/src/view/com/login/Signin.tsx @@ -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') } diff --git a/src/view/com/modals/EditProfile.tsx b/src/view/com/modals/EditProfile.tsx index add75e89e..f822fcfd9 100644 --- a/src/view/com/modals/EditProfile.tsx +++ b/src/view/com/modals/EditProfile.tsx @@ -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' diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index 2529d0d5b..58dd12e61 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -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 { diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index 3c6551093..38b526d29 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -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: { diff --git a/src/view/com/modals/crop-image/CropImage.web.tsx b/src/view/com/modals/crop-image/CropImage.web.tsx index e43f37397..c774b94e1 100644 --- a/src/view/com/modals/crop-image/CropImage.web.tsx +++ b/src/view/com/modals/crop-image/CropImage.web.tsx @@ -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, diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx index 0ca6e1e74..087b1f39b 100644 --- a/src/view/com/profile/ProfileHeader.tsx +++ b/src/view/com/profile/ProfileHeader.tsx @@ -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 diff --git a/src/view/com/util/PostEmbeds/index.tsx b/src/view/com/util/PostEmbeds/index.tsx index 031f01e88..d2186b600 100644 --- a/src/view/com/util/PostEmbeds/index.tsx +++ b/src/view/com/util/PostEmbeds/index.tsx @@ -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' diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index 9b8dd3de5..5a7a4801d 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -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 () => { diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx index dc140b035..06a80d45b 100644 --- a/src/view/com/util/UserBanner.tsx +++ b/src/view/com/util/UserBanner.tsx @@ -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 () => { diff --git a/src/view/com/util/ViewHeader.tsx b/src/view/com/util/ViewHeader.tsx index 0c3ca0bf5..fe195c7b1 100644 --- a/src/view/com/util/ViewHeader.tsx +++ b/src/view/com/util/ViewHeader.tsx @@ -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}> diff --git a/src/view/com/util/forms/DropdownButton.tsx b/src/view/com/util/forms/DropdownButton.tsx index 8fddd5941..2946c5ca0 100644 --- a/src/view/com/util/forms/DropdownButton.tsx +++ b/src/view/com/util/forms/DropdownButton.tsx @@ -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, diff --git a/src/view/com/util/images/image-crop-picker/ImageCropPicker.web.tsx b/src/view/com/util/images/image-crop-picker/ImageCropPicker.web.tsx deleted file mode 100644 index d632590d6..000000000 --- a/src/view/com/util/images/image-crop-picker/ImageCropPicker.web.tsx +++ /dev/null @@ -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() - }) -} diff --git a/src/view/shell/mobile/index.tsx b/src/view/shell/mobile/index.tsx index 0b3921b7e..da8e73a60 100644 --- a/src/view/shell/mobile/index.tsx +++ b/src/view/shell/mobile/index.tsx @@ -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} diff --git a/src/view/shell/web/index.tsx b/src/view/shell/web/index.tsx index fff34b1ce..b9ae32f27 100644 --- a/src/view/shell/web/index.tsx +++ b/src/view/shell/web/index.tsx @@ -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, + }, }) |