[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:
parent
eec300d772
commit
3b8b562268
30 changed files with 1093 additions and 342 deletions
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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}}>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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="">
|
||||
|
|
250
src/view/com/util/forms/NativeDropdown.tsx
Normal file
250
src/view/com/util/forms/NativeDropdown.tsx
Normal 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,
|
||||
},
|
||||
})
|
148
src/view/com/util/forms/PostDropdownBtn.tsx
Normal file
148
src/view/com/util/forms/PostDropdownBtn.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -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="">
|
||||
|
|
|
@ -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="">
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue