Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
117
fork

Configure Feed

Select the types of activity you want to include in your feed.

at a876aae44ea07494ebea9727350aa060b81f317b 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}