Bluesky app fork with some witchin' additions 馃挮
at main 363 lines 8.4 kB view raw
1import {cloneElement, Fragment, isValidElement, useMemo} from 'react' 2import { 3 Pressable, 4 type StyleProp, 5 type TextStyle, 6 View, 7 type ViewStyle, 8} from 'react-native' 9import {msg} from '@lingui/core/macro' 10import {useLingui} from '@lingui/react' 11import {Trans} from '@lingui/react/macro' 12import flattenReactChildren from 'react-keyed-flatten-children' 13 14import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' 15import {atoms as a, useTheme} from '#/alf' 16import {Button, ButtonText} from '#/components/Button' 17import * as Dialog from '#/components/Dialog' 18import {useInteractionState} from '#/components/hooks/useInteractionState' 19import { 20 Context, 21 ItemContext, 22 useMenuContext, 23 useMenuItemContext, 24} from '#/components/Menu/context' 25import { 26 type ContextType, 27 type GroupProps, 28 type ItemIconProps, 29 type ItemProps, 30 type ItemTextProps, 31 type TriggerProps, 32} from '#/components/Menu/types' 33import {Text} from '#/components/Typography' 34import {IS_ANDROID, IS_IOS, IS_NATIVE} from '#/env' 35 36export { 37 type DialogControlProps as MenuControlProps, 38 useDialogControl as useMenuControl, 39} from '#/components/Dialog' 40 41export {useMenuContext} 42 43export function Root({ 44 children, 45 control, 46}: React.PropsWithChildren<{ 47 control?: Dialog.DialogControlProps 48}>) { 49 const defaultControl = Dialog.useDialogControl() 50 const context = useMemo<ContextType>( 51 () => ({ 52 control: control || defaultControl, 53 }), 54 [control, defaultControl], 55 ) 56 57 return <Context.Provider value={context}>{children}</Context.Provider> 58} 59 60export function Trigger({ 61 children, 62 label, 63 role = 'button', 64 hint, 65}: TriggerProps) { 66 const context = useMenuContext() 67 const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() 68 const { 69 state: pressed, 70 onIn: onPressIn, 71 onOut: onPressOut, 72 } = useInteractionState() 73 74 return children({ 75 IS_NATIVE: true, 76 control: context.control, 77 state: { 78 hovered: false, 79 focused, 80 pressed, 81 }, 82 props: { 83 ref: null, 84 onPress: context.control.open, 85 onFocus, 86 onBlur, 87 onPressIn, 88 onPressOut, 89 accessibilityHint: hint, 90 accessibilityLabel: label, 91 accessibilityRole: role, 92 }, 93 }) 94} 95 96export function Outer({ 97 children, 98 showCancel, 99}: React.PropsWithChildren<{ 100 showCancel?: boolean 101 style?: StyleProp<ViewStyle> 102}>) { 103 const context = useMenuContext() 104 const {_} = useLingui() 105 106 return ( 107 <Dialog.Outer 108 control={context.control} 109 nativeOptions={{preventExpansion: true}}> 110 <Dialog.Handle /> 111 {/* Re-wrap with context since Dialogs are portal-ed to root */} 112 <Context.Provider value={context}> 113 <Dialog.ScrollableInner label={_(msg`Menu`)}> 114 <View style={[a.gap_lg]}> 115 {children} 116 {IS_NATIVE && showCancel && <Cancel />} 117 </View> 118 </Dialog.ScrollableInner> 119 </Context.Provider> 120 </Dialog.Outer> 121 ) 122} 123 124export function Item({children, label, style, onPress, ...rest}: ItemProps) { 125 const t = useTheme() 126 const context = useMenuContext() 127 const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() 128 const { 129 state: pressed, 130 onIn: onPressIn, 131 onOut: onPressOut, 132 } = useInteractionState() 133 134 return ( 135 <Pressable 136 {...rest} 137 accessibilityHint="" 138 accessibilityLabel={label} 139 onFocus={onFocus} 140 onBlur={onBlur} 141 onPress={async e => { 142 if (IS_ANDROID) { 143 /** 144 * Below fix for iOS doesn't work for Android, this does. 145 */ 146 onPress?.(e) 147 context.control.close() 148 } else if (IS_IOS) { 149 /** 150 * Fixes a subtle bug on iOS 151 * {@link https://github.com/bluesky-social/social-app/pull/5849/files#diff-de516ef5e7bd9840cd639213301df38cf03acfcad5bda85a1d63efd249ba79deL124-L127} 152 */ 153 context.control.close(() => { 154 onPress?.(e) 155 }) 156 } 157 }} 158 onPressIn={e => { 159 onPressIn() 160 rest.onPressIn?.(e) 161 }} 162 onPressOut={e => { 163 onPressOut() 164 rest.onPressOut?.(e) 165 }} 166 style={[ 167 a.flex_row, 168 a.align_center, 169 a.gap_sm, 170 a.px_md, 171 a.rounded_md, 172 a.overflow_hidden, 173 a.border, 174 t.atoms.bg_contrast_25, 175 t.atoms.border_contrast_low, 176 {minHeight: 44, paddingVertical: 10}, 177 style, 178 (focused || pressed) && !rest.disabled && [t.atoms.bg_contrast_50], 179 ]}> 180 <ItemContext.Provider value={{disabled: Boolean(rest.disabled)}}> 181 {children} 182 </ItemContext.Provider> 183 </Pressable> 184 ) 185} 186 187export function ItemText({children, style}: ItemTextProps) { 188 const t = useTheme() 189 const {disabled} = useMenuItemContext() 190 return ( 191 <Text 192 numberOfLines={1} 193 ellipsizeMode="middle" 194 style={[ 195 a.flex_1, 196 a.text_md, 197 a.font_semi_bold, 198 t.atoms.text_contrast_high, 199 style, 200 disabled && t.atoms.text_contrast_low, 201 ]}> 202 {children} 203 </Text> 204 ) 205} 206 207export function ItemIcon({icon: Comp, fill}: ItemIconProps) { 208 const t = useTheme() 209 const {disabled} = useMenuItemContext() 210 return ( 211 <Comp 212 size="lg" 213 fill={ 214 fill 215 ? fill({disabled}) 216 : disabled 217 ? t.atoms.text_contrast_low.color 218 : t.atoms.text_contrast_medium.color 219 } 220 /> 221 ) 222} 223 224export function ItemRadio({selected}: {selected: boolean}) { 225 const t = useTheme() 226 const enableSquareButtons = useEnableSquareButtons() 227 return ( 228 <View 229 style={[ 230 a.justify_center, 231 a.align_center, 232 enableSquareButtons ? a.rounded_sm : a.rounded_full, 233 t.atoms.border_contrast_high, 234 { 235 borderWidth: 1, 236 height: 20, 237 width: 20, 238 }, 239 ]}> 240 {selected ? ( 241 <View 242 style={[ 243 a.absolute, 244 enableSquareButtons ? a.rounded_sm : a.rounded_full, 245 {height: 14, width: 14}, 246 selected 247 ? { 248 backgroundColor: t.palette.primary_500, 249 } 250 : {}, 251 ]} 252 /> 253 ) : null} 254 </View> 255 ) 256} 257 258/** 259 * NATIVE ONLY - for adding non-pressable items to the menu 260 * 261 * @platform ios, android 262 */ 263export function ContainerItem({ 264 children, 265 style, 266}: { 267 children: React.ReactNode 268 style?: StyleProp<ViewStyle> 269}) { 270 const t = useTheme() 271 return ( 272 <View 273 style={[ 274 a.flex_row, 275 a.align_center, 276 a.gap_sm, 277 a.px_md, 278 a.rounded_md, 279 a.border, 280 t.atoms.bg_contrast_25, 281 t.atoms.border_contrast_low, 282 {paddingVertical: 10}, 283 style, 284 ]}> 285 {children} 286 </View> 287 ) 288} 289 290export function LabelText({ 291 children, 292 style, 293}: { 294 children: React.ReactNode 295 style?: StyleProp<TextStyle> 296}) { 297 const t = useTheme() 298 return ( 299 <Text 300 style={[ 301 a.font_semi_bold, 302 t.atoms.text_contrast_medium, 303 {marginBottom: -8}, 304 style, 305 ]}> 306 {children} 307 </Text> 308 ) 309} 310 311export function Group({children, style}: GroupProps) { 312 const t = useTheme() 313 return ( 314 <View 315 style={[ 316 a.rounded_md, 317 a.overflow_hidden, 318 a.border, 319 t.atoms.border_contrast_low, 320 style, 321 ]}> 322 {flattenReactChildren(children).map((child, i) => { 323 return isValidElement(child) && 324 (child.type === Item || child.type === ContainerItem) ? ( 325 <Fragment key={i}> 326 {i > 0 ? ( 327 <View style={[a.border_b, t.atoms.border_contrast_low]} /> 328 ) : null} 329 {cloneElement(child, { 330 // @ts-expect-error cloneElement is not aware of the types 331 style: { 332 borderRadius: 0, 333 borderWidth: 0, 334 }, 335 })} 336 </Fragment> 337 ) : null 338 })} 339 </View> 340 ) 341} 342 343function Cancel() { 344 const {_} = useLingui() 345 const context = useMenuContext() 346 347 return ( 348 <Button 349 label={_(msg`Close this dialog`)} 350 size="small" 351 variant="ghost" 352 color="secondary" 353 onPress={() => context.control.close()}> 354 <ButtonText> 355 <Trans>Cancel</Trans> 356 </ButtonText> 357 </Button> 358 ) 359} 360 361export function Divider() { 362 return null 363}