[APP-737] Accessible native dropdown menu (#988)

* fix comments

* add zeego package

* get basic native dropdown working

* add separator and icon components

* refined native dropdown component

* add android build properties to app.json

* move `PostDropdownBtn` to its own component

* fix selectors issue

* move `PostDropdownBtn` to its own component

* fix hitslop

* fix post dropdown hitslop

* fix android dropdown icons

* move `UserAvatar.tsx` to native dropdown

* use native dropdown in `ProfileHeader.tsx`

* use native dropdown in `PostThreadItem.tsx`

* use native dropdown in `UserBanner.tsx`

* use native dropdown in `CustomFeed.tsx`

* replace `testId` with `testID` (which is what is used everywhere)

* move `Settings.tsx` to use native dropdown

* create jest mocks for zeego

* create jest mock for `zeego/dropdown-menu`

* web styles for native dropdown

* remove example native dropdown

* adjust web styles

* fix propagation

* fix pressable in `Settings.tsx`

* animate dropdown on web

* add keyboard nav and hover styles

* add hitslop to constants

* add comments to NativeDropdown component

* temporarily removed android icons

* add testID to PostDropdownBtn

* add testID back to all NativeDropdown button implementations

* add postDropdownBtn testID

* add testID to dropdown items

* remove testID from dropdown menu item

* refactor home-screen tests for native dropdown

* refactor profile-screen tests for native dropdown

* refactor thread-muting tests for native dropdown

* refactor thread-screen tests for native dropdown

* fix dropdown color for post dropdown button

* remove icons from android dropdown menu

* fix `create-account.test.ts`

* fix `invite-codes.test.ts`
This commit is contained in:
Ansh 2023-07-28 14:00:37 -07:00 committed by GitHub
parent eec300d772
commit 3b8b562268
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 1093 additions and 342 deletions

View file

@ -10,7 +10,7 @@ export const Welcome = ({next}: {next: () => void}) => {
const pal = usePalette('default')
return (
<View style={[styles.container]}>
<View>
<View testID="welcomeScreen">
<Text style={[pal.text, styles.title]}>Welcome to </Text>
<Text style={[pal.text, pal.link, styles.title]}>Bluesky</Text>
@ -52,7 +52,12 @@ export const Welcome = ({next}: {next: () => void}) => {
</View>
</View>
<Button onPress={next} label="Continue" labelStyle={styles.buttonText} />
<Button
onPress={next}
label="Continue"
testID="continueBtn"
labelStyle={styles.buttonText}
/>
</View>
)
}

View file

@ -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">

View file

@ -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">

View file

@ -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) => (
<SafeAreaView style={styles.root}>

View file

@ -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}>
<FontAwesomeIcon
icon="bars"
size={18}
@ -68,7 +69,7 @@ export const FeedsTabBar = observer(
<View style={[pal.view]}>
<Link
href="/settings/saved-feeds"
hitSlop={10}
hitSlop={HITSLOP_10}
accessibilityRole="button"
accessibilityLabel="Edit Saved Feeds"
accessibilityHint="Opens screen to edit Saved Feeds">

View file

@ -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({
<View style={s.flex1} />
<PostDropdownBtn
testID="postDropdownBtn"
style={[styles.metaItem, s.mt2, s.px5]}
itemUri={itemUri}
itemCid={itemCid}
itemHref={itemHref}
@ -212,13 +211,8 @@ export const PostThreadItem = observer(function PostThreadItem({
onCopyPostText={onCopyPostText}
onOpenTranslate={onOpenTranslate}
onToggleThreadMute={onToggleThreadMute}
onDeletePost={onDeletePost}>
<FontAwesomeIcon
icon="ellipsis-h"
size={14}
style={[pal.textLight]}
/>
</PostDropdownBtn>
onDeletePost={onDeletePost}
/>
</View>
<View style={styles.meta}>
<Link

View file

@ -17,7 +17,6 @@ import {toShareUrl} from 'lib/strings/url-helpers'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {sanitizeHandle} from 'lib/strings/handles'
import {s, colors} from 'lib/styles'
import {DropdownButton, DropdownItem} from '../util/forms/DropdownButton'
import * as Toast from '../util/Toast'
import {LoadingPlaceholder} from '../util/LoadingPlaceholder'
import {Text} from '../util/text/Text'
@ -36,11 +35,11 @@ import {FollowState} from 'state/models/cache/my-follows'
import {shareUrl} from 'lib/sharing'
import {formatCount} from '../util/numeric/format'
import {navigate} from '../../../Navigation'
import {NativeDropdown, DropdownItem} from '../util/forms/NativeDropdown'
import {BACK_HITSLOP} from 'lib/constants'
import {isInvalidHandle} from 'lib/strings/handles'
import {makeProfileLink} from 'lib/routes/links'
const BACK_HITSLOP = {left: 30, top: 30, right: 30, bottom: 30}
interface Props {
view: ProfileModel
onRefreshAll: () => 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 ? (
<DropdownButton
<NativeDropdown
testID="profileHeaderDropdownBtn"
type="bare"
items={dropdownItems}
style={[styles.btn, styles.secondaryBtn, pal.btn]}>
<FontAwesomeIcon icon="ellipsis" style={[pal.text]} />
</DropdownButton>
items={dropdownItems}>
<View style={[styles.btn, styles.secondaryBtn, pal.btn]}>
<FontAwesomeIcon
icon="ellipsis"
size={20}
style={[pal.text]}
/>
</View>
</NativeDropdown>
) : undefined}
</View>
<View>

View file

@ -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({
<TouchableOpacity
testID="viewHeaderBackOrMenuBtn"
onPress={onPressMenu}
hitSlop={MENU_HITSLOP}
hitSlop={HITSLOP_10}
style={styles.headerMenuBtn}
accessibilityRole="button"
accessibilityLabel="Menu"

View file

@ -2,7 +2,6 @@ import React, {useMemo} from 'react'
import {StyleSheet, View} from 'react-native'
import Svg, {Circle, Rect, Path} from 'react-native-svg'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {IconProp} from '@fortawesome/fontawesome-svg-core'
import {HighPriorityImage} from 'view/com/util/images/Image'
import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
import {
@ -11,12 +10,12 @@ import {
} from 'lib/hooks/usePermissions'
import {useStores} from 'state/index'
import {colors} from 'lib/styles'
import {DropdownButton} from './forms/DropdownButton'
import {usePalette} from 'lib/hooks/usePalette'
import {isWeb, isAndroid} from 'platform/detection'
import {Image as RNImage} from 'react-native-image-crop-picker'
import {AvatarModeration} from 'lib/labeling/types'
import {UserPreviewLink} from './UserPreviewLink'
import {DropdownItem, NativeDropdown} from './forms/NativeDropdown'
type Type = 'user' | 'algo' | 'list'
@ -130,59 +129,81 @@ export function UserAvatar({
}, [type, size])
const dropdownItems = useMemo(
() => [
!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 ? (
<DropdownButton
testID="changeAvatarBtn"
type="bare"
items={dropdownItems}
openToRight
rightOffset={-10}
bottomOffset={-10}
menuWidth={170}>
<NativeDropdown testID="changeAvatarBtn" items={dropdownItems}>
{avatar ? (
<HighPriorityImage
testID="userAvatarImage"
@ -234,7 +248,7 @@ export function UserAvatar({
color={pal.text.color as string}
/>
</View>
</DropdownButton>
</NativeDropdown>
) : avatar &&
!((moderation?.blur && isAndroid) /* android crashes with blur */) ? (
<View style={{width: size, height: size}}>

View file

@ -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 ? (
<DropdownButton
testID="changeBannerBtn"
type="bare"
items={dropdownItems}
openToRight
rightOffset={-200}
bottomOffset={-10}
menuWidth={170}>
<NativeDropdown testID="changeBannerBtn" items={dropdownItems}>
{banner ? (
<Image
testID="userBannerImage"
@ -109,7 +129,7 @@ export function UserBanner({
color={pal.text.color as string}
/>
</View>
</DropdownButton>
</NativeDropdown>
) : banner &&
!((moderation?.blur && isAndroid) /* android crashes with blur */) ? (
<Image

View file

@ -14,14 +14,10 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Text} from '../text/Text'
import {Button, ButtonType} from './Button'
import {colors} from 'lib/styles'
import {toShareUrl} from 'lib/strings/url-helpers'
import {useStores} from 'state/index'
import {usePalette} from 'lib/hooks/usePalette'
import {useTheme} from 'lib/ThemeContext'
import {isWeb} from 'platform/detection'
import {shareUrl} from 'lib/sharing'
import {HITSLOP_10} from 'lib/constants'
const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10}
const ESTIMATED_BTN_HEIGHT = 50
const ESTIMATED_SEP_HEIGHT = 16
const ESTIMATED_HEADING_HEIGHT = 60
@ -140,7 +136,7 @@ export function DropdownButton({
testID={testID}
style={style}
onPress={onPress}
hitSlop={HITSLOP}
hitSlop={HITSLOP_10}
ref={ref1}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel || `Opens ${numItems} options`}
@ -163,112 +159,6 @@ export function DropdownButton({
)
}
export function PostDropdownBtn({
testID,
style,
children,
itemUri,
itemCid,
itemHref,
isAuthor,
isThreadMuted,
onCopyPostText,
onOpenTranslate,
onToggleThreadMute,
onDeletePost,
}: {
testID?: string
style?: StyleProp<ViewStyle>
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 (
<DropdownButton
testID={testID}
style={style}
items={dropdownItems}
menuWidth={isWeb ? 220 : 200}
accessibilityLabel="Additional post actions"
accessibilityHint="">
{children}
</DropdownButton>
)
}
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 <select />
// and <option /> 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 */}
<TouchableWithoutFeedback
onPress={onOuterPress}
// TODO: Refactor dropdown components to:
// - (On web, if not handled by React Native) use semantic <select />
// and <option /> 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="">

View file

@ -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 (
<DropdownMenu.Item
{...props}
style={[styles.item, focused && {backgroundColor: backgroundColor}]}
onFocus={() => {
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 (
<DropdownMenu.ItemTitle
{...props}
style={[props.style, pal.text, styles.itemTitle]}
/>
)
},
'ItemTitle',
)
type IconProps = React.ComponentProps<(typeof DropdownMenu)['ItemIcon']>
export const DropdownMenuItemIcon = DropdownMenu.create((props: IconProps) => {
return <DropdownMenu.ItemIcon {...props} />
}, '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 (
<DropdownMenu.Separator
{...props}
style={[
props.style,
styles.separator,
{backgroundColor: separatorColor},
]}
/>
)
},
'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<ViewStyle>
return (
<DropdownMenuRoot>
<DropdownMenuTrigger action="press">
<Pressable
testID={testID}
accessibilityRole="button"
style={({pressed}) => [{opacity: pressed ? 0.5 : 1}]}
hitSlop={HITSLOP_10}>
{children ? (
children
) : (
<FontAwesomeIcon
icon="ellipsis"
size={20}
style={[defaultCtrlColor, styles.ellipsis]}
/>
)}
</Pressable>
</DropdownMenuTrigger>
<DropdownMenuContent
style={[styles.content, dropDownBackgroundColor]}
loop>
{items.map((item, index) => {
if (item.label === 'separator') {
return (
<DropdownMenuSeparator
key={getKey(item.label, index, item.testID)}
/>
)
}
if (index > 1 && items[index - 1].label === 'separator') {
return (
<DropdownMenu.Group key={getKey(item.label, index, item.testID)}>
<DropdownMenuItem
key={getKey(item.label, index, item.testID)}
onSelect={item.onPress}>
<DropdownMenuItemTitle>{item.label}</DropdownMenuItemTitle>
{item.icon && (
<DropdownMenuItemIcon
ios={item.icon.ios}
// androidIconName={item.icon.android} TODO: Add custom android icon support, because these ones are based on https://developer.android.com/reference/android/R.drawable.html and they are ugly
>
<FontAwesomeIcon
icon={item.icon.web}
size={20}
style={[pal.text]}
/>
</DropdownMenuItemIcon>
)}
</DropdownMenuItem>
</DropdownMenu.Group>
)
}
return (
<DropdownMenuItem
key={getKey(item.label, index, item.testID)}
onSelect={item.onPress}>
<DropdownMenuItemTitle>{item.label}</DropdownMenuItemTitle>
{item.icon && (
<DropdownMenuItemIcon
ios={item.icon.ios}
// androidIconName={item.icon.android}
>
<FontAwesomeIcon
icon={item.icon.web}
size={20}
style={[pal.text]}
/>
</DropdownMenuItemIcon>
)}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenuRoot>
)
}
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,
},
})

View file

@ -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 (
<Pressable testID={testID} accessibilityRole="button">
<NativeDropdown items={dropdownItems} />
</Pressable>
)
}

View file

@ -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 = ({
<TouchableOpacity
style={[pal.view, pal.borderDark, styles.loadLatest]}
onPress={onPress}
hitSlop={HITSLOP}
hitSlop={HITSLOP_20}
accessibilityRole="button"
accessibilityLabel={label}
accessibilityHint="">

View file

@ -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="">

View file

@ -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) {
</Text>
) : undefined}
</TouchableOpacity>
<View>
{opts.big ? undefined : (
<PostDropdownBtn
testID="postDropdownBtn"
style={styles.ctrl}
itemUri={opts.itemUri}
itemCid={opts.itemCid}
itemHref={opts.itemHref}
itemTitle={opts.itemTitle}
isAuthor={opts.isAuthor}
isThreadMuted={opts.isThreadMuted}
onCopyPostText={opts.onCopyPostText}
onOpenTranslate={opts.onOpenTranslate}
onToggleThreadMute={opts.onToggleThreadMute}
onDeletePost={opts.onDeletePost}>
<FontAwesomeIcon
icon="ellipsis-h"
size={18}
style={[
s.mt2,
s.mr5,
{
color:
theme.colorScheme === 'light' ? colors.gray4 : colors.gray5,
} as FontAwesomeIconStyle,
]}
/>
</PostDropdownBtn>
)}
</View>
{opts.big ? undefined : (
<PostDropdownBtn
testID="postDropdownBtn"
itemUri={opts.itemUri}
itemCid={opts.itemCid}
itemHref={opts.itemHref}
itemTitle={opts.itemTitle}
isAuthor={opts.isAuthor}
isThreadMuted={opts.isThreadMuted}
onCopyPostText={opts.onCopyPostText}
onOpenTranslate={opts.onOpenTranslate}
onToggleThreadMute={opts.onToggleThreadMute}
onDeletePost={opts.onDeletePost}
/>
)}
{/* used for adding pad to the right side */}
<View />
</View>

View file

@ -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

View file

@ -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<CommonNavigatorParams, 'CustomFeed'>
@ -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(
</Button>
) : undefined}
{currentFeed?.isSaved ? (
<DropdownButton
<NativeDropdown
testID="feedHeaderDropdownBtn"
type="default-light"
items={dropdownItems}
menuWidth={250}>
<FontAwesomeIcon
icon="ellipsis"
color={pal.colors.textLight}
size={18}
/>
</DropdownButton>
/>
) : (
<Button
type="default-light"

View file

@ -3,6 +3,7 @@ import {
ActivityIndicator,
Linking,
Platform,
Pressable,
StyleSheet,
TextStyle,
TouchableOpacity,
@ -30,7 +31,6 @@ import {Link} from '../com/util/Link'
import {Text} from '../com/util/text/Text'
import * as Toast from '../com/util/Toast'
import {UserAvatar} from '../com/util/UserAvatar'
import {DropdownButton} from 'view/com/util/forms/DropdownButton'
import {ToggleButton} from 'view/com/util/forms/ToggleButton'
import {SelectableBtn} from 'view/com/util/forms/SelectableBtn'
import {usePalette} from 'lib/hooks/usePalette'
@ -50,6 +50,7 @@ import {makeProfileLink} from 'lib/routes/links'
// -prf
import {useDebugHeaderSetting} from 'lib/api/debug-appview-proxy-header'
import {STATUS_PAGE_URL} from 'lib/constants'
import {DropdownItem, NativeDropdown} from 'view/com/util/forms/NativeDropdown'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'>
export const SettingsScreen = withAuthRequired(
@ -565,24 +566,31 @@ export const SettingsScreen = withAuthRequired(
function AccountDropdownBtn({handle}: {handle: string}) {
const store = useStores()
const pal = usePalette('default')
const items = [
const items: DropdownItem[] = [
{
label: 'Remove account',
onPress: () => {
store.session.removeAccount(handle)
Toast.show('Account removed from quick access')
},
icon: {
ios: {
name: 'trash',
},
android: 'ic_delete',
web: 'trash',
},
},
]
return (
<View style={s.pl10}>
<DropdownButton type="bare" items={items}>
<Pressable accessibilityRole="button" style={s.pl10}>
<NativeDropdown testID="accountSettingsDropdownBtn" items={items}>
<FontAwesomeIcon
icon="ellipsis-h"
style={pal.textLight as FontAwesomeIconStyle}
/>
</DropdownButton>
</View>
</NativeDropdown>
</Pressable>
)
}