diff --git a/__e2e__/tests/create-account.test.ts b/__e2e__/tests/create-account.test.ts
index 7db4e912..8706fae7 100644
--- a/__e2e__/tests/create-account.test.ts
+++ b/__e2e__/tests/create-account.test.ts
@@ -25,6 +25,8 @@ describe('Create account', () => {
await element(by.id('handleInput')).typeText('e2e-test')
await device.takeScreenshot('4- entered handle')
await element(by.id('nextBtn')).tap()
+ await expect(element(by.id('welcomeScreen'))).toBeVisible()
+ await element(by.id('continueBtn')).tap()
await expect(element(by.id('homeScreen'))).toBeVisible()
})
})
diff --git a/__e2e__/tests/home-screen.test.ts b/__e2e__/tests/home-screen.test.ts
index 7fa9ff28..d0eeb670 100644
--- a/__e2e__/tests/home-screen.test.ts
+++ b/__e2e__/tests/home-screen.test.ts
@@ -55,7 +55,7 @@ describe('Home screen', () => {
await element(by.id('postDropdownBtn').withAncestor(carlaPosts))
.atIndex(0)
.tap()
- await element(by.id('postDropdownReportBtn')).tap()
+ await element(by.text('Report post')).tap()
await expect(element(by.id('reportPostModal'))).toBeVisible()
await element(
by.id('reportPostRadios-com.atproto.moderation.defs#reasonSpam'),
@@ -84,7 +84,7 @@ describe('Home screen', () => {
await element(by.id('postDropdownBtn').withAncestor(alicePosts))
.atIndex(0)
.tap()
- await element(by.id('postDropdownDeleteBtn')).tap()
+ await element(by.text('Delete post')).tap()
await expect(element(by.id('confirmModal'))).toBeVisible()
await element(by.id('confirmBtn')).tap()
await expect(
diff --git a/__e2e__/tests/invite-codes.test.ts b/__e2e__/tests/invite-codes.test.ts
index 846d3b76..74b80a8d 100644
--- a/__e2e__/tests/invite-codes.test.ts
+++ b/__e2e__/tests/invite-codes.test.ts
@@ -42,6 +42,8 @@ describe('invite-codes', () => {
await element(by.id('handleInput')).typeText('e2e-test')
await device.takeScreenshot('4- entered handle')
await element(by.id('nextBtn')).tap()
+ await expect(element(by.id('welcomeScreen'))).toBeVisible()
+ await element(by.id('continueBtn')).tap()
await expect(element(by.id('homeScreen'))).toBeVisible()
await element(by.id('viewHeaderDrawerBtn')).tap()
await element(by.id('menuItemButton-Settings')).tap()
diff --git a/__e2e__/tests/profile-screen.test.ts b/__e2e__/tests/profile-screen.test.ts
index a7bb9365..6c6d6db9 100644
--- a/__e2e__/tests/profile-screen.test.ts
+++ b/__e2e__/tests/profile-screen.test.ts
@@ -62,10 +62,10 @@ describe('Profile screen', () => {
await element(by.id('profileHeaderEditProfileButton')).tap()
await expect(element(by.id('editProfileModal'))).toBeVisible()
await element(by.id('changeBannerBtn')).tap()
- await element(by.id('changeBannerLibraryBtn')).tap()
+ await element(by.text('Library')).tap()
await sleep(3e3)
await element(by.id('changeAvatarBtn')).tap()
- await element(by.id('changeAvatarLibraryBtn')).tap()
+ await element(by.text('Library')).tap()
await sleep(3e3)
await element(by.id('editProfileSaveBtn')).tap()
await expect(element(by.id('editProfileModal'))).not.toBeVisible()
@@ -79,9 +79,9 @@ describe('Profile screen', () => {
await element(by.id('profileHeaderEditProfileButton')).tap()
await expect(element(by.id('editProfileModal'))).toBeVisible()
await element(by.id('changeBannerBtn')).tap()
- await element(by.id('changeBannerRemoveBtn')).tap()
+ await element(by.text('Remove')).tap()
await element(by.id('changeAvatarBtn')).tap()
- await element(by.id('changeAvatarRemoveBtn')).tap()
+ await element(by.text('Remove')).tap()
await element(by.id('editProfileSaveBtn')).tap()
await expect(element(by.id('editProfileModal'))).not.toBeVisible()
await expect(element(by.id('userBannerFallback'))).toExist()
@@ -109,16 +109,16 @@ describe('Profile screen', () => {
it('Can mute/unmute another user', async () => {
await expect(element(by.id('profileHeaderMutedNotice'))).not.toExist()
await element(by.id('profileHeaderDropdownBtn')).tap()
- await element(by.id('profileHeaderDropdownMuteBtn')).tap()
+ await element(by.text('Mute Account')).tap()
await expect(element(by.id('profileHeaderMutedNotice'))).toBeVisible()
await element(by.id('profileHeaderDropdownBtn')).tap()
- await element(by.id('profileHeaderDropdownMuteBtn')).tap()
+ await element(by.text('Unmute Account')).tap()
await expect(element(by.id('profileHeaderMutedNotice'))).not.toExist()
})
it('Can report another user', async () => {
await element(by.id('profileHeaderDropdownBtn')).tap()
- await element(by.id('profileHeaderDropdownReportBtn')).tap()
+ await element(by.text('Report Account')).tap()
await expect(element(by.id('reportAccountModal'))).toBeVisible()
await element(
by.id('reportAccountRadios-com.atproto.moderation.defs#reasonSpam'),
@@ -166,7 +166,7 @@ describe('Profile screen', () => {
it('Can report posts', async () => {
const posts = by.id('feedItem-by-bob.test')
await element(by.id('postDropdownBtn').withAncestor(posts)).atIndex(0).tap()
- await element(by.id('postDropdownReportBtn')).tap()
+ await element(by.text('Report post')).tap()
await expect(element(by.id('reportPostModal'))).toBeVisible()
await element(
by.id('reportPostRadios-com.atproto.moderation.defs#reasonSpam'),
diff --git a/__e2e__/tests/thread-muting.test.ts b/__e2e__/tests/thread-muting.test.ts
index a5cefdb2..8acd9d81 100644
--- a/__e2e__/tests/thread-muting.test.ts
+++ b/__e2e__/tests/thread-muting.test.ts
@@ -45,7 +45,7 @@ describe('Thread muting', () => {
await element(by.id('postDropdownBtn').withAncestor(bobNotifs))
.atIndex(0)
.tap()
- await element(by.id('postDropdownMuteThreadBtn')).tap()
+ await element(by.text('Mute thread')).tap()
// have to wait for the toast to clear
await waitFor(element(by.id('viewHeaderDrawerBtn')))
.toBeVisible()
@@ -93,7 +93,7 @@ describe('Thread muting', () => {
await element(by.id('postDropdownBtn').withAncestor(alicePosts))
.atIndex(0)
.tap()
- await element(by.id('postDropdownMuteThreadBtn')).tap()
+ await element(by.text('Mute thread')).tap()
// TODO
// the swipe down to trigger PTR isnt working and I dont want to block on this
diff --git a/__e2e__/tests/thread-screen.test.ts b/__e2e__/tests/thread-screen.test.ts
index 8d3eacc8..081282a3 100644
--- a/__e2e__/tests/thread-screen.test.ts
+++ b/__e2e__/tests/thread-screen.test.ts
@@ -104,7 +104,7 @@ describe('Thread screen', () => {
it('Can report the root post', async () => {
const post = by.id('postThreadItem-by-bob.test')
await element(by.id('postDropdownBtn').withAncestor(post)).atIndex(0).tap()
- await element(by.id('postDropdownReportBtn')).tap()
+ await element(by.text('Report post')).tap()
await expect(element(by.id('reportPostModal'))).toBeVisible()
await element(
by.id('reportPostRadios-com.atproto.moderation.defs#reasonSpam'),
@@ -116,7 +116,7 @@ describe('Thread screen', () => {
it('Can report a reply post', async () => {
const post = by.id('postThreadItem-by-carla.test')
await element(by.id('postDropdownBtn').withAncestor(post)).atIndex(0).tap()
- await element(by.id('postDropdownReportBtn')).tap()
+ await element(by.text('Report post')).tap()
await expect(element(by.id('reportPostModal'))).toBeVisible()
await element(
by.id('reportPostRadios-com.atproto.moderation.defs#reasonSpam'),
diff --git a/__mocks__/zeego/dropdown-menu.js b/__mocks__/zeego/dropdown-menu.js
new file mode 100644
index 00000000..1d51addc
--- /dev/null
+++ b/__mocks__/zeego/dropdown-menu.js
@@ -0,0 +1,2 @@
+export const DropdownMenu = jest.fn().mockImplementation(() => {})
+export const create = jest.fn().mockImplementation(() => {})
diff --git a/app.json b/app.json
index 2513fbba..be643334 100644
--- a/app.json
+++ b/app.json
@@ -80,6 +80,8 @@
{
"android": {
"compileSdkVersion": 34,
+ "targetSdkVersion": 34,
+ "buildToolsVersion": "34.0.0",
"kotlinVersion": "1.8.0"
}
}
diff --git a/package.json b/package.json
index 4bb54087..1ede7bf1 100644
--- a/package.json
+++ b/package.json
@@ -42,6 +42,7 @@
"@react-native-clipboard/clipboard": "^1.10.0",
"@react-native-community/blur": "^4.3.0",
"@react-native-community/datetimepicker": "6.7.3",
+ "@react-native-menu/menu": "^0.8.0",
"@react-navigation/bottom-tabs": "^6.5.7",
"@react-navigation/drawer": "^6.6.2",
"@react-navigation/native": "^6.1.6",
@@ -120,6 +121,7 @@
"react-native-haptic-feedback": "^1.14.0",
"react-native-image-crop-picker": "^0.38.1",
"react-native-inappbrowser-reborn": "^3.6.3",
+ "react-native-ios-context-menu": "^1.15.3",
"react-native-linear-gradient": "^2.6.2",
"react-native-pager-view": "6.1.4",
"react-native-progress": "bluesky-social/react-native-progress",
@@ -139,6 +141,7 @@
"sentry-expo": "~6.1.0",
"tippy.js": "^6.3.7",
"tlds": "^1.234.0",
+ "zeego": "^1.6.2",
"zod": "^3.20.2"
},
"devDependencies": {
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index 34da35e4..001cdf8c 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -1,3 +1,5 @@
+import {Insets} from 'react-native'
+
const HELP_DESK_LANG = 'en-us'
export const HELP_DESK_URL = `https://blueskyweb.zendesk.com/hc/${HELP_DESK_LANG}`
@@ -134,3 +136,15 @@ export function LINK_META_PROXY(serviceUrl: string) {
}
export const STATUS_PAGE_URL = 'https://status.bsky.app/'
+
+// Hitslop constants
+export const createHitslop = (size: number): Insets => ({
+ top: size,
+ left: size,
+ bottom: size,
+ right: size,
+})
+export const HITSLOP_10 = createHitslop(10)
+export const HITSLOP_20 = createHitslop(20)
+export const HITSLOP_30 = createHitslop(30)
+export const BACK_HITSLOP = HITSLOP_30
diff --git a/src/view/com/auth/onboarding/Welcome.tsx b/src/view/com/auth/onboarding/Welcome.tsx
index e7c068ea..87435c88 100644
--- a/src/view/com/auth/onboarding/Welcome.tsx
+++ b/src/view/com/auth/onboarding/Welcome.tsx
@@ -10,7 +10,7 @@ export const Welcome = ({next}: {next: () => void}) => {
const pal = usePalette('default')
return (
-
+
Welcome to
Bluesky
@@ -52,7 +52,12 @@ export const Welcome = ({next}: {next: () => void}) => {
-
+
)
}
diff --git a/src/view/com/composer/photos/OpenCameraBtn.tsx b/src/view/com/composer/photos/OpenCameraBtn.tsx
index 0f955984..d58b17c5 100644
--- a/src/view/com/composer/photos/OpenCameraBtn.tsx
+++ b/src/view/com/composer/photos/OpenCameraBtn.tsx
@@ -10,11 +10,9 @@ import {useStores} from 'state/index'
import {isDesktopWeb} from 'platform/detection'
import {openCamera} from 'lib/media/picker'
import {useCameraPermission} from 'lib/hooks/usePermissions'
-import {POST_IMG_MAX} from 'lib/constants'
+import {HITSLOP_10, POST_IMG_MAX} from 'lib/constants'
import {GalleryModel} from 'state/models/media/gallery'
-const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10}
-
type Props = {
gallery: GalleryModel
}
@@ -54,7 +52,7 @@ export function OpenCameraBtn({gallery}: Props) {
testID="openCameraButton"
onPress={onPressTakePicture}
style={styles.button}
- hitSlop={HITSLOP}
+ hitSlop={HITSLOP_10}
accessibilityRole="button"
accessibilityLabel="Camera"
accessibilityHint="Opens camera on device">
diff --git a/src/view/com/composer/photos/SelectPhotoBtn.tsx b/src/view/com/composer/photos/SelectPhotoBtn.tsx
index aaf0477c..081456f7 100644
--- a/src/view/com/composer/photos/SelectPhotoBtn.tsx
+++ b/src/view/com/composer/photos/SelectPhotoBtn.tsx
@@ -9,8 +9,7 @@ import {useAnalytics} from 'lib/analytics/analytics'
import {isDesktopWeb} from 'platform/detection'
import {usePhotoLibraryPermission} from 'lib/hooks/usePermissions'
import {GalleryModel} from 'state/models/media/gallery'
-
-const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10}
+import {HITSLOP_10} from 'lib/constants'
type Props = {
gallery: GalleryModel
@@ -36,7 +35,7 @@ export function SelectPhotoBtn({gallery}: Props) {
testID="openGalleryBtn"
onPress={onPressSelectPhotos}
style={styles.button}
- hitSlop={HITSLOP}
+ hitSlop={HITSLOP_10}
accessibilityRole="button"
accessibilityLabel="Gallery"
accessibilityHint="Opens device photo gallery">
diff --git a/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx b/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx
index 84e5f90f..c95538c5 100644
--- a/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx
+++ b/src/view/com/lightbox/ImageViewing/components/ImageDefaultHeader.tsx
@@ -6,6 +6,7 @@
*
*/
+import {createHitslop} from 'lib/constants'
import React from 'react'
import {SafeAreaView, Text, TouchableOpacity, StyleSheet} from 'react-native'
@@ -13,7 +14,7 @@ type Props = {
onRequestClose: () => void
}
-const HIT_SLOP = {top: 16, left: 16, bottom: 16, right: 16}
+const HIT_SLOP = createHitslop(16)
const ImageDefaultHeader = ({onRequestClose}: Props) => (
diff --git a/src/view/com/pager/FeedsTabBarMobile.tsx b/src/view/com/pager/FeedsTabBarMobile.tsx
index 62117356..55a38803 100644
--- a/src/view/com/pager/FeedsTabBarMobile.tsx
+++ b/src/view/com/pager/FeedsTabBarMobile.tsx
@@ -12,6 +12,7 @@ import {Text} from '../util/text/Text'
import {CogIcon} from 'lib/icons'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {s} from 'lib/styles'
+import {HITSLOP_10} from 'lib/constants'
export const FeedsTabBar = observer(
(
@@ -54,7 +55,7 @@ export const FeedsTabBar = observer(
accessibilityRole="button"
accessibilityLabel="Open navigation"
accessibilityHint="Access profile and other navigation links"
- hitSlop={10}>
+ hitSlop={HITSLOP_10}>
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index 0680bbc0..edf8d774 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -11,7 +11,7 @@ import {PostThreadItemModel} from 'state/models/content/post-thread-item'
import {Link} from '../util/Link'
import {RichText} from '../util/text/RichText'
import {Text} from '../util/text/Text'
-import {PostDropdownBtn} from '../util/forms/DropdownButton'
+import {PostDropdownBtn} from '../util/forms/PostDropdownBtn'
import * as Toast from '../util/Toast'
import {PreviewableUserAvatar} from '../util/UserAvatar'
import {s} from 'lib/styles'
@@ -202,7 +202,6 @@ export const PostThreadItem = observer(function PostThreadItem({
-
-
+ onDeletePost={onDeletePost}
+ />
void
@@ -260,15 +259,29 @@ const ProfileHeaderLoaded = observer(
testID: 'profileHeaderDropdownShareBtn',
label: 'Share',
onPress: onPressShare,
+ icon: {
+ ios: {
+ name: 'square.and.arrow.up',
+ },
+ android: 'ic_menu_share',
+ web: 'share',
+ },
},
]
if (!isMe) {
- items.push({sep: true})
+ items.push({label: 'separator'})
// Only add "Add to Lists" on other user's profiles, doesn't make sense to mute my own self!
items.push({
testID: 'profileHeaderDropdownListAddRemoveBtn',
label: 'Add to Lists',
onPress: onPressAddRemoveLists,
+ icon: {
+ ios: {
+ name: 'list.bullet',
+ },
+ android: 'ic_menu_add',
+ web: 'list',
+ },
})
if (!view.viewer.blocking) {
items.push({
@@ -277,6 +290,13 @@ const ProfileHeaderLoaded = observer(
onPress: view.viewer.muted
? onPressUnmuteAccount
: onPressMuteAccount,
+ icon: {
+ ios: {
+ name: 'speaker.slash',
+ },
+ android: 'ic_lock_silent_mode',
+ web: 'comment-slash',
+ },
})
}
items.push({
@@ -285,11 +305,25 @@ const ProfileHeaderLoaded = observer(
onPress: view.viewer.blocking
? onPressUnblockAccount
: onPressBlockAccount,
+ icon: {
+ ios: {
+ name: 'person.fill.xmark',
+ },
+ android: 'ic_menu_close_clear_cancel',
+ web: 'user-slash',
+ },
})
items.push({
testID: 'profileHeaderDropdownReportBtn',
label: 'Report Account',
onPress: onPressReportAccount,
+ icon: {
+ ios: {
+ name: 'exclamationmark.triangle',
+ },
+ android: 'ic_menu_report_image',
+ web: 'circle-exclamation',
+ },
})
}
return items
@@ -380,13 +414,17 @@ const ProfileHeaderLoaded = observer(
>
) : null}
{dropdownItems?.length ? (
-
-
-
+ items={dropdownItems}>
+
+
+
+
) : undefined}
diff --git a/src/view/com/search/HeaderWithInput.tsx b/src/view/com/search/HeaderWithInput.tsx
index 2ec079dd..f825c578 100644
--- a/src/view/com/search/HeaderWithInput.tsx
+++ b/src/view/com/search/HeaderWithInput.tsx
@@ -10,8 +10,7 @@ import {useTheme} from 'lib/ThemeContext'
import {usePalette} from 'lib/hooks/usePalette'
import {useStores} from 'state/index'
import {useAnalytics} from 'lib/analytics/analytics'
-
-const MENU_HITSLOP = {left: 10, top: 10, right: 30, bottom: 10}
+import {HITSLOP_10} from 'lib/constants'
interface Props {
isInputFocused: boolean
@@ -55,7 +54,7 @@ export function HeaderWithInput({
[
- !isWeb && {
- testID: 'changeAvatarCameraBtn',
- label: 'Camera',
- icon: 'camera' as IconProp,
- onPress: async () => {
- if (!(await requestCameraAccessIfNeeded())) {
- return
- }
+ () =>
+ [
+ !isWeb && {
+ testID: 'changeAvatarCameraBtn',
+ label: 'Camera',
+ icon: {
+ ios: {
+ name: 'camera',
+ },
+ android: 'ic_menu_camera',
+ web: 'camera',
+ },
+ onPress: async () => {
+ if (!(await requestCameraAccessIfNeeded())) {
+ return
+ }
- onSelectNewAvatar?.(
- await openCamera(store, {
- width: 1000,
- height: 1000,
+ onSelectNewAvatar?.(
+ await openCamera(store, {
+ width: 1000,
+ height: 1000,
+ cropperCircleOverlay: true,
+ }),
+ )
+ },
+ },
+ {
+ testID: 'changeAvatarLibraryBtn',
+ label: 'Library',
+ icon: {
+ ios: {
+ name: 'photo.on.rectangle.angled',
+ },
+ android: 'ic_menu_gallery',
+ web: 'gallery',
+ },
+ onPress: async () => {
+ if (!(await requestPhotoAccessIfNeeded())) {
+ return
+ }
+
+ const items = await openPicker({
+ aspect: [1, 1],
+ })
+ const item = items[0]
+
+ const croppedImage = await openCropper(store, {
+ mediaType: 'photo',
cropperCircleOverlay: true,
- }),
- )
- },
- },
- {
- testID: 'changeAvatarLibraryBtn',
- label: 'Library',
- icon: 'image' as IconProp,
- onPress: async () => {
- if (!(await requestPhotoAccessIfNeeded())) {
- return
- }
+ height: item.height,
+ width: item.width,
+ path: item.path,
+ })
- const items = await openPicker({
- aspect: [1, 1],
- })
- const item = items[0]
-
- const croppedImage = await openCropper(store, {
- mediaType: 'photo',
- cropperCircleOverlay: true,
- height: item.height,
- width: item.width,
- path: item.path,
- })
-
- onSelectNewAvatar?.(croppedImage)
+ onSelectNewAvatar?.(croppedImage)
+ },
},
- },
- !!avatar && {
- testID: 'changeAvatarRemoveBtn',
- label: 'Remove',
- icon: ['far', 'trash-can'] as IconProp,
- onPress: async () => {
- onSelectNewAvatar?.(null)
+ !!avatar && {
+ label: 'separator',
},
- },
- ],
+ !!avatar && {
+ testID: 'changeAvatarRemoveBtn',
+ label: 'Remove',
+ icon: {
+ ios: {
+ name: 'trash',
+ },
+ android: 'ic_delete',
+ web: 'trash',
+ },
+ onPress: async () => {
+ onSelectNewAvatar?.(null)
+ },
+ },
+ ].filter(Boolean) as DropdownItem[],
[
avatar,
onSelectNewAvatar,
@@ -209,14 +230,7 @@ export function UserAvatar({
// onSelectNewAvatar is only passed as prop on the EditProfile component
return onSelectNewAvatar ? (
-
+
{avatar ? (
-
+
) : avatar &&
!((moderation?.blur && isAndroid) /* android crashes with blur */) ? (
diff --git a/src/view/com/util/UserBanner.tsx b/src/view/com/util/UserBanner.tsx
index cce0e839..b7e91b5d 100644
--- a/src/view/com/util/UserBanner.tsx
+++ b/src/view/com/util/UserBanner.tsx
@@ -1,7 +1,6 @@
-import React from 'react'
+import React, {useMemo} from 'react'
import {StyleSheet, View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {IconProp} from '@fortawesome/fontawesome-svg-core'
import {Image} from 'expo-image'
import {colors} from 'lib/styles'
import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
@@ -10,11 +9,11 @@ import {
usePhotoLibraryPermission,
useCameraPermission,
} from 'lib/hooks/usePermissions'
-import {DropdownButton} from './forms/DropdownButton'
import {usePalette} from 'lib/hooks/usePalette'
import {AvatarModeration} from 'lib/labeling/types'
import {isWeb, isAndroid} from 'platform/detection'
import {Image as RNImage} from 'react-native-image-crop-picker'
+import {NativeDropdown, DropdownItem} from './forms/NativeDropdown'
export function UserBanner({
banner,
@@ -30,63 +29,84 @@ export function UserBanner({
const {requestCameraAccessIfNeeded} = useCameraPermission()
const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
- const dropdownItems = [
- !isWeb && {
- testID: 'changeBannerCameraBtn',
- label: 'Camera',
- icon: 'camera' as IconProp,
- onPress: async () => {
- if (!(await requestCameraAccessIfNeeded())) {
- return
- }
- onSelectNewBanner?.(
- await openCamera(store, {
- width: 3000,
- height: 1000,
- }),
- )
- },
- },
- {
- testID: 'changeBannerLibraryBtn',
- label: 'Library',
- icon: 'image' as IconProp,
- onPress: async () => {
- if (!(await requestPhotoAccessIfNeeded())) {
- return
- }
- const items = await openPicker()
+ const dropdownItems: DropdownItem[] = useMemo(
+ () =>
+ [
+ !isWeb && {
+ testID: 'changeBannerCameraBtn',
+ label: 'Camera',
+ icon: {
+ ios: {
+ name: 'camera',
+ },
+ android: 'ic_menu_camera',
+ web: 'camera',
+ },
+ onPress: async () => {
+ if (!(await requestCameraAccessIfNeeded())) {
+ return
+ }
+ onSelectNewBanner?.(
+ await openCamera(store, {
+ width: 3000,
+ height: 1000,
+ }),
+ )
+ },
+ },
+ {
+ testID: 'changeBannerLibraryBtn',
+ label: 'Library',
+ icon: {
+ ios: {
+ name: 'photo.on.rectangle.angled',
+ },
+ android: 'ic_menu_gallery',
+ web: 'gallery',
+ },
+ onPress: async () => {
+ if (!(await requestPhotoAccessIfNeeded())) {
+ return
+ }
+ const items = await openPicker()
- onSelectNewBanner?.(
- await openCropper(store, {
- mediaType: 'photo',
- path: items[0].path,
- width: 3000,
- height: 1000,
- }),
- )
- },
- },
- !!banner && {
- testID: 'changeBannerRemoveBtn',
- label: 'Remove',
- icon: ['far', 'trash-can'] as IconProp,
- onPress: () => {
- onSelectNewBanner?.(null)
- },
- },
- ]
+ onSelectNewBanner?.(
+ await openCropper(store, {
+ mediaType: 'photo',
+ path: items[0].path,
+ width: 3000,
+ height: 1000,
+ }),
+ )
+ },
+ },
+ !!banner && {
+ testID: 'changeBannerRemoveBtn',
+ label: 'Remove',
+ icon: {
+ ios: {
+ name: 'trash',
+ },
+ android: 'ic_delete',
+ web: 'trash',
+ },
+ onPress: () => {
+ onSelectNewBanner?.(null)
+ },
+ },
+ ].filter(Boolean) as DropdownItem[],
+ [
+ banner,
+ onSelectNewBanner,
+ requestCameraAccessIfNeeded,
+ requestPhotoAccessIfNeeded,
+ store,
+ ],
+ )
// setUserBanner is only passed as prop on the EditProfile component
return onSelectNewBanner ? (
-
+
{banner ? (
-
+
) : banner &&
!((moderation?.blur && isAndroid) /* android crashes with blur */) ? (
- children?: React.ReactNode
- itemUri: string
- itemCid: string
- itemHref: string
- itemTitle: string
- isAuthor: boolean
- isThreadMuted: boolean
- onCopyPostText: () => void
- onOpenTranslate: () => void
- onToggleThreadMute: () => void
- onDeletePost: () => void
-}) {
- const store = useStores()
-
- const dropdownItems: DropdownItem[] = [
- {
- testID: 'postDropdownTranslateBtn',
- icon: 'language',
- label: 'Translate...',
- onPress() {
- onOpenTranslate()
- },
- },
- {
- testID: 'postDropdownCopyTextBtn',
- icon: ['far', 'paste'],
- label: 'Copy post text',
- onPress() {
- onCopyPostText()
- },
- },
- {
- testID: 'postDropdownShareBtn',
- icon: 'share',
- label: 'Share...',
- onPress() {
- const url = toShareUrl(itemHref)
- shareUrl(url)
- },
- },
- {sep: true},
- {
- testID: 'postDropdownMuteThreadBtn',
- icon: 'comment-slash',
- label: isThreadMuted ? 'Unmute thread' : 'Mute thread',
- onPress() {
- onToggleThreadMute()
- },
- },
- {sep: true},
- !isAuthor && {
- testID: 'postDropdownReportBtn',
- icon: 'circle-exclamation',
- label: 'Report post',
- onPress() {
- store.shell.openModal({
- name: 'report-post',
- postUri: itemUri,
- postCid: itemCid,
- })
- },
- },
- isAuthor && {
- testID: 'postDropdownDeleteBtn',
- icon: ['far', 'trash-can'],
- label: 'Delete post',
- onPress() {
- store.shell.openModal({
- name: 'confirm',
- title: 'Delete this post?',
- message: 'Are you sure? This can not be undone.',
- onPressConfirm: onDeletePost,
- })
- },
- },
- ].filter(Boolean) as DropdownItem[]
-
- return (
-
- {children}
-
- )
-}
-
function createDropdownMenu(
x: number,
y: number,
@@ -324,15 +214,16 @@ const DropdownItems = ({
const numItems = items.filter(isBtn).length
+ // TODO: Refactor dropdown components to:
+ // - (On web, if not handled by React Native) use semantic
+ // and elements for keyboard navigation out of the box
+ // - (On mobile) be buttons by default, accept `label` and `nativeID`
+ // props, and always have an explicit label
return (
<>
+ {/* This TouchableWithoutFeedback renders the background so if the user clicks outside, the dropdown closes */}
- // and elements for keyboard navigation out of the box
- // - (On mobile) be buttons by default, accept `label` and `nativeID`
- // props, and always have an explicit label
accessibilityRole="button"
accessibilityLabel="Toggle dropdown"
accessibilityHint="">
diff --git a/src/view/com/util/forms/NativeDropdown.tsx b/src/view/com/util/forms/NativeDropdown.tsx
new file mode 100644
index 00000000..d8f16ce1
--- /dev/null
+++ b/src/view/com/util/forms/NativeDropdown.tsx
@@ -0,0 +1,250 @@
+import React from 'react'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import * as DropdownMenu from 'zeego/dropdown-menu'
+import {
+ Pressable,
+ StyleSheet,
+ Platform,
+ StyleProp,
+ ViewStyle,
+} from 'react-native'
+import {IconProp} from '@fortawesome/fontawesome-svg-core'
+import {MenuItemCommonProps} from 'zeego/lib/typescript/menu'
+import {usePalette} from 'lib/hooks/usePalette'
+import {isWeb} from 'platform/detection'
+import {useTheme} from 'lib/ThemeContext'
+import {HITSLOP_10} from 'lib/constants'
+
+// Custom Dropdown Menu Components
+// ==
+export const DropdownMenuRoot = DropdownMenu.Root
+export const DropdownMenuTrigger = DropdownMenu.Trigger
+export const DropdownMenuContent = DropdownMenu.Content
+type ItemProps = React.ComponentProps<(typeof DropdownMenu)['Item']>
+export const DropdownMenuItem = DropdownMenu.create(
+ (props: ItemProps & {testID?: string}) => {
+ const pal = usePalette('default')
+ const theme = useTheme()
+ const [focused, setFocused] = React.useState(false)
+ const {borderColor: backgroundColor} =
+ theme.colorScheme === 'dark' ? pal.borderDark : pal.border
+
+ return (
+ {
+ setFocused(true)
+ props.onFocus && props.onFocus()
+ }}
+ onBlur={() => {
+ setFocused(false)
+ props.onBlur && props.onBlur()
+ }}
+ />
+ )
+ },
+ 'Item',
+)
+type TitleProps = React.ComponentProps<(typeof DropdownMenu)['ItemTitle']>
+export const DropdownMenuItemTitle = DropdownMenu.create(
+ (props: TitleProps) => {
+ const pal = usePalette('default')
+ return (
+
+ )
+ },
+ 'ItemTitle',
+)
+type IconProps = React.ComponentProps<(typeof DropdownMenu)['ItemIcon']>
+export const DropdownMenuItemIcon = DropdownMenu.create((props: IconProps) => {
+ return
+}, 'ItemIcon')
+type SeparatorProps = React.ComponentProps<(typeof DropdownMenu)['Separator']>
+export const DropdownMenuSeparator = DropdownMenu.create(
+ (props: SeparatorProps) => {
+ const pal = usePalette('default')
+ const theme = useTheme()
+ const {borderColor: separatorColor} =
+ theme.colorScheme === 'dark' ? pal.borderDark : pal.border
+ return (
+
+ )
+ },
+ 'Separator',
+)
+
+// Types for Dropdown Menu and Items
+export type DropdownItem = {
+ label: string | 'separator'
+ onPress?: () => void
+ testID?: string
+ icon?: {
+ ios: MenuItemCommonProps['ios']
+ android: string
+ web: IconProp
+ }
+}
+type Props = {
+ items: DropdownItem[]
+ children?: React.ReactNode
+ testID?: string
+}
+
+/* The `NativeDropdown` function uses native iOS and Android dropdown menus.
+ * It also creates a animated custom dropdown for web that uses
+ * Radix UI primitives under the hood
+ * @prop {DropdownItem[]} items - An array of dropdown items
+ * @prop {React.ReactNode} children - A custom dropdown trigger
+ */
+export function NativeDropdown({items, children, testID}: Props) {
+ const pal = usePalette('default')
+ const theme = useTheme()
+ const dropDownBackgroundColor =
+ theme.colorScheme === 'dark' ? pal.btn : pal.viewLight
+ const defaultCtrlColor = React.useMemo(
+ () => ({
+ color: theme.palette.default.postCtrl,
+ }),
+ [theme],
+ ) as StyleProp
+
+ return (
+
+
+ [{opacity: pressed ? 0.5 : 1}]}
+ hitSlop={HITSLOP_10}>
+ {children ? (
+ children
+ ) : (
+
+ )}
+
+
+
+ {items.map((item, index) => {
+ if (item.label === 'separator') {
+ return (
+
+ )
+ }
+ if (index > 1 && items[index - 1].label === 'separator') {
+ return (
+
+
+ {item.label}
+ {item.icon && (
+
+
+
+ )}
+
+
+ )
+ }
+ return (
+
+ {item.label}
+ {item.icon && (
+
+
+
+ )}
+
+ )
+ })}
+
+
+ )
+}
+
+const getKey = (label: string, index: number, id?: string) => {
+ if (id) {
+ return id
+ }
+ return `${label}_${index}`
+}
+
+const styles = StyleSheet.create({
+ separator: {
+ height: 1,
+ marginVertical: 4,
+ },
+ ellipsis: {
+ padding: isWeb ? 0 : 10,
+ },
+ content: {
+ backgroundColor: '#f0f0f0',
+ borderRadius: 8,
+ paddingVertical: 4,
+ paddingHorizontal: 4,
+ marginTop: 6,
+ ...Platform.select({
+ web: {
+ animationDuration: '400ms',
+ animationTimingFunction: 'cubic-bezier(0.16, 1, 0.3, 1)',
+ willChange: 'transform, opacity',
+ animationKeyframes: {
+ '0%': {opacity: 0, transform: [{scale: 0.5}]},
+ '100%': {opacity: 1, transform: [{scale: 1}]},
+ },
+ boxShadow:
+ '0px 10px 38px -10px rgba(22, 23, 24, 0.35), 0px 10px 20px -15px rgba(22, 23, 24, 0.2)',
+ transformOrigin: 'var(--radix-dropdown-menu-content-transform-origin)',
+ },
+ }),
+ },
+ item: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ columnGap: 20,
+ // @ts-ignore -web
+ cursor: 'pointer',
+ paddingVertical: 8,
+ paddingHorizontal: 12,
+ borderRadius: 8,
+ },
+ itemTitle: {
+ fontSize: 18,
+ },
+})
diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx
new file mode 100644
index 00000000..ad9ba161
--- /dev/null
+++ b/src/view/com/util/forms/PostDropdownBtn.tsx
@@ -0,0 +1,148 @@
+import React from 'react'
+import {toShareUrl} from 'lib/strings/url-helpers'
+import {useStores} from 'state/index'
+import {shareUrl} from 'lib/sharing'
+import {
+ NativeDropdown,
+ DropdownItem as NativeDropdownItem,
+} from './NativeDropdown'
+import {Pressable} from 'react-native'
+
+export function PostDropdownBtn({
+ testID,
+ itemUri,
+ itemCid,
+ itemHref,
+ isAuthor,
+ isThreadMuted,
+ onCopyPostText,
+ onOpenTranslate,
+ onToggleThreadMute,
+ onDeletePost,
+}: {
+ testID: string
+ itemUri: string
+ itemCid: string
+ itemHref: string
+ itemTitle: string
+ isAuthor: boolean
+ isThreadMuted: boolean
+ onCopyPostText: () => void
+ onOpenTranslate: () => void
+ onToggleThreadMute: () => void
+ onDeletePost: () => void
+}) {
+ const store = useStores()
+
+ const dropdownItems: NativeDropdownItem[] = [
+ {
+ label: 'Translate',
+ onPress() {
+ onOpenTranslate()
+ },
+ testID: 'postDropdownTranslateBtn',
+ icon: {
+ ios: {
+ name: 'character.book.closed',
+ },
+ android: 'ic_menu_sort_alphabetically',
+ web: 'language',
+ },
+ },
+ {
+ label: 'Copy post text',
+ onPress() {
+ onCopyPostText()
+ },
+ testID: 'postDropdownCopyTextBtn',
+ icon: {
+ ios: {
+ name: 'doc.on.doc',
+ },
+ android: 'ic_menu_edit',
+ web: ['far', 'paste'],
+ },
+ },
+ {
+ label: 'Share',
+ onPress() {
+ const url = toShareUrl(itemHref)
+ shareUrl(url)
+ },
+ testID: 'postDropdownShareBtn',
+ icon: {
+ ios: {
+ name: 'square.and.arrow.up',
+ },
+ android: 'ic_menu_share',
+ web: 'share',
+ },
+ },
+ {
+ label: 'separator',
+ },
+ {
+ label: isThreadMuted ? 'Unmute thread' : 'Mute thread',
+ onPress() {
+ onToggleThreadMute()
+ },
+ testID: 'postDropdownMuteThreadBtn',
+ icon: {
+ ios: {
+ name: 'speaker.slash',
+ },
+ android: 'ic_lock_silent_mode',
+ web: 'comment-slash',
+ },
+ },
+ {
+ label: 'separator',
+ },
+ {
+ label: 'Report post',
+ onPress() {
+ store.shell.openModal({
+ name: 'report-post',
+ postUri: itemUri,
+ postCid: itemCid,
+ })
+ },
+ testID: 'postDropdownReportBtn',
+ icon: {
+ ios: {
+ name: 'exclamationmark.triangle',
+ },
+ android: 'ic_menu_report_image',
+ web: 'circle-exclamation',
+ },
+ },
+ isAuthor && {
+ label: 'separator',
+ },
+ isAuthor && {
+ label: 'Delete post',
+ onPress() {
+ store.shell.openModal({
+ name: 'confirm',
+ title: 'Delete this post?',
+ message: 'Are you sure? This can not be undone.',
+ onPressConfirm: onDeletePost,
+ })
+ },
+ testID: 'postDropdownDeleteBtn',
+ icon: {
+ ios: {
+ name: 'trash',
+ },
+ android: 'ic_menu_delete',
+ web: ['far', 'trash-can'],
+ },
+ },
+ ].filter(Boolean) as NativeDropdownItem[]
+
+ return (
+
+
+
+ )
+}
diff --git a/src/view/com/util/load-latest/LoadLatestBtn.web.tsx b/src/view/com/util/load-latest/LoadLatestBtn.web.tsx
index fefc540c..c90e5dfb 100644
--- a/src/view/com/util/load-latest/LoadLatestBtn.web.tsx
+++ b/src/view/com/util/load-latest/LoadLatestBtn.web.tsx
@@ -5,8 +5,7 @@ import {Text} from '../text/Text'
import {usePalette} from 'lib/hooks/usePalette'
import {LoadLatestBtn as LoadLatestBtnMobile} from './LoadLatestBtnMobile'
import {isMobileWeb} from 'platform/detection'
-
-const HITSLOP = {left: 20, top: 20, right: 20, bottom: 20}
+import {HITSLOP_20} from 'lib/constants'
export const LoadLatestBtn = ({
onPress,
@@ -40,7 +39,7 @@ export const LoadLatestBtn = ({
minimalShellMode && styles.loadLatestCenteredMinimal,
]}
onPress={onPress}
- hitSlop={HITSLOP}
+ hitSlop={HITSLOP_20}
accessibilityRole="button"
accessibilityLabel={label}
accessibilityHint="">
@@ -52,7 +51,7 @@ export const LoadLatestBtn = ({
diff --git a/src/view/com/util/load-latest/LoadLatestBtnMobile.tsx b/src/view/com/util/load-latest/LoadLatestBtnMobile.tsx
index 412b9b80..eb7eaaa4 100644
--- a/src/view/com/util/load-latest/LoadLatestBtnMobile.tsx
+++ b/src/view/com/util/load-latest/LoadLatestBtnMobile.tsx
@@ -7,8 +7,7 @@ import {clamp} from 'lodash'
import {useStores} from 'state/index'
import {usePalette} from 'lib/hooks/usePalette'
import {colors} from 'lib/styles'
-
-const HITSLOP = {left: 20, top: 20, right: 20, bottom: 20}
+import {HITSLOP_20} from 'lib/constants'
export const LoadLatestBtn = observer(
({
@@ -35,7 +34,7 @@ export const LoadLatestBtn = observer(
},
]}
onPress={onPress}
- hitSlop={HITSLOP}
+ hitSlop={HITSLOP_20}
accessibilityRole="button"
accessibilityLabel={label}
accessibilityHint="">
diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx
index c544f640..672e0269 100644
--- a/src/view/com/util/post-ctrls/PostCtrls.tsx
+++ b/src/view/com/util/post-ctrls/PostCtrls.tsx
@@ -6,17 +6,13 @@ import {
View,
ViewStyle,
} from 'react-native'
-import {
- FontAwesomeIcon,
- FontAwesomeIconStyle,
-} from '@fortawesome/react-native-fontawesome'
// DISABLED see #135
// import {
// TriggerableAnimated,
// TriggerableAnimatedRef,
// } from './anim/TriggerableAnimated'
import {Text} from '../text/Text'
-import {PostDropdownBtn} from '../forms/DropdownButton'
+import {PostDropdownBtn} from '../forms/PostDropdownBtn'
import {HeartIcon, HeartIconSolid, CommentBottomArrow} from 'lib/icons'
import {s, colors} from 'lib/styles'
import {pluralize} from 'lib/strings/helpers'
@@ -24,6 +20,7 @@ import {useTheme} from 'lib/ThemeContext'
import {useStores} from 'state/index'
import {RepostButton} from './RepostButton'
import {Haptics} from 'lib/haptics'
+import {createHitslop} from 'lib/constants'
interface PostCtrlsOpts {
itemUri: string
@@ -56,7 +53,7 @@ interface PostCtrlsOpts {
onDeletePost: () => void
}
-const HITSLOP = {top: 5, left: 5, bottom: 5, right: 5}
+const HITSLOP = createHitslop(5)
// DISABLED see #135
/*
@@ -222,36 +219,21 @@ export function PostCtrls(opts: PostCtrlsOpts) {
) : undefined}
-
- {opts.big ? undefined : (
-
-
-
- )}
-
+ {opts.big ? undefined : (
+
+ )}
{/* used for adding pad to the right side */}
diff --git a/src/view/com/util/post-ctrls/RepostButton.tsx b/src/view/com/util/post-ctrls/RepostButton.tsx
index 4338e4c5..5fe62aef 100644
--- a/src/view/com/util/post-ctrls/RepostButton.tsx
+++ b/src/view/com/util/post-ctrls/RepostButton.tsx
@@ -6,8 +6,9 @@ import {useTheme} from 'lib/ThemeContext'
import {Text} from '../text/Text'
import {pluralize} from 'lib/strings/helpers'
import {useStores} from 'state/index'
+import {createHitslop} from 'lib/constants'
-const HITSLOP = {top: 5, left: 5, bottom: 5, right: 5}
+const HITSLOP = createHitslop(5)
interface Props {
isReposted: boolean
diff --git a/src/view/screens/CustomFeed.tsx b/src/view/screens/CustomFeed.tsx
index 61550c68..d5ecff04 100644
--- a/src/view/screens/CustomFeed.tsx
+++ b/src/view/screens/CustomFeed.tsx
@@ -29,10 +29,10 @@ import {Haptics} from 'lib/haptics'
import {ComposeIcon2} from 'lib/icons'
import {FAB} from '../com/util/fab/FAB'
import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
-import {DropdownButton, DropdownItem} from 'view/com/util/forms/DropdownButton'
import {useOnMainScroll} from 'lib/hooks/useOnMainScroll'
import {EmptyState} from 'view/com/util/EmptyState'
import {useAnalytics} from 'lib/analytics/analytics'
+import {NativeDropdown, DropdownItem} from 'view/com/util/forms/NativeDropdown'
import {makeProfileLink} from 'lib/routes/links'
type Props = NativeStackScreenProps
@@ -121,11 +121,25 @@ export const CustomFeedScreen = withAuthRequired(
testID: 'feedHeaderDropdownRemoveBtn',
label: 'Remove from my feeds',
onPress: onToggleSaved,
+ icon: {
+ ios: {
+ name: 'trash',
+ },
+ android: 'ic_delete',
+ web: 'trash',
+ },
},
{
testID: 'feedHeaderDropdownShareBtn',
label: 'Share link',
onPress: onPressShare,
+ icon: {
+ ios: {
+ name: 'square.and.arrow.up',
+ },
+ android: 'ic_menu_share',
+ web: 'share',
+ },
},
]
return items
@@ -163,17 +177,10 @@ export const CustomFeedScreen = withAuthRequired(
) : undefined}
{currentFeed?.isSaved ? (
-
-
-
+ />
) : (