my fork of the bluesky client
1import React, {useImperativeHandle} from 'react'
2import {
3 FlatList,
4 FlatListProps,
5 StyleProp,
6 TouchableWithoutFeedback,
7 View,
8 ViewStyle,
9} from 'react-native'
10import {msg} from '@lingui/macro'
11import {useLingui} from '@lingui/react'
12import {DismissableLayer} from '@radix-ui/react-dismissable-layer'
13import {useFocusGuards} from '@radix-ui/react-focus-guards'
14import {FocusScope} from '@radix-ui/react-focus-scope'
15
16import {logger} from '#/logger'
17import {useDialogStateControlContext} from '#/state/dialogs'
18import {atoms as a, flatten, useBreakpoints, useTheme, web} from '#/alf'
19import {Button, ButtonIcon} from '#/components/Button'
20import {Context} from '#/components/Dialog/context'
21import {
22 DialogControlProps,
23 DialogInnerProps,
24 DialogOuterProps,
25} from '#/components/Dialog/types'
26import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
27import {Portal} from '#/components/Portal'
28
29export {useDialogContext, useDialogControl} from '#/components/Dialog/context'
30export * from '#/components/Dialog/shared'
31export * from '#/components/Dialog/types'
32export * from '#/components/Dialog/utils'
33export {Input} from '#/components/forms/TextField'
34
35const stopPropagation = (e: any) => e.stopPropagation()
36const preventDefault = (e: any) => e.preventDefault()
37
38export function Outer({
39 children,
40 control,
41 onClose,
42}: React.PropsWithChildren<DialogOuterProps>) {
43 const {_} = useLingui()
44 const {gtMobile} = useBreakpoints()
45 const [isOpen, setIsOpen] = React.useState(false)
46 const {setDialogIsOpen} = useDialogStateControlContext()
47
48 const open = React.useCallback(() => {
49 setDialogIsOpen(control.id, true)
50 setIsOpen(true)
51 }, [setIsOpen, setDialogIsOpen, control.id])
52
53 const close = React.useCallback<DialogControlProps['close']>(
54 cb => {
55 setDialogIsOpen(control.id, false)
56 setIsOpen(false)
57
58 try {
59 if (cb && typeof cb === 'function') {
60 // This timeout ensures that the callback runs at the same time as it would on native. I.e.
61 // console.log('Step 1') -> close(() => console.log('Step 3')) -> console.log('Step 2')
62 // This should always output 'Step 1', 'Step 2', 'Step 3', but without the timeout it would output
63 // 'Step 1', 'Step 3', 'Step 2'.
64 setTimeout(cb)
65 }
66 } catch (e: any) {
67 logger.error(`Dialog closeCallback failed`, {
68 message: e.message,
69 })
70 }
71
72 onClose?.()
73 },
74 [control.id, onClose, setDialogIsOpen],
75 )
76
77 const handleBackgroundPress = React.useCallback(async () => {
78 close()
79 }, [close])
80
81 useImperativeHandle(
82 control.ref,
83 () => ({
84 open,
85 close,
86 }),
87 [close, open],
88 )
89
90 const context = React.useMemo(
91 () => ({
92 close,
93 isNativeDialog: false,
94 nativeSnapPoint: 0,
95 disableDrag: false,
96 setDisableDrag: () => {},
97 }),
98 [close],
99 )
100
101 return (
102 <>
103 {isOpen && (
104 <Portal>
105 <Context.Provider value={context}>
106 <TouchableWithoutFeedback
107 accessibilityHint={undefined}
108 accessibilityLabel={_(msg`Close active dialog`)}
109 onPress={handleBackgroundPress}>
110 <View
111 style={[
112 web(a.fixed),
113 a.inset_0,
114 a.z_10,
115 a.align_center,
116 gtMobile ? a.p_lg : a.p_md,
117 {overflowY: 'auto'},
118 ]}>
119 <Backdrop />
120 <View
121 style={[
122 a.w_full,
123 a.z_20,
124 a.justify_center,
125 a.align_center,
126 {
127 minHeight: web('calc(90vh - 36px)') || undefined,
128 },
129 ]}>
130 {children}
131 </View>
132 </View>
133 </TouchableWithoutFeedback>
134 </Context.Provider>
135 </Portal>
136 )}
137 </>
138 )
139}
140
141export function Inner({
142 children,
143 style,
144 label,
145 accessibilityLabelledBy,
146 accessibilityDescribedBy,
147 header,
148 contentContainerStyle,
149}: DialogInnerProps) {
150 const t = useTheme()
151 const {close} = React.useContext(Context)
152 const {gtMobile} = useBreakpoints()
153 useFocusGuards()
154 return (
155 <FocusScope loop asChild trapped>
156 <View
157 role="dialog"
158 aria-role="dialog"
159 aria-label={label}
160 aria-labelledby={accessibilityLabelledBy}
161 aria-describedby={accessibilityDescribedBy}
162 // @ts-ignore web only -prf
163 onClick={stopPropagation}
164 onStartShouldSetResponder={_ => true}
165 onTouchEnd={stopPropagation}
166 style={flatten([
167 a.relative,
168 a.rounded_md,
169 a.w_full,
170 a.border,
171 t.atoms.bg,
172 {
173 maxWidth: 600,
174 borderColor: t.palette.contrast_200,
175 shadowColor: t.palette.black,
176 shadowOpacity: t.name === 'light' ? 0.1 : 0.4,
177 shadowRadius: 30,
178 // @ts-ignore web only
179 animation: 'fadeIn ease-out 0.1s',
180 },
181 flatten(style),
182 ])}>
183 <DismissableLayer
184 onInteractOutside={preventDefault}
185 onFocusOutside={preventDefault}
186 onDismiss={close}
187 style={{display: 'flex', flexDirection: 'column'}}>
188 {header}
189 <View style={[gtMobile ? a.p_2xl : a.p_xl, contentContainerStyle]}>
190 {children}
191 </View>
192 </DismissableLayer>
193 </View>
194 </FocusScope>
195 )
196}
197
198export const ScrollableInner = Inner
199
200export const InnerFlatList = React.forwardRef<
201 FlatList,
202 FlatListProps<any> & {label: string} & {
203 webInnerStyle?: StyleProp<ViewStyle>
204 webInnerContentContainerStyle?: StyleProp<ViewStyle>
205 }
206>(function InnerFlatList(
207 {label, style, webInnerStyle, webInnerContentContainerStyle, ...props},
208 ref,
209) {
210 const {gtMobile} = useBreakpoints()
211 return (
212 <Inner
213 label={label}
214 style={[
215 a.overflow_hidden,
216 a.px_0,
217 // @ts-ignore web only -sfn
218 {maxHeight: 'calc(-36px + 100vh)'},
219 webInnerStyle,
220 ]}
221 contentContainerStyle={[a.px_0, webInnerContentContainerStyle]}>
222 <FlatList
223 ref={ref}
224 style={[gtMobile ? a.px_2xl : a.px_xl, flatten(style)]}
225 {...props}
226 />
227 </Inner>
228 )
229})
230
231export function Close() {
232 const {_} = useLingui()
233 const {close} = React.useContext(Context)
234 return (
235 <View
236 style={[
237 a.absolute,
238 a.z_10,
239 {
240 top: a.pt_md.paddingTop,
241 right: a.pr_md.paddingRight,
242 },
243 ]}>
244 <Button
245 size="small"
246 variant="ghost"
247 color="secondary"
248 shape="round"
249 onPress={() => close()}
250 label={_(msg`Close active dialog`)}>
251 <ButtonIcon icon={X} size="md" />
252 </Button>
253 </View>
254 )
255}
256
257export function Handle() {
258 return null
259}
260
261function Backdrop() {
262 const t = useTheme()
263 return (
264 <View
265 style={{
266 opacity: 0.8,
267 }}>
268 <View
269 style={[
270 a.fixed,
271 a.inset_0,
272 {
273 backgroundColor: t.palette.black,
274 // @ts-ignore web only
275 animation: 'fadeIn ease-out 0.15s',
276 },
277 ]}
278 />
279 </View>
280 )
281}