diff --git a/src/components/Lightbox/chrome/CircleChromeButton.tsx b/src/components/Lightbox/chrome/CircleChromeButton.tsx index 9b31266bd35..64187af274c 100644 --- a/src/components/Lightbox/chrome/CircleChromeButton.tsx +++ b/src/components/Lightbox/chrome/CircleChromeButton.tsx @@ -27,9 +27,13 @@ type Props = { | 'accessibilityHint' > -const SIZE = 32 +/** + * Apple's minimum tap size. The image options button is a native menu trigger + * on iOS, where `hitSlop` doesn't apply, so the circle itself is the target. + */ +const SIZE = 44 const RADIUS = SIZE / 2 -const ICON = 18 +const ICON = 24 export function CircleChromeButton({ icon: Icon, diff --git a/src/components/Lightbox/chrome/ImageMenu.tsx b/src/components/Lightbox/chrome/ImageMenu.tsx index 0d34e8be341..27471a12701 100644 --- a/src/components/Lightbox/chrome/ImageMenu.tsx +++ b/src/components/Lightbox/chrome/ImageMenu.tsx @@ -1,28 +1,10 @@ -import {useEffect, useRef, useState} from 'react' -import { - Modal, - Pressable, - StyleSheet, - useWindowDimensions, - View, -} from 'react-native' -import Animated, { - interpolate, - useAnimatedStyle, - useSharedValue, - withSpring, - withTiming, -} from 'react-native-reanimated' -import {scheduleOnRN} from 'react-native-worklets' import {useLingui} from '@lingui/react/macro' -import {atoms as a} from '#/alf' +import {ThemeProvider} from '#/alf' import {ArrowShareRight_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowShareRight' -import {type Props as IconProps} from '#/components/icons/common' import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsIcon} from '#/components/icons/DotGrid' import {Download_Stroke2_Corner0_Rounded as DownloadIcon} from '#/components/icons/Download' -import {Text} from '#/components/Typography' -import {IS_IPAD} from '#/env' +import * as Menu from '#/components/Menu' import {CircleChromeButton} from './CircleChromeButton' type Props = { @@ -30,188 +12,34 @@ type Props = { onPressSave: () => void } -type Anchor = {x: number; y: number; width: number; height: number} - -const GAP = 6 -const CARD_BG = '#000000' -const CARD_BORDER = '#232e3e' -const ITEM_TEXT = '#f9fafb' - -const SPRING_IN = {damping: 18, mass: 0.6, stiffness: 240} -const TIMING_OUT = {duration: 150} - export function ImageMenu({onPressShare, onPressSave}: Props) { const {t: l} = useLingui() - const triggerRef = useRef>(null) - const [isMounted, setIsMounted] = useState(false) - const [anchor, setAnchor] = useState(null) - const {width: windowWidth, height: windowHeight} = useWindowDimensions() - const progress = useSharedValue(0) - - useEffect(() => { - if (!isMounted) return - triggerRef.current?.measureInWindow((x, y, width, height) => { - setAnchor({x, y, width, height}) - }) - }, [isMounted, windowWidth, windowHeight]) - - const open = () => { - triggerRef.current?.measureInWindow((x, y, width, height) => { - setAnchor({x, y, width, height}) - setIsMounted(true) - progress.set(withSpring(1, SPRING_IN)) - }) - } - - const close = () => { - progress.set( - withTiming(0, TIMING_OUT, finished => { - if (finished) { - scheduleOnRN(setIsMounted, false) - } - }), - ) - } - - const runAction = (action: () => void) => { - close() - action() - } return ( - <> - - - - - - {anchor && ( - - runAction(onPressShare)} + // The lightbox is always dark, so its menu is too. + + + + {({props}) => ( + - runAction(onPressSave)} - /> - - )} - - - ) -} - -function MenuCard({ - anchor, - progress, - children, -}: { - anchor: Anchor - progress: ReturnType> - children: React.ReactNode -}) { - const animatedStyle = useAnimatedStyle(() => ({ - opacity: progress.get(), - transform: [{scale: interpolate(progress.get(), [0, 1], [0.9, 1])}], - })) - - return ( - - {children} - + )} + + + + {l`Share image`} + + + + {l`Save image`} + + + + + ) } - -function MenuItem({ - icon: Icon, - label, - onPress, -}: { - icon: React.ComponentType - label: string - onPress: () => void -}) { - return ( - [styles.item, pressed && styles.itemPressed]}> - - {label} - - ) -} - -const styles = StyleSheet.create({ - card: { - padding: 8, - borderRadius: 16, - borderWidth: 0.5, - borderColor: CARD_BORDER, - backgroundColor: CARD_BG, - shadowColor: '#000', - shadowOffset: {width: 0, height: 4}, - shadowOpacity: 0.16, - shadowRadius: 20, - elevation: 8, - }, - item: { - height: 44, - paddingHorizontal: 12, - borderRadius: 8, - flexDirection: 'row', - alignItems: 'center', - gap: 6, - }, - itemPressed: { - backgroundColor: 'rgba(255, 255, 255, 0.08)', - }, - itemText: { - fontSize: 15, - fontWeight: '500', - lineHeight: 19.5, - color: ITEM_TEXT, - }, -}) diff --git a/src/components/PostControls/RepostButton.test.tsx b/src/components/PostControls/RepostButton.test.tsx index 346cebd995d..3466411b18a 100644 --- a/src/components/PostControls/RepostButton.test.tsx +++ b/src/components/PostControls/RepostButton.test.tsx @@ -11,11 +11,7 @@ jest.mock('#/components/PostControls/RepostButtonMenu', () => { const {View} = require('react-native') return { - RepostButtonMenu: ({longPressToQuote}: {longPressToQuote?: boolean}) => ( - - ), + RepostButtonMenu: () => , } }) jest.mock('#/components/Dialog', () => { diff --git a/src/components/PostControls/RepostButton.tsx b/src/components/PostControls/RepostButton.tsx index ad7f24c92cb..8b686d19ef5 100644 --- a/src/components/PostControls/RepostButton.tsx +++ b/src/components/PostControls/RepostButton.tsx @@ -22,11 +22,7 @@ import { import {RepostButtonMenu, type RepostButtonProps} from './RepostButtonMenu' let RepostButton = (props: RepostButtonProps): React.ReactNode => - IS_IPAD ? ( - - ) : ( - - ) + IS_IPAD ? : RepostButton = memo(RepostButton) export {RepostButton} diff --git a/src/components/PostControls/RepostButtonMenu.test.tsx b/src/components/PostControls/RepostButtonMenu.test.tsx index b6c066cc6e7..3872d55bc88 100644 --- a/src/components/PostControls/RepostButtonMenu.test.tsx +++ b/src/components/PostControls/RepostButtonMenu.test.tsx @@ -1,6 +1,5 @@ import {fireEvent, render, within} from '@testing-library/react-native' -import * as Menu from '#/components/Menu' import {RepostButtonMenu} from './RepostButtonMenu' jest.mock('#/state/session', () => ({ @@ -12,12 +11,8 @@ jest.mock('#/alf', () => ({ })) jest.mock('#/components/Menu', () => { const {Pressable, Text, View} = require('react-native') - const open = jest.fn() - const unanchoredOpen = jest.fn() return { - __testOpen: open, - __testUnanchoredOpen: unanchoredOpen, Root: ({children}: React.PropsWithChildren) => {children}, Trigger: ({ children, @@ -30,8 +25,8 @@ jest.mock('#/components/Menu', () => { label: string }) => children({ - control: {open: unanchoredOpen}, - props: {accessibilityLabel: label, onPress: open}, + control: {open: jest.fn()}, + props: {accessibilityLabel: label, onPress: jest.fn()}, }), Outer: ({children}: React.PropsWithChildren) => {children}, Group: ({children}: React.PropsWithChildren) => ( @@ -73,19 +68,16 @@ jest.mock('./PostControlButton', () => { PostControlButton: ({ children, label, - onLongPress, onPress, testID, }: React.PropsWithChildren<{ label?: string - onLongPress?: () => void onPress?: () => void testID?: string }>) => ( {children} @@ -110,11 +102,6 @@ jest.mock('@lingui/react', () => ({ }), })) -const mockMenuOpen = (Menu as unknown as {__testOpen: jest.Mock}).__testOpen -const mockUnanchoredOpen = ( - Menu as unknown as {__testUnanchoredOpen: jest.Mock} -).__testUnanchoredOpen - afterEach(() => { jest.clearAllMocks() }) @@ -158,39 +145,4 @@ describe('RepostButtonMenu', () => { fireEvent.press(quoteButton) expect(onQuote).not.toHaveBeenCalled() }) - - it('opens the menu on long press when quote is disabled', () => { - const screen = render( - , - ) - - fireEvent(screen.getByTestId('repostBtn'), 'longPress') - - expect(mockMenuOpen).toHaveBeenCalledTimes(1) - expect(mockUnanchoredOpen).not.toHaveBeenCalled() - }) - - it('keeps long press-to-quote when quote is enabled', () => { - const onQuote = jest.fn() - const screen = render( - , - ) - - fireEvent(screen.getByTestId('repostBtn'), 'longPress') - - expect(onQuote).toHaveBeenCalledTimes(1) - expect(mockMenuOpen).not.toHaveBeenCalled() - }) }) diff --git a/src/components/PostControls/RepostButtonMenu.tsx b/src/components/PostControls/RepostButtonMenu.tsx index 15413f9a85b..376a6a83323 100644 --- a/src/components/PostControls/RepostButtonMenu.tsx +++ b/src/components/PostControls/RepostButtonMenu.tsx @@ -20,7 +20,6 @@ export interface RepostButtonProps { onQuote: () => void big?: boolean embeddingDisabled: boolean - longPressToQuote?: boolean } export function RepostButtonMenu({ @@ -30,7 +29,6 @@ export function RepostButtonMenu({ onQuote, big, embeddingDisabled, - longPressToQuote = false, }: RepostButtonProps): React.ReactNode { const t = useTheme() const {t: l} = useLingui() @@ -40,7 +38,7 @@ export function RepostButtonMenu({ return hasSession ? ( - + {({props}) => ( - requireAuth(() => { - if (embeddingDisabled) { - props.onPress() - } else { - onQuote() - } - }) - : undefined - }> + {...props}> {typeof repostCount !== 'undefined' && repostCount > 0 && ( diff --git a/src/components/dms/ConvoMenu.tsx b/src/components/dms/ConvoMenu.tsx index e3414fd070e..e7b93bedfe0 100644 --- a/src/components/dms/ConvoMenu.tsx +++ b/src/components/dms/ConvoMenu.tsx @@ -43,6 +43,7 @@ import * as Menu from '#/components/Menu' import {ReportDialog} from '#/components/moderation/ReportDialog' import * as Prompt from '#/components/Prompt' import * as Toast from '#/components/Toast' +import {IS_NATIVE} from '#/env' import type * as bsky from '#/types/bsky' let ConvoMenu = ({ @@ -80,45 +81,59 @@ let ConvoMenu = ({ const reportSubject = getConvoReportSubject(convo, currentAccount?.did) + const trigger = hideTrigger ? null : ( + + {({props}) => ( + + + + )} + + ) + const contentProps = { + profile, + showMarkAsRead, + blockInfo, + convo, + canReport: !!reportSubject, + leaveConvoControl, + reportControl, + blockedByListControl, + } + return ( <> - - {!hideTrigger && ( - - - {({props}) => ( - - )} - - - )} - - - - - + {/* + * Native menus can only open from their trigger, so the chat list's + * long-press menu, which has none, stays a sheet. + */} + {IS_NATIVE && trigger ? ( + + ) : ( + + {trigger} + + + + + )} canReport: boolean @@ -183,7 +189,48 @@ function MenuContent({ leaveConvoControl: Prompt.PromptControlProps reportControl: Prompt.PromptControlProps blockedByListControl: Prompt.PromptControlProps +} + +/** + * An inline menu needs its groups as direct children of `Menu.Outer`, so its + * items' hooks run as soon as it mounts. That's fine for the one in the + * conversation header. + */ +function InlineConvoMenu({ + control, + trigger, + ...props +}: MenuContentProps & { + control?: Menu.MenuControlProps + trigger: React.ReactElement }) { + const items = useConvoMenuItems(props) + return ( + + {trigger} + {items} + + ) +} + +/** + * Sheet menus only mount their content while open, so a chat list row doesn't + * fetch its convo until its menu opens. + */ +function MenuContent(props: MenuContentProps) { + return useConvoMenuItems(props) +} + +function useConvoMenuItems({ + convo: initialConvo, + profile, + canReport, + showMarkAsRead, + blockInfo, + leaveConvoControl, + reportControl, + blockedByListControl, +}: MenuContentProps) { const navigation = useNavigation() const {t: l} = useLingui() const {mutate: markAsRead} = useMarkAsReadMutation() diff --git a/src/screens/Messages/ConversationSettings/MemberMenu.tsx b/src/screens/Messages/ConversationSettings/MemberMenu.tsx index f128951f8fe..2ba3d851844 100644 --- a/src/screens/Messages/ConversationSettings/MemberMenu.tsx +++ b/src/screens/Messages/ConversationSettings/MemberMenu.tsx @@ -54,9 +54,10 @@ export function MemberMenu({ const removeMemberPrompt = Prompt.usePromptControl() const [menuDidOpen, setMenuDidOpen] = useState(false) - const {data: convoAvailability} = useGetConvoAvailabilityQuery(profile.did, { - enabled: menuDidOpen, - }) + const {data: prefetchedAvailability, refetch: fetchAvailability} = + useGetConvoAvailabilityQuery(profile.did, { + enabled: menuDidOpen, + }) const {mutate: initiateConvo} = useGetConvoForMembers({ onSuccess: ({convo}) => { ax.metric('chat:open', {logContext: 'ConvoSettings'}) @@ -78,7 +79,7 @@ export function MemberMenu({ }) const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) - const messageMember = () => { + const openChat = (convoAvailability: typeof prefetchedAvailability) => { if (!convoAvailability?.canChat) { return } @@ -94,6 +95,19 @@ export function MemberMenu({ } } + const messageMember = () => { + if (prefetchedAvailability) { + openChat(prefetchedAvailability) + } else { + /* + * The trigger prefetches this, but on iOS its onPress only runs when + * the menu first opens, and the badge trigger's never does, since its + * onPress is nested in `pressableProps`. + */ + void fetchAvailability().then(({data}) => openChat(data)) + } + } + const handleMessageMember = requireEmailVerification(messageMember, { instructions: [ @@ -138,7 +152,7 @@ export function MemberMenu({ return ( <> - + {({props, state, control: menuControl}) => { const isActive = diff --git a/src/screens/ProfileList/components/MoreOptionsMenu.tsx b/src/screens/ProfileList/components/MoreOptionsMenu.tsx index bb8d426cd12..45a5cc6b84b 100644 --- a/src/screens/ProfileList/components/MoreOptionsMenu.tsx +++ b/src/screens/ProfileList/components/MoreOptionsMenu.tsx @@ -167,7 +167,7 @@ export function MoreOptionsMenu({ return ( <> - + {({props}) => (