about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/components/Menu/index.tsx30
-rw-r--r--src/components/icons/Camera.tsx9
-rw-r--r--src/components/icons/StreamingLive.tsx5
-rw-r--r--src/view/com/util/UserAvatar.tsx232
-rw-r--r--src/view/com/util/UserBanner.tsx230
5 files changed, 281 insertions, 225 deletions
diff --git a/src/components/Menu/index.tsx b/src/components/Menu/index.tsx
index ee96a5667..f9b697ea2 100644
--- a/src/components/Menu/index.tsx
+++ b/src/components/Menu/index.tsx
@@ -16,6 +16,10 @@ import {
   ItemTextProps,
   ItemIconProps,
 } from '#/components/Menu/types'
+import {Button, ButtonText} from '#/components/Button'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {isNative} from 'platform/detection'
 
 export {useDialogControl as useMenuControl} from '#/components/Dialog'
 
@@ -68,7 +72,10 @@ export function Trigger({children, label}: TriggerProps) {
   })
 }
 
-export function Outer({children}: React.PropsWithChildren<{}>) {
+export function Outer({
+  children,
+  showCancel,
+}: React.PropsWithChildren<{showCancel?: boolean}>) {
   const context = React.useContext(Context)
 
   return (
@@ -78,7 +85,10 @@ export function Outer({children}: React.PropsWithChildren<{}>) {
       {/* Re-wrap with context since Dialogs are portal-ed to root */}
       <Context.Provider value={context}>
         <Dialog.ScrollableInner label="Menu TODO">
-          <View style={[a.gap_lg]}>{children}</View>
+          <View style={[a.gap_lg]}>
+            {children}
+            {isNative && showCancel && <Cancel />}
+          </View>
           <View style={{height: a.gap_lg.gap}} />
         </Dialog.ScrollableInner>
       </Context.Provider>
@@ -185,6 +195,22 @@ export function Group({children, style}: GroupProps) {
   )
 }
 
+function Cancel() {
+  const {_} = useLingui()
+  const {control} = React.useContext(Context)
+
+  return (
+    <Button
+      label={_(msg`Close this dialog`)}
+      size="small"
+      variant="ghost"
+      color="secondary"
+      onPress={() => control.close()}>
+      <ButtonText>Cancel</ButtonText>
+    </Button>
+  )
+}
+
 export function Divider() {
   return null
 }
diff --git a/src/components/icons/Camera.tsx b/src/components/icons/Camera.tsx
new file mode 100644
index 000000000..ced8e7442
--- /dev/null
+++ b/src/components/icons/Camera.tsx
@@ -0,0 +1,9 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Camera_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M8.371 3.89A2 2 0 0 1 10.035 3h3.93a2 2 0 0 1 1.664.89L17.035 6H20a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h2.965L8.37 3.89ZM13.965 5h-3.93L8.63 7.11A2 2 0 0 1 6.965 8H4v11h16V8h-2.965a2 2 0 0 1-1.664-.89L13.965 5ZM12 11a2 2 0 1 0 0 4 2 2 0 0 0 0-4Zm-4 2a4 4 0 1 1 8 0 4 4 0 0 1-8 0Z',
+})
+
+export const Camera_Filled_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M8.371 3.89A2 2 0 0 1 10.035 3h3.93a2 2 0 0 1 1.664.89L17.035 6H20a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h2.965L8.37 3.89ZM12 9a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Z',
+})
diff --git a/src/components/icons/StreamingLive.tsx b/src/components/icons/StreamingLive.tsx
new file mode 100644
index 000000000..8ab5099da
--- /dev/null
+++ b/src/components/icons/StreamingLive.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const StreamingLive_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M4 4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2H4Zm8 12.5c1.253 0 2.197.609 2.674 1.5H9.326c.477-.891 1.42-1.5 2.674-1.5Zm0-2c2.404 0 4.235 1.475 4.822 3.5H20V6H4v12h3.178c.587-2.025 2.418-3.5 4.822-3.5Zm-1.25-3.75a1.25 1.25 0 1 1 2.5 0 1.25 1.25 0 0 1-2.5 0ZM12 7.5a3.25 3.25 0 1 0 0 6.5 3.25 3.25 0 0 0 0-6.5Zm5.75 2a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Z',
+})
diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx
index f673db1ee..413237397 100644
--- a/src/view/com/util/UserAvatar.tsx
+++ b/src/view/com/util/UserAvatar.tsx
@@ -1,9 +1,13 @@
 import React, {memo, useMemo} from 'react'
-import {Image, StyleSheet, View} from 'react-native'
+import {Image, StyleSheet, TouchableOpacity, View} from 'react-native'
 import Svg, {Circle, Rect, Path} from 'react-native-svg'
+import {Image as RNImage} from 'react-native-image-crop-picker'
+import {useLingui} from '@lingui/react'
+import {msg, Trans} from '@lingui/macro'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {HighPriorityImage} from 'view/com/util/images/Image'
 import {ModerationUI} from '@atproto/api'
+
+import {HighPriorityImage} from 'view/com/util/images/Image'
 import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
 import {
   usePhotoLibraryPermission,
@@ -11,12 +15,16 @@ import {
 } from 'lib/hooks/usePermissions'
 import {colors} from 'lib/styles'
 import {usePalette} from 'lib/hooks/usePalette'
-import {isWeb, isAndroid} from 'platform/detection'
-import {Image as RNImage} from 'react-native-image-crop-picker'
+import {isWeb, isAndroid, isNative} from 'platform/detection'
 import {UserPreviewLink} from './UserPreviewLink'
-import {DropdownItem, NativeDropdown} from './forms/NativeDropdown'
-import {useLingui} from '@lingui/react'
-import {msg} from '@lingui/macro'
+import * as Menu from '#/components/Menu'
+import {
+  Camera_Stroke2_Corner0_Rounded as Camera,
+  Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled,
+} from '#/components/icons/Camera'
+import {StreamingLive_Stroke2_Corner0_Rounded as Library} from '#/components/icons/StreamingLive'
+import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
+import {useTheme} from '#/alf'
 
 export type UserAvatarType = 'user' | 'algo' | 'list'
 
@@ -196,6 +204,7 @@ let EditableUserAvatar = ({
   avatar,
   onSelectNewAvatar,
 }: EditableUserAvatarProps): React.ReactNode => {
+  const t = useTheme()
   const pal = usePalette('default')
   const {_} = useLingui()
   const {requestCameraAccessIfNeeded} = useCameraPermission()
@@ -216,118 +225,115 @@ let EditableUserAvatar = ({
     }
   }, [type, size])
 
-  const dropdownItems = useMemo(
-    () =>
-      [
-        !isWeb && {
-          testID: 'changeAvatarCameraBtn',
-          label: _(msg`Camera`),
-          icon: {
-            ios: {
-              name: 'camera',
-            },
-            android: 'ic_menu_camera',
-            web: 'camera',
-          },
-          onPress: async () => {
-            if (!(await requestCameraAccessIfNeeded())) {
-              return
-            }
+  const onOpenCamera = React.useCallback(async () => {
+    if (!(await requestCameraAccessIfNeeded())) {
+      return
+    }
 
-            onSelectNewAvatar(
-              await openCamera({
-                width: 1000,
-                height: 1000,
-                cropperCircleOverlay: true,
-              }),
-            )
-          },
-        },
-        {
-          testID: 'changeAvatarLibraryBtn',
-          label: _(msg`Library`),
-          icon: {
-            ios: {
-              name: 'photo.on.rectangle.angled',
-            },
-            android: 'ic_menu_gallery',
-            web: 'gallery',
-          },
-          onPress: async () => {
-            if (!(await requestPhotoAccessIfNeeded())) {
-              return
-            }
+    onSelectNewAvatar(
+      await openCamera({
+        width: 1000,
+        height: 1000,
+        cropperCircleOverlay: true,
+      }),
+    )
+  }, [onSelectNewAvatar, requestCameraAccessIfNeeded])
 
-            const items = await openPicker({
-              aspect: [1, 1],
-            })
-            const item = items[0]
-            if (!item) {
-              return
-            }
+  const onOpenLibrary = React.useCallback(async () => {
+    if (!(await requestPhotoAccessIfNeeded())) {
+      return
+    }
 
-            const croppedImage = await openCropper({
-              mediaType: 'photo',
-              cropperCircleOverlay: true,
-              height: item.height,
-              width: item.width,
-              path: item.path,
-            })
+    const items = await openPicker({
+      aspect: [1, 1],
+    })
+    const item = items[0]
+    if (!item) {
+      return
+    }
 
-            onSelectNewAvatar(croppedImage)
-          },
-        },
-        !!avatar && {
-          label: 'separator',
-        },
-        !!avatar && {
-          testID: 'changeAvatarRemoveBtn',
-          label: _(msg`Remove`),
-          icon: {
-            ios: {
-              name: 'trash',
-            },
-            android: 'ic_delete',
-            web: ['far', 'trash-can'],
-          },
-          onPress: async () => {
-            onSelectNewAvatar(null)
-          },
-        },
-      ].filter(Boolean) as DropdownItem[],
-    [
-      avatar,
-      onSelectNewAvatar,
-      requestCameraAccessIfNeeded,
-      requestPhotoAccessIfNeeded,
-      _,
-    ],
-  )
+    const croppedImage = await openCropper({
+      mediaType: 'photo',
+      cropperCircleOverlay: true,
+      height: item.height,
+      width: item.width,
+      path: item.path,
+    })
+
+    onSelectNewAvatar(croppedImage)
+  }, [onSelectNewAvatar, requestPhotoAccessIfNeeded])
+
+  const onRemoveAvatar = React.useCallback(() => {
+    onSelectNewAvatar(null)
+  }, [onSelectNewAvatar])
 
   return (
-    <NativeDropdown
-      testID="changeAvatarBtn"
-      items={dropdownItems}
-      accessibilityLabel={_(msg`Image options`)}
-      accessibilityHint="">
-      {avatar ? (
-        <HighPriorityImage
-          testID="userAvatarImage"
-          style={aviStyle}
-          source={{uri: avatar}}
-          accessibilityRole="image"
-        />
-      ) : (
-        <DefaultAvatar type={type} size={size} />
-      )}
-      <View style={[styles.editButtonContainer, pal.btn]}>
-        <FontAwesomeIcon
-          icon="camera"
-          size={12}
-          color={pal.text.color as string}
-        />
-      </View>
-    </NativeDropdown>
+    <Menu.Root>
+      <Menu.Trigger label={_(msg`Edit avatar`)}>
+        {({props}) => (
+          <TouchableOpacity {...props} activeOpacity={0.8}>
+            {avatar ? (
+              <HighPriorityImage
+                testID="userAvatarImage"
+                style={aviStyle}
+                source={{uri: avatar}}
+                accessibilityRole="image"
+              />
+            ) : (
+              <DefaultAvatar type={type} size={size} />
+            )}
+            <View style={[styles.editButtonContainer, pal.btn]}>
+              <CameraFilled height={14} width={14} style={t.atoms.text} />
+            </View>
+          </TouchableOpacity>
+        )}
+      </Menu.Trigger>
+      <Menu.Outer showCancel>
+        <Menu.Group>
+          {isNative && (
+            <Menu.Item
+              testID="changeAvatarCameraBtn"
+              label={_(msg`Upload from Camera`)}
+              onPress={onOpenCamera}>
+              <Menu.ItemText>
+                <Trans>Upload from Camera</Trans>
+              </Menu.ItemText>
+              <Menu.ItemIcon icon={Camera} />
+            </Menu.Item>
+          )}
+
+          <Menu.Item
+            testID="changeAvatarLibraryBtn"
+            label={_(msg`Upload from Library`)}
+            onPress={onOpenLibrary}>
+            <Menu.ItemText>
+              {isNative ? (
+                <Trans>Upload from Library</Trans>
+              ) : (
+                <Trans>Upload from Files</Trans>
+              )}
+            </Menu.ItemText>
+            <Menu.ItemIcon icon={Library} />
+          </Menu.Item>
+        </Menu.Group>
+        {!!avatar && (
+          <>
+            <Menu.Divider />
+            <Menu.Group>
+              <Menu.Item
+                testID="changeAvatarRemoveBtn"
+                label={_(`Remove Avatar`)}
+                onPress={onRemoveAvatar}>
+                <Menu.ItemText>
+                  <Trans>Remove Avatar</Trans>
+                </Menu.ItemText>
+                <Menu.ItemIcon icon={Trash} />
+              </Menu.Item>
+            </Menu.Group>
+          </>
+        )}
+      </Menu.Outer>
+    </Menu.Root>
   )
 }
 EditableUserAvatar = memo(EditableUserAvatar)
diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx
index cb47b6659..a5ddfee8a 100644
--- a/src/view/com/util/UserBanner.tsx
+++ b/src/view/com/util/UserBanner.tsx
@@ -1,21 +1,29 @@
-import React, {useMemo} from 'react'
-import {StyleSheet, View} from 'react-native'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import React from 'react'
+import {StyleSheet, TouchableOpacity, View} from 'react-native'
 import {ModerationUI} from '@atproto/api'
 import {Image} from 'expo-image'
 import {useLingui} from '@lingui/react'
-import {msg} from '@lingui/macro'
+import {msg, Trans} from '@lingui/macro'
+
 import {colors} from 'lib/styles'
 import {useTheme} from 'lib/ThemeContext'
+import {useTheme as useAlfTheme} from '#/alf'
 import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
 import {
   usePhotoLibraryPermission,
   useCameraPermission,
 } from 'lib/hooks/usePermissions'
 import {usePalette} from 'lib/hooks/usePalette'
-import {isWeb, isAndroid} from 'platform/detection'
+import {isAndroid, isNative} from 'platform/detection'
 import {Image as RNImage} from 'react-native-image-crop-picker'
-import {NativeDropdown, DropdownItem} from './forms/NativeDropdown'
+import {EventStopper} from 'view/com/util/EventStopper'
+import * as Menu from '#/components/Menu'
+import {
+  Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled,
+  Camera_Stroke2_Corner0_Rounded as Camera,
+} from '#/components/icons/Camera'
+import {StreamingLive_Stroke2_Corner0_Rounded as Library} from '#/components/icons/StreamingLive'
+import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
 
 export function UserBanner({
   banner,
@@ -28,118 +36,120 @@ export function UserBanner({
 }) {
   const pal = usePalette('default')
   const theme = useTheme()
+  const t = useAlfTheme()
   const {_} = useLingui()
   const {requestCameraAccessIfNeeded} = useCameraPermission()
   const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
 
-  const dropdownItems: DropdownItem[] = useMemo(
-    () =>
-      [
-        !isWeb && {
-          testID: 'changeBannerCameraBtn',
-          label: _(msg`Camera`),
-          icon: {
-            ios: {
-              name: 'camera',
-            },
-            android: 'ic_menu_camera',
-            web: 'camera',
-          },
-          onPress: async () => {
-            if (!(await requestCameraAccessIfNeeded())) {
-              return
-            }
-            onSelectNewBanner?.(
-              await openCamera({
-                width: 3000,
-                height: 1000,
-              }),
-            )
-          },
-        },
-        {
-          testID: 'changeBannerLibraryBtn',
-          label: _(msg`Library`),
-          icon: {
-            ios: {
-              name: 'photo.on.rectangle.angled',
-            },
-            android: 'ic_menu_gallery',
-            web: 'gallery',
-          },
-          onPress: async () => {
-            if (!(await requestPhotoAccessIfNeeded())) {
-              return
-            }
-            const items = await openPicker()
-            if (!items[0]) {
-              return
-            }
+  const onOpenCamera = React.useCallback(async () => {
+    if (!(await requestCameraAccessIfNeeded())) {
+      return
+    }
+    onSelectNewBanner?.(
+      await openCamera({
+        width: 3000,
+        height: 1000,
+      }),
+    )
+  }, [onSelectNewBanner, requestCameraAccessIfNeeded])
 
-            onSelectNewBanner?.(
-              await openCropper({
-                mediaType: 'photo',
-                path: items[0].path,
-                width: 3000,
-                height: 1000,
-              }),
-            )
-          },
-        },
-        !!banner && {
-          testID: 'changeBannerRemoveBtn',
-          label: _(msg`Remove`),
-          icon: {
-            ios: {
-              name: 'trash',
-            },
-            android: 'ic_delete',
-            web: ['far', 'trash-can'],
-          },
-          onPress: () => {
-            onSelectNewBanner?.(null)
-          },
-        },
-      ].filter(Boolean) as DropdownItem[],
-    [
-      banner,
-      onSelectNewBanner,
-      requestCameraAccessIfNeeded,
-      requestPhotoAccessIfNeeded,
-      _,
-    ],
-  )
+  const onOpenLibrary = React.useCallback(async () => {
+    if (!(await requestPhotoAccessIfNeeded())) {
+      return
+    }
+    const items = await openPicker()
+    if (!items[0]) {
+      return
+    }
+
+    onSelectNewBanner?.(
+      await openCropper({
+        mediaType: 'photo',
+        path: items[0].path,
+        width: 3000,
+        height: 1000,
+      }),
+    )
+  }, [onSelectNewBanner, requestPhotoAccessIfNeeded])
+
+  const onRemoveBanner = React.useCallback(() => {
+    onSelectNewBanner?.(null)
+  }, [onSelectNewBanner])
 
   // setUserBanner is only passed as prop on the EditProfile component
   return onSelectNewBanner ? (
-    <NativeDropdown
-      testID="changeBannerBtn"
-      items={dropdownItems}
-      accessibilityLabel={_(msg`Image options`)}
-      accessibilityHint="">
-      {banner ? (
-        <Image
-          testID="userBannerImage"
-          style={styles.bannerImage}
-          source={{uri: banner}}
-          accessible={true}
-          accessibilityIgnoresInvertColors
-        />
-      ) : (
-        <View
-          testID="userBannerFallback"
-          style={[styles.bannerImage, styles.defaultBanner]}
-        />
-      )}
-      <View style={[styles.editButtonContainer, pal.btn]}>
-        <FontAwesomeIcon
-          icon="camera"
-          size={12}
-          style={{color: colors.white}}
-          color={pal.text.color as string}
-        />
-      </View>
-    </NativeDropdown>
+    <EventStopper onKeyDown={false}>
+      <Menu.Root>
+        <Menu.Trigger label={_(msg`Edit avatar`)}>
+          {({props}) => (
+            <TouchableOpacity {...props} activeOpacity={0.8}>
+              {banner ? (
+                <Image
+                  testID="userBannerImage"
+                  style={styles.bannerImage}
+                  source={{uri: banner}}
+                  accessible={true}
+                  accessibilityIgnoresInvertColors
+                />
+              ) : (
+                <View
+                  testID="userBannerFallback"
+                  style={[styles.bannerImage, styles.defaultBanner]}
+                />
+              )}
+              <View style={[styles.editButtonContainer, pal.btn]}>
+                <CameraFilled height={14} width={14} style={t.atoms.text} />
+              </View>
+            </TouchableOpacity>
+          )}
+        </Menu.Trigger>
+        <Menu.Outer showCancel>
+          <Menu.Group>
+            {isNative && (
+              <Menu.Item
+                testID="changeBannerCameraBtn"
+                label={_(msg`Upload from Camera`)}
+                onPress={onOpenCamera}>
+                <Menu.ItemText>
+                  <Trans>Upload from Camera</Trans>
+                </Menu.ItemText>
+                <Menu.ItemIcon icon={Camera} />
+              </Menu.Item>
+            )}
+
+            <Menu.Item
+              testID="changeBannerLibraryBtn"
+              label={_(msg`Upload from Library`)}
+              onPress={onOpenLibrary}>
+              <Menu.ItemText>
+                {isNative ? (
+                  <Trans>Upload from Library</Trans>
+                ) : (
+                  <Trans>Upload from Files</Trans>
+                )}
+              </Menu.ItemText>
+              <Menu.ItemIcon icon={Library} />
+            </Menu.Item>
+          </Menu.Group>
+          {!!banner && (
+            <>
+              <Menu.Divider />
+              <Menu.Group>
+                <Menu.Item
+                  testID="changeBannerRemoveBtn"
+                  label={_(`Remove Banner`)}
+                  onPress={onRemoveBanner}>
+                  <Menu.ItemText>
+                    <Trans>Remove Banner</Trans>
+                  </Menu.ItemText>
+                  <Menu.ItemIcon icon={Trash} />
+                </Menu.Item>
+              </Menu.Group>
+            </>
+          )}
+        </Menu.Outer>
+      </Menu.Root>
+    </EventStopper>
   ) : banner &&
     !((moderation?.blur && isAndroid) /* android crashes with blur */) ? (
     <Image