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