Bluesky app fork with some witchin' additions 馃挮
at main 467 lines 12 kB view raw
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}