Repository navigation
Use inline menus for list, chat, avatar and account menus #12296
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7bb18e6
502135a
fc474d6
b00ce70
83c7292
3efa076
187e04c
f8c7830
c0f24d2
34b7d5f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,217 +1,45 @@ | ||
| 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 = { | ||
| onPressShare: () => void | ||
| 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<React.ComponentRef<typeof View>>(null) | ||
| const [isMounted, setIsMounted] = useState(false) | ||
| const [anchor, setAnchor] = useState<Anchor | null>(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 ( | ||
| <> | ||
| <View ref={triggerRef} collapsable={false}> | ||
| <CircleChromeButton | ||
| icon={DotsIcon} | ||
| iconStyle={{transform: [{rotate: '90deg'}]}} | ||
| label={l`Image options`} | ||
| onPress={open} | ||
| /> | ||
| </View> | ||
| <Modal | ||
| transparent | ||
| visible={isMounted} | ||
| animationType="none" | ||
| onRequestClose={close} | ||
| supportedOrientations={ | ||
| IS_IPAD | ||
| ? [ | ||
| 'portrait', | ||
| 'portrait-upside-down', | ||
| 'landscape-left', | ||
| 'landscape-right', | ||
| ] | ||
| : ['portrait', 'landscape'] | ||
| } | ||
| statusBarTranslucent> | ||
| <Pressable | ||
| accessibilityRole="button" | ||
| accessibilityLabel={l`Close menu`} | ||
| accessibilityHint="" | ||
| style={[a.absolute, a.inset_0]} | ||
| onPress={close} | ||
| /> | ||
| {anchor && ( | ||
| <MenuCard anchor={anchor} progress={progress}> | ||
| <MenuItem | ||
| icon={ShareIcon} | ||
| label={l`Share image`} | ||
| onPress={() => runAction(onPressShare)} | ||
| // The lightbox is always dark, so its menu is too. | ||
| <ThemeProvider theme="dark"> | ||
| <Menu.Root inline> | ||
| <Menu.Trigger label={l`Image options`}> | ||
| {({props}) => ( | ||
| <CircleChromeButton | ||
| icon={DotsIcon} | ||
| iconStyle={{transform: [{rotate: '90deg'}]}} | ||
| label={props.accessibilityLabel} | ||
| onPress={props.onPress} | ||
| /> | ||
| <MenuItem | ||
| icon={DownloadIcon} | ||
| label={l`Save image`} | ||
| onPress={() => runAction(onPressSave)} | ||
| /> | ||
| </MenuCard> | ||
| )} | ||
| </Modal> | ||
| </> | ||
| ) | ||
| } | ||
|
|
||
| function MenuCard({ | ||
| anchor, | ||
| progress, | ||
| children, | ||
| }: { | ||
| anchor: Anchor | ||
| progress: ReturnType<typeof useSharedValue<number>> | ||
| children: React.ReactNode | ||
| }) { | ||
| const animatedStyle = useAnimatedStyle(() => ({ | ||
| opacity: progress.get(), | ||
| transform: [{scale: interpolate(progress.get(), [0, 1], [0.9, 1])}], | ||
| })) | ||
|
|
||
| return ( | ||
| <Animated.View | ||
| style={[ | ||
| a.absolute, | ||
| a.self_start, | ||
| styles.card, | ||
| { | ||
| top: anchor.y + anchor.height + GAP, | ||
| left: anchor.x, | ||
| transformOrigin: 'top left', | ||
| }, | ||
| animatedStyle, | ||
| ]}> | ||
| {children} | ||
| </Animated.View> | ||
| )} | ||
| </Menu.Trigger> | ||
| <Menu.Outer> | ||
| <Menu.Item label={l`Share image`} onPress={onPressShare}> | ||
| <Menu.ItemText>{l`Share image`}</Menu.ItemText> | ||
| <Menu.ItemIcon icon={ShareIcon} /> | ||
| </Menu.Item> | ||
| <Menu.Item label={l`Save image`} onPress={onPressSave}> | ||
| <Menu.ItemText>{l`Save image`}</Menu.ItemText> | ||
| <Menu.ItemIcon icon={DownloadIcon} /> | ||
| </Menu.Item> | ||
| </Menu.Outer> | ||
| </Menu.Root> | ||
| </ThemeProvider> | ||
| ) | ||
| } | ||
|
|
||
| function MenuItem({ | ||
| icon: Icon, | ||
| label, | ||
| onPress, | ||
| }: { | ||
| icon: React.ComponentType<IconProps> | ||
| label: string | ||
| onPress: () => void | ||
| }) { | ||
| return ( | ||
| <Pressable | ||
| accessibilityRole="button" | ||
| accessibilityLabel={label} | ||
| accessibilityHint="" | ||
| onPress={onPress} | ||
| style={({pressed}) => [styles.item, pressed && styles.itemPressed]}> | ||
| <Icon width={18} fill={ITEM_TEXT} /> | ||
| <Text style={styles.itemText}>{label}</Text> | ||
| </Pressable> | ||
| ) | ||
| } | ||
|
|
||
| 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, | ||
| }, | ||
| }) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -22,11 +22,7 @@ import { | |
| import {RepostButtonMenu, type RepostButtonProps} from './RepostButtonMenu' | ||
|
|
||
| let RepostButton = (props: RepostButtonProps): React.ReactNode => | ||
| IS_IPAD ? ( | ||
| <RepostButtonMenu {...props} longPressToQuote /> | ||
| ) : ( | ||
| <RepostButtonDialog {...props} /> | ||
| ) | ||
| IS_IPAD ? <RepostButtonMenu {...props} /> : <RepostButtonDialog {...props} /> | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. non-blocking: Long press on the iPad repost button does nothing when the gate is off Removing Possible fix: keep the long-press handler for the non-inline case, for example by reading |
||
|
|
||
| RepostButton = memo(RepostButton) | ||
| export {RepostButton} | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
non-blocking: With the gate off, the image menu becomes a light sheet with dark-theme items
Menu.Root inlineonly renders a native menu whenNativeMenusEnableis on (src/components/Menu/index.tsx:78-79). Otherwise it falls back toDialog.Outer. That gate is off by default, so most users will see the fallback. The nativeDialog.Outertakes the sheet'sbackgroundColorandcolorSchemefromuseTheme(useThemeName())(src/components/Dialog/index.tsx:90-91, 301-302), which is the app's colour mode. It ignores thisThemeProvider. The items inside use plainuseTheme(), so they do get the dark theme.How to reproduce: use the app in light mode with the gate off, open an image in the lightbox, and tap
…. You get a white sheet, a light-mode handle, and darkbg_contrast_25item cards with light text. The old hand-rolled card was dark in every colour mode.Possible fixes: apply the
ThemeProvideronly when the menu really is inline, or keep the old dark card for the fallback until the gate rolls out.