Bluesky app fork with some witchin' additions 馃挮
at post-text-option 326 lines 8.7 kB view raw
1import React, {useImperativeHandle} from 'react' 2import { 3 FlatList, 4 type FlatListProps, 5 type GestureResponderEvent, 6 type StyleProp, 7 TouchableWithoutFeedback, 8 View, 9 type ViewStyle, 10} from 'react-native' 11import {msg} from '@lingui/macro' 12import {useLingui} from '@lingui/react' 13import {DismissableLayer, FocusGuards, FocusScope} from 'radix-ui/internal' 14import {RemoveScrollBar} from 'react-remove-scroll-bar' 15 16import {logger} from '#/logger' 17import {useA11y} from '#/state/a11y' 18import {useDialogStateControlContext} from '#/state/dialogs' 19import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' 20import {atoms as a, flatten, useBreakpoints, useTheme, web} from '#/alf' 21import {Button, ButtonIcon} from '#/components/Button' 22import {Context} from '#/components/Dialog/context' 23import { 24 type DialogControlProps, 25 type DialogInnerProps, 26 type DialogOuterProps, 27} from '#/components/Dialog/types' 28import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 29import {Portal} from '#/components/Portal' 30 31export {useDialogContext, useDialogControl} from '#/components/Dialog/context' 32export * from '#/components/Dialog/shared' 33export * from '#/components/Dialog/types' 34export * from '#/components/Dialog/utils' 35export {Input} from '#/components/forms/TextField' 36 37// 100 minus 10vh of paddingVertical 38export const WEB_DIALOG_HEIGHT = '80vh' 39 40const stopPropagation = (e: any) => e.stopPropagation() 41const preventDefault = (e: any) => e.preventDefault() 42 43export function Outer({ 44 children, 45 control, 46 onClose, 47 webOptions, 48}: React.PropsWithChildren<DialogOuterProps>) { 49 const {_} = useLingui() 50 const {gtMobile} = useBreakpoints() 51 const [isOpen, setIsOpen] = React.useState(false) 52 const {setDialogIsOpen} = useDialogStateControlContext() 53 54 const open = React.useCallback(() => { 55 setDialogIsOpen(control.id, true) 56 setIsOpen(true) 57 }, [setIsOpen, setDialogIsOpen, control.id]) 58 59 const close = React.useCallback<DialogControlProps['close']>( 60 cb => { 61 setDialogIsOpen(control.id, false) 62 setIsOpen(false) 63 64 try { 65 if (cb && typeof cb === 'function') { 66 // This timeout ensures that the callback runs at the same time as it would on native. I.e. 67 // console.log('Step 1') -> close(() => console.log('Step 3')) -> console.log('Step 2') 68 // This should always output 'Step 1', 'Step 2', 'Step 3', but without the timeout it would output 69 // 'Step 1', 'Step 3', 'Step 2'. 70 setTimeout(cb) 71 } 72 } catch (e: any) { 73 logger.error(`Dialog closeCallback failed`, { 74 message: e.message, 75 }) 76 } 77 78 onClose?.() 79 }, 80 [control.id, onClose, setDialogIsOpen], 81 ) 82 83 const handleBackgroundPress = React.useCallback( 84 async (e: GestureResponderEvent) => { 85 webOptions?.onBackgroundPress ? webOptions.onBackgroundPress(e) : close() 86 }, 87 [webOptions, close], 88 ) 89 90 useImperativeHandle( 91 control.ref, 92 () => ({ 93 open, 94 close, 95 }), 96 [close, open], 97 ) 98 99 const context = React.useMemo( 100 () => ({ 101 close, 102 isNativeDialog: false, 103 nativeSnapPoint: 0, 104 disableDrag: false, 105 setDisableDrag: () => {}, 106 isWithinDialog: true, 107 }), 108 [close], 109 ) 110 111 return ( 112 <> 113 {isOpen && ( 114 <Portal> 115 <Context.Provider value={context}> 116 <RemoveScrollBar /> 117 <TouchableWithoutFeedback 118 accessibilityHint={undefined} 119 accessibilityLabel={_(msg`Close active dialog`)} 120 onPress={handleBackgroundPress}> 121 <View 122 style={[ 123 web(a.fixed), 124 a.inset_0, 125 a.z_10, 126 a.px_xl, 127 webOptions?.alignCenter ? a.justify_center : undefined, 128 a.align_center, 129 { 130 overflowY: 'auto', 131 paddingVertical: gtMobile ? '10vh' : a.pt_xl.paddingTop, 132 }, 133 ]}> 134 <Backdrop /> 135 {/** 136 * This is needed to prevent centered dialogs from overflowing 137 * above the screen, and provides a "natural" centering so that 138 * stacked dialogs appear relatively aligned. 139 */} 140 <View 141 style={[ 142 a.w_full, 143 a.z_20, 144 a.align_center, 145 web({minHeight: '60vh', position: 'static'}), 146 ]}> 147 {children} 148 </View> 149 </View> 150 </TouchableWithoutFeedback> 151 </Context.Provider> 152 </Portal> 153 )} 154 </> 155 ) 156} 157 158export function Inner({ 159 children, 160 style, 161 label, 162 accessibilityLabelledBy, 163 accessibilityDescribedBy, 164 header, 165 contentContainerStyle, 166}: DialogInnerProps) { 167 const t = useTheme() 168 const {close} = React.useContext(Context) 169 const {gtMobile} = useBreakpoints() 170 const {reduceMotionEnabled} = useA11y() 171 FocusGuards.useFocusGuards() 172 return ( 173 <FocusScope.FocusScope loop asChild trapped> 174 <View 175 role="dialog" 176 aria-role="dialog" 177 aria-label={label} 178 aria-labelledby={accessibilityLabelledBy} 179 aria-describedby={accessibilityDescribedBy} 180 // @ts-expect-error web only -prf 181 onClick={stopPropagation} 182 onStartShouldSetResponder={_ => true} 183 onTouchEnd={stopPropagation} 184 // note: flatten is required for some reason -sfn 185 style={flatten([ 186 a.relative, 187 a.rounded_md, 188 a.w_full, 189 a.border, 190 t.atoms.bg, 191 { 192 maxWidth: 600, 193 borderColor: t.palette.contrast_200, 194 shadowColor: t.palette.black, 195 shadowOpacity: t.name === 'light' ? 0.1 : 0.4, 196 shadowRadius: 30, 197 }, 198 !reduceMotionEnabled && a.zoom_fade_in, 199 style, 200 ])}> 201 <DismissableLayer.DismissableLayer 202 onInteractOutside={preventDefault} 203 onFocusOutside={preventDefault} 204 onDismiss={close} 205 style={{height: '100%', display: 'flex', flexDirection: 'column'}}> 206 {header} 207 <View style={[gtMobile ? a.p_2xl : a.p_xl, contentContainerStyle]}> 208 {children} 209 </View> 210 </DismissableLayer.DismissableLayer> 211 </View> 212 </FocusScope.FocusScope> 213 ) 214} 215 216export const ScrollableInner = Inner 217 218export const InnerFlatList = React.forwardRef< 219 FlatList, 220 FlatListProps<any> & {label: string} & { 221 webInnerStyle?: StyleProp<ViewStyle> 222 webInnerContentContainerStyle?: StyleProp<ViewStyle> 223 footer?: React.ReactNode 224 } 225>(function InnerFlatList( 226 { 227 label, 228 style, 229 webInnerStyle, 230 webInnerContentContainerStyle, 231 footer, 232 ...props 233 }, 234 ref, 235) { 236 const {gtMobile} = useBreakpoints() 237 return ( 238 <Inner 239 label={label} 240 style={[ 241 a.overflow_hidden, 242 a.px_0, 243 web({maxHeight: WEB_DIALOG_HEIGHT}), 244 webInnerStyle, 245 ]} 246 contentContainerStyle={[a.h_full, a.px_0, webInnerContentContainerStyle]}> 247 <FlatList 248 ref={ref} 249 style={[a.h_full, gtMobile ? a.px_2xl : a.px_xl, style]} 250 {...props} 251 /> 252 {footer} 253 </Inner> 254 ) 255}) 256 257export function FlatListFooter({children}: {children: React.ReactNode}) { 258 const t = useTheme() 259 260 return ( 261 <View 262 style={[ 263 a.absolute, 264 a.bottom_0, 265 a.w_full, 266 a.z_10, 267 t.atoms.bg, 268 a.border_t, 269 t.atoms.border_contrast_low, 270 a.px_lg, 271 a.py_md, 272 ]}> 273 {children} 274 </View> 275 ) 276} 277 278export function Close() { 279 const {_} = useLingui() 280 const {close} = React.useContext(Context) 281 282 const enableSquareButtons = useEnableSquareButtons() 283 284 return ( 285 <View 286 style={[ 287 a.absolute, 288 a.z_10, 289 { 290 top: a.pt_md.paddingTop, 291 right: a.pr_md.paddingRight, 292 }, 293 ]}> 294 <Button 295 size="small" 296 variant="ghost" 297 color="secondary" 298 shape={enableSquareButtons ? 'square' : 'round'} 299 onPress={() => close()} 300 label={_(msg`Close active dialog`)}> 301 <ButtonIcon icon={X} size="md" /> 302 </Button> 303 </View> 304 ) 305} 306 307export function Handle() { 308 return null 309} 310 311function Backdrop() { 312 const t = useTheme() 313 const {reduceMotionEnabled} = useA11y() 314 return ( 315 <View style={{opacity: 0.8}}> 316 <View 317 style={[ 318 a.fixed, 319 a.inset_0, 320 {backgroundColor: t.palette.black}, 321 !reduceMotionEnabled && a.fade_in, 322 ]} 323 /> 324 </View> 325 ) 326}