forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {
2 Children,
3 createContext,
4 useCallback,
5 useContext,
6 useEffect,
7 useMemo,
8 useRef,
9 useState,
10} from 'react'
11import {useWindowDimensions, View} from 'react-native'
12import Animated, {Easing, ZoomIn} from 'react-native-reanimated'
13import {useSafeAreaInsets} from 'react-native-safe-area-context'
14
15import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
16import {GlobalGestureEventsProvider} from '#/state/global-gesture-events'
17import {atoms as a, select, useTheme} from '#/alf'
18import {useOnGesture} from '#/components/hooks/useOnGesture'
19import {createPortalGroup, Portal as RootPortal} from '#/components/Portal'
20import {
21 ARROW_HALF_SIZE,
22 ARROW_SIZE,
23 BUBBLE_MAX_WIDTH,
24 MIN_EDGE_SPACE,
25} from '#/components/Tooltip/const'
26import {Text} from '#/components/Typography'
27
28const TooltipPortal = createPortalGroup()
29const TooltipProviderContext =
30 createContext<React.RefObject<View | null> | null>(null)
31
32/**
33 * Provider for Tooltip component. Only needed when you need to position the tooltip relative to a container,
34 * such as in the composer sheet.
35 *
36 * Only really necessary on iOS but can work on Android.
37 */
38export function SheetCompatProvider({children}: {children: React.ReactNode}) {
39 const ref = useRef<View | null>(null)
40 return (
41 <GlobalGestureEventsProvider style={[a.flex_1]}>
42 <TooltipPortal.Provider>
43 <View ref={ref} collapsable={false} style={[a.flex_1]}>
44 <TooltipProviderContext value={ref}>
45 {children}
46 </TooltipProviderContext>
47 </View>
48 <TooltipPortal.Outlet />
49 </TooltipPortal.Provider>
50 </GlobalGestureEventsProvider>
51 )
52}
53SheetCompatProvider.displayName = 'TooltipSheetCompatProvider'
54
55/**
56 * These are native specific values, not shared with web
57 */
58const ARROW_VISUAL_OFFSET = ARROW_SIZE / 1.25 // vibes-based, slightly off the target
59const BUBBLE_SHADOW_OFFSET = ARROW_SIZE / 3 // vibes-based, provide more shadow beneath tip
60
61type TooltipContextType = {
62 position: 'top' | 'bottom'
63 visible: boolean
64 onVisibleChange: (visible: boolean) => void
65}
66
67type TargetMeasurements = {
68 x: number
69 y: number
70 width: number
71 height: number
72}
73
74type TargetContextType = {
75 targetMeasurements: TargetMeasurements | undefined
76 setTargetMeasurements: (measurements: TargetMeasurements) => void
77 shouldMeasure: boolean
78}
79
80const TooltipContext = createContext<TooltipContextType>({
81 position: 'bottom',
82 visible: false,
83 onVisibleChange: () => {},
84})
85TooltipContext.displayName = 'TooltipContext'
86
87const TargetContext = createContext<TargetContextType>({
88 targetMeasurements: undefined,
89 setTargetMeasurements: () => {},
90 shouldMeasure: false,
91})
92TargetContext.displayName = 'TargetContext'
93
94export function Outer({
95 children,
96 position = 'bottom',
97 visible: requestVisible,
98 onVisibleChange,
99}: {
100 children: React.ReactNode
101 position?: 'top' | 'bottom'
102 visible: boolean
103 onVisibleChange: (visible: boolean) => void
104}) {
105 /**
106 * Lagging state to track the externally-controlled visibility of the
107 * tooltip, which needs to wait for the target to be measured before
108 * actually being shown.
109 */
110 const [visible, setVisible] = useState<boolean>(false)
111 const [targetMeasurements, setTargetMeasurements] = useState<
112 | {
113 x: number
114 y: number
115 width: number
116 height: number
117 }
118 | undefined
119 >(undefined)
120
121 if (requestVisible && !visible && targetMeasurements) {
122 setVisible(true)
123 } else if (!requestVisible && visible) {
124 setVisible(false)
125 setTargetMeasurements(undefined)
126 }
127
128 const ctx = useMemo(
129 () => ({position, visible, onVisibleChange}),
130 [position, visible, onVisibleChange],
131 )
132 const targetCtx = useMemo(
133 () => ({
134 targetMeasurements,
135 setTargetMeasurements,
136 shouldMeasure: requestVisible,
137 }),
138 [requestVisible, targetMeasurements, setTargetMeasurements],
139 )
140
141 return (
142 <TooltipContext.Provider value={ctx}>
143 <TargetContext.Provider value={targetCtx}>
144 {children}
145 </TargetContext.Provider>
146 </TooltipContext.Provider>
147 )
148}
149
150export function Target({children}: {children: React.ReactNode}) {
151 const {shouldMeasure, setTargetMeasurements} = useContext(TargetContext)
152 const [hasLayedOut, setHasLayedOut] = useState(false)
153 const targetRef = useRef<View>(null)
154 const containerRef = useContext(TooltipProviderContext)
155 const keyboardIsOpen = useIsKeyboardVisible()
156
157 useEffect(() => {
158 if (!shouldMeasure || !hasLayedOut) return
159 /*
160 * Once opened, measure the dimensions and position of the target
161 */
162
163 if (containerRef?.current) {
164 targetRef.current?.measureLayout(
165 containerRef.current,
166 (x, y, width, height) => {
167 if (x !== undefined && y !== undefined && width && height) {
168 setTargetMeasurements({x, y, width, height})
169 }
170 },
171 )
172 } else {
173 targetRef.current?.measure((_x, _y, width, height, x, y) => {
174 if (x !== undefined && y !== undefined && width && height) {
175 setTargetMeasurements({x, y, width, height})
176 }
177 })
178 }
179 }, [
180 shouldMeasure,
181 setTargetMeasurements,
182 hasLayedOut,
183 containerRef,
184 keyboardIsOpen,
185 ])
186
187 return (
188 <View
189 collapsable={false}
190 ref={targetRef}
191 onLayout={() => setHasLayedOut(true)}>
192 {children}
193 </View>
194 )
195}
196
197export function Content({
198 children,
199 label,
200}: {
201 children: React.ReactNode
202 label: string
203}) {
204 const {position, visible, onVisibleChange} = useContext(TooltipContext)
205 const {targetMeasurements} = useContext(TargetContext)
206 const isWithinProvider = !!useContext(TooltipProviderContext)
207 const requestClose = useCallback(() => {
208 onVisibleChange(false)
209 }, [onVisibleChange])
210
211 if (!visible || !targetMeasurements) return null
212
213 const Portal = isWithinProvider ? TooltipPortal.Portal : RootPortal
214
215 return (
216 <Portal>
217 <Bubble
218 label={label}
219 position={position}
220 /*
221 * Gotta pass these in here. Inside the Bubble, we're Potal-ed outside
222 * the context providers.
223 */
224 targetMeasurements={targetMeasurements}
225 requestClose={requestClose}>
226 {children}
227 </Bubble>
228 </Portal>
229 )
230}
231
232function Bubble({
233 children,
234 label,
235 position,
236 requestClose,
237 targetMeasurements,
238}: {
239 children: React.ReactNode
240 label: string
241 position: TooltipContextType['position']
242 requestClose: () => void
243 targetMeasurements: Exclude<
244 TargetContextType['targetMeasurements'],
245 undefined
246 >
247}) {
248 const t = useTheme()
249 const insets = useSafeAreaInsets()
250 const dimensions = useWindowDimensions()
251 const [bubbleMeasurements, setBubbleMeasurements] = useState<
252 | {
253 width: number
254 height: number
255 }
256 | undefined
257 >(undefined)
258 const coords = useMemo(() => {
259 if (!bubbleMeasurements)
260 return {
261 top: 0,
262 bottom: 0,
263 left: 0,
264 right: 0,
265 tipTop: 0,
266 tipLeft: 0,
267 }
268
269 const {width: ww, height: wh} = dimensions
270 const maxTop = insets.top
271 const maxBottom = wh - insets.bottom
272 const {width: cw, height: ch} = bubbleMeasurements
273 const minLeft = MIN_EDGE_SPACE
274 const maxLeft = ww - minLeft
275
276 let computedPosition: 'top' | 'bottom' = position
277 let top = targetMeasurements.y + targetMeasurements.height
278 let left = Math.max(
279 minLeft,
280 targetMeasurements.x + targetMeasurements.width / 2 - cw / 2,
281 )
282 const tipTranslate = ARROW_HALF_SIZE * -1
283 let tipTop = tipTranslate
284
285 if (left + cw > maxLeft) {
286 left -= left + cw - maxLeft
287 }
288
289 let tipLeft =
290 targetMeasurements.x -
291 left +
292 targetMeasurements.width / 2 -
293 ARROW_HALF_SIZE
294
295 let bottom = top + ch
296
297 function positionTop() {
298 top = top - ch - targetMeasurements.height
299 bottom = top + ch
300 tipTop = tipTop + ch
301 computedPosition = 'top'
302 }
303
304 function positionBottom() {
305 top = targetMeasurements.y + targetMeasurements.height
306 bottom = top + ch
307 tipTop = tipTranslate
308 computedPosition = 'bottom'
309 }
310
311 if (position === 'top') {
312 positionTop()
313 if (top < maxTop) {
314 positionBottom()
315 }
316 } else {
317 if (bottom > maxBottom) {
318 positionTop()
319 }
320 }
321
322 if (computedPosition === 'bottom') {
323 top += ARROW_VISUAL_OFFSET
324 bottom += ARROW_VISUAL_OFFSET
325 } else {
326 top -= ARROW_VISUAL_OFFSET
327 bottom -= ARROW_VISUAL_OFFSET
328 }
329
330 return {
331 computedPosition,
332 top,
333 bottom,
334 left,
335 right: left + cw,
336 tipTop,
337 tipLeft,
338 }
339 }, [position, targetMeasurements, bubbleMeasurements, insets, dimensions])
340
341 const requestCloseWrapped = useCallback(() => {
342 setBubbleMeasurements(undefined)
343 requestClose()
344 }, [requestClose])
345
346 useOnGesture(
347 useCallback(
348 e => {
349 const {x, y} = e
350 const isInside =
351 x > coords.left &&
352 x < coords.right &&
353 y > coords.top &&
354 y < coords.bottom
355
356 if (!isInside) {
357 requestCloseWrapped()
358 }
359 },
360 [coords, requestCloseWrapped],
361 ),
362 )
363
364 return (
365 <View
366 accessible
367 role="alert"
368 accessibilityHint=""
369 accessibilityLabel={label}
370 // android
371 importantForAccessibility="yes"
372 // ios
373 accessibilityViewIsModal
374 style={[
375 a.absolute,
376 a.align_start,
377 {
378 width: BUBBLE_MAX_WIDTH,
379 opacity: bubbleMeasurements ? 1 : 0,
380 top: coords.top,
381 left: coords.left,
382 },
383 ]}>
384 <Animated.View
385 entering={ZoomIn.easing(Easing.out(Easing.exp))}
386 style={{transformOrigin: oppposite(position)}}>
387 <View
388 style={[
389 a.absolute,
390 a.top_0,
391 a.z_10,
392 t.atoms.bg,
393 select(t.name, {
394 light: t.atoms.bg,
395 dark: t.atoms.bg_contrast_100,
396 dim: t.atoms.bg_contrast_100,
397 }),
398 {
399 borderTopLeftRadius: a.rounded_2xs.borderRadius,
400 borderBottomRightRadius: a.rounded_2xs.borderRadius,
401 width: ARROW_SIZE,
402 height: ARROW_SIZE,
403 transform: [{rotate: '45deg'}],
404 top: coords.tipTop,
405 left: coords.tipLeft,
406 },
407 ]}
408 />
409 <View
410 style={[
411 a.px_md,
412 a.py_sm,
413 a.rounded_sm,
414 select(t.name, {
415 light: t.atoms.bg,
416 dark: t.atoms.bg_contrast_100,
417 dim: t.atoms.bg_contrast_100,
418 }),
419 t.atoms.shadow_md,
420 {
421 shadowOpacity: 0.2,
422 shadowOffset: {
423 width: 0,
424 height:
425 BUBBLE_SHADOW_OFFSET *
426 (coords.computedPosition === 'bottom' ? -1 : 1),
427 },
428 },
429 ]}
430 onLayout={e => {
431 setBubbleMeasurements({
432 width: e.nativeEvent.layout.width,
433 height: e.nativeEvent.layout.height,
434 })
435 }}>
436 {children}
437 </View>
438 </Animated.View>
439 </View>
440 )
441}
442
443function oppposite(position: 'top' | 'bottom') {
444 switch (position) {
445 case 'top':
446 return 'center bottom'
447 case 'bottom':
448 return 'center top'
449 default:
450 return 'center'
451 }
452}
453
454export function TextBubble({children}: {children: React.ReactNode}) {
455 const c = Children.toArray(children)
456 return (
457 <Content label={c.join(' ')}>
458 <View style={[a.gap_xs]}>
459 {c.map((child, i) => (
460 <Text key={i} style={[a.text_sm, a.leading_snug]}>
461 {child}
462 </Text>
463 ))}
464 </View>
465 </Content>
466 )
467}