forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
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}