about summary refs log tree commit diff
path: root/src/view
diff options
context:
space:
mode:
Diffstat (limited to 'src/view')
-rw-r--r--src/view/com/composer/ComposePost.tsx49
-rw-r--r--src/view/com/composer/photos/PhotoCarouselPicker.tsx91
-rw-r--r--src/view/com/composer/photos/PhotoCarouselPicker.web.tsx156
-rw-r--r--src/view/com/lightbox/ImageViewing/index.tsx4
-rw-r--r--src/view/com/lightbox/Lightbox.tsx2
-rw-r--r--src/view/com/login/CreateAccount.tsx7
-rw-r--r--src/view/com/login/Signin.tsx6
-rw-r--r--src/view/com/modals/EditProfile.tsx4
-rw-r--r--src/view/com/modals/Modal.tsx53
-rw-r--r--src/view/com/modals/Modal.web.tsx78
-rw-r--r--src/view/com/modals/crop-image/CropImage.web.tsx24
-rw-r--r--src/view/com/profile/ProfileHeader.tsx17
-rw-r--r--src/view/com/util/PostEmbeds/index.tsx2
-rw-r--r--src/view/com/util/UserAvatar.tsx5
-rw-r--r--src/view/com/util/UserBanner.tsx5
-rw-r--r--src/view/com/util/ViewHeader.tsx2
-rw-r--r--src/view/com/util/forms/DropdownButton.tsx30
-rw-r--r--src/view/com/util/images/image-crop-picker/ImageCropPicker.tsx92
-rw-r--r--src/view/com/util/images/image-crop-picker/ImageCropPicker.web.tsx75
-rw-r--r--src/view/com/util/images/image-crop-picker/types.ts31
-rw-r--r--src/view/shell/mobile/index.tsx6
-rw-r--r--src/view/shell/web/index.tsx45
22 files changed, 233 insertions, 551 deletions
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.tsx b/src/view/com/util/images/image-crop-picker/ImageCropPicker.tsx
deleted file mode 100644
index d723fef99..000000000
--- a/src/view/com/util/images/image-crop-picker/ImageCropPicker.tsx
+++ /dev/null
@@ -1,92 +0,0 @@
-import {
-  openPicker as openPickerFn,
-  openCamera as openCameraFn,
-  openCropper as openCropperFn,
-  ImageOrVideo,
-} from 'react-native-image-crop-picker'
-import {RootStoreModel} from 'state/index'
-import {PickerOpts, CameraOpts, CropperOpts, PickedMedia} from './types'
-export type {PickedMedia} from './types'
-
-/**
- * NOTE
- * These methods all include the RootStoreModel as the first param
- * because the web versions require it. The signatures have to remain
- * equivalent between the different forms, but the store param is not
- * used here.
- * -prf
- */
-
-export async function openPicker(
-  _store: RootStoreModel,
-  opts: PickerOpts,
-): Promise<PickedMedia[]> {
-  const mediaType = opts.mediaType || 'photo'
-  const items = await openPickerFn({
-    mediaType,
-    multiple: opts.multiple,
-    maxFiles: opts.maxFiles,
-  })
-  const toMedia = (item: ImageOrVideo) => ({
-    mediaType,
-    path: item.path,
-    mime: item.mime,
-    size: item.size,
-    width: item.width,
-    height: item.height,
-  })
-  if (Array.isArray(items)) {
-    return items.map(toMedia)
-  }
-  return [toMedia(items)]
-}
-
-export async function openCamera(
-  _store: RootStoreModel,
-  opts: CameraOpts,
-): Promise<PickedMedia> {
-  const mediaType = opts.mediaType || 'photo'
-  const item = await openCameraFn({
-    mediaType,
-    width: opts.width,
-    height: opts.height,
-    freeStyleCropEnabled: opts.freeStyleCropEnabled,
-    cropperCircleOverlay: opts.cropperCircleOverlay,
-    cropping: true,
-    forceJpg: true, // ios only
-    compressImageQuality: 1.0,
-  })
-  return {
-    mediaType,
-    path: item.path,
-    mime: item.mime,
-    size: item.size,
-    width: item.width,
-    height: item.height,
-  }
-}
-
-export async function openCropper(
-  _store: RootStoreModel,
-  opts: CropperOpts,
-): Promise<PickedMedia> {
-  const mediaType = opts.mediaType || 'photo'
-  const item = await openCropperFn({
-    path: opts.path,
-    mediaType: opts.mediaType || 'photo',
-    width: opts.width,
-    height: opts.height,
-    freeStyleCropEnabled: opts.freeStyleCropEnabled,
-    cropperCircleOverlay: opts.cropperCircleOverlay,
-    forceJpg: true, // ios only
-    compressImageQuality: 1.0,
-  })
-  return {
-    mediaType,
-    path: item.path,
-    mime: item.mime,
-    size: item.size,
-    width: item.width,
-    height: item.height,
-  }
-}
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/com/util/images/image-crop-picker/types.ts b/src/view/com/util/images/image-crop-picker/types.ts
deleted file mode 100644
index 3197b4d3e..000000000
--- a/src/view/com/util/images/image-crop-picker/types.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-export interface PickerOpts {
-  mediaType?: 'photo'
-  multiple?: boolean
-  maxFiles?: number
-}
-
-export interface CameraOpts {
-  mediaType?: 'photo'
-  width: number
-  height: number
-  freeStyleCropEnabled?: boolean
-  cropperCircleOverlay?: boolean
-}
-
-export interface CropperOpts {
-  path: string
-  mediaType?: 'photo'
-  width: number
-  height: number
-  freeStyleCropEnabled?: boolean
-  cropperCircleOverlay?: boolean
-}
-
-export interface PickedMedia {
-  mediaType: 'photo'
-  path: string
-  mime: string
-  size: number
-  width: number
-  height: number
-}
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,
+  },
 })