Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

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

at main 468 lines 14 kB view raw
1import {memo, useState} from 'react' 2import {ActivityIndicator, StyleSheet} from 'react-native' 3import { 4 Gesture, 5 GestureDetector, 6 type PanGesture, 7} from 'react-native-gesture-handler' 8import Animated, { 9 runOnJS, 10 type SharedValue, 11 useAnimatedReaction, 12 useAnimatedRef, 13 useAnimatedStyle, 14 useSharedValue, 15 withSpring, 16} from 'react-native-reanimated' 17import {Image} from 'expo-image' 18 19import { 20 type Dimensions as ImageDimensions, 21 type ImageSource, 22 type LightboxTransforms, 23} from '../../@types' 24import { 25 applyRounding, 26 createTransform, 27 prependPan, 28 prependPinch, 29 prependTransform, 30 readTransform, 31 type TransformMatrix, 32} from '../../transforms' 33 34const MIN_SCREEN_ZOOM = 2 35const MAX_ORIGINAL_IMAGE_ZOOM = 2 36 37const initialTransform = createTransform() 38 39type Props = { 40 imageSrc: ImageSource 41 onRequestClose: () => void 42 onTap: () => void 43 onZoom: (isZoomed: boolean) => void 44 onLoad: (dims: ImageDimensions) => void 45 isScrollViewBeingDragged: boolean 46 showControls: boolean 47 measureSafeArea: () => { 48 x: number 49 y: number 50 width: number 51 height: number 52 } 53 imageAspect: number | undefined 54 imageDimensions: ImageDimensions | undefined 55 dismissSwipePan: PanGesture 56 transforms: Readonly<SharedValue<LightboxTransforms>> 57} 58const ImageItem = ({ 59 imageSrc, 60 onTap, 61 onZoom, 62 onLoad, 63 isScrollViewBeingDragged, 64 measureSafeArea, 65 imageAspect, 66 imageDimensions, 67 dismissSwipePan, 68 transforms, 69}: Props) => { 70 const [isScaled, setIsScaled] = useState(false) 71 const committedTransform = useSharedValue(initialTransform) 72 const panTranslation = useSharedValue({x: 0, y: 0}) 73 const pinchOrigin = useSharedValue({x: 0, y: 0}) 74 const pinchScale = useSharedValue(1) 75 const pinchTranslation = useSharedValue({x: 0, y: 0}) 76 const containerRef = useAnimatedRef() 77 78 // Keep track of when we're entering or leaving scaled rendering. 79 // Note: DO NOT move any logic reading animated values outside this function. 80 useAnimatedReaction( 81 () => { 82 if (pinchScale.get() !== 1) { 83 // We're currently pinching. 84 return true 85 } 86 const [, , committedScale] = readTransform(committedTransform.get()) 87 if (committedScale !== 1) { 88 // We started from a pinched in state. 89 return true 90 } 91 // We're at rest. 92 return false 93 }, 94 (nextIsScaled, prevIsScaled) => { 95 if (nextIsScaled !== prevIsScaled) { 96 runOnJS(handleZoom)(nextIsScaled) 97 } 98 }, 99 ) 100 101 function handleZoom(nextIsScaled: boolean) { 102 setIsScaled(nextIsScaled) 103 onZoom(nextIsScaled) 104 } 105 106 // On Android, stock apps prevent going "out of bounds" on pan or pinch. You should "bump" into edges. 107 // If the user tried to pan too hard, this function will provide the negative panning to stay in bounds. 108 function getExtraTranslationToStayInBounds( 109 candidateTransform: TransformMatrix, 110 screenSize: {width: number; height: number}, 111 ) { 112 'worklet' 113 if (!imageAspect) { 114 return [0, 0] 115 } 116 const [nextTranslateX, nextTranslateY, nextScale] = 117 readTransform(candidateTransform) 118 const scaledDimensions = getScaledDimensions( 119 imageAspect, 120 nextScale, 121 screenSize, 122 ) 123 const clampedTranslateX = clampTranslation( 124 nextTranslateX, 125 scaledDimensions.width, 126 screenSize.width, 127 ) 128 const clampedTranslateY = clampTranslation( 129 nextTranslateY, 130 scaledDimensions.height, 131 screenSize.height, 132 ) 133 const dx = clampedTranslateX - nextTranslateX 134 const dy = clampedTranslateY - nextTranslateY 135 return [dx, dy] 136 } 137 138 const pinch = Gesture.Pinch() 139 .onStart(e => { 140 'worklet' 141 const screenSize = measureSafeArea() 142 pinchOrigin.set({ 143 x: e.focalX - screenSize.width / 2, 144 y: e.focalY - screenSize.height / 2, 145 }) 146 }) 147 .onChange(e => { 148 'worklet' 149 const screenSize = measureSafeArea() 150 if (!imageDimensions) { 151 return 152 } 153 // Don't let the picture zoom in so close that it gets blurry. 154 // Also, like in stock Android apps, don't let the user zoom out further than 1:1. 155 const [, , committedScale] = readTransform(committedTransform.get()) 156 const maxCommittedScale = Math.max( 157 MIN_SCREEN_ZOOM, 158 (imageDimensions.width / screenSize.width) * MAX_ORIGINAL_IMAGE_ZOOM, 159 ) 160 const minPinchScale = 1 / committedScale 161 const maxPinchScale = maxCommittedScale / committedScale 162 const nextPinchScale = Math.min( 163 Math.max(minPinchScale, e.scale), 164 maxPinchScale, 165 ) 166 pinchScale.set(nextPinchScale) 167 168 // Zooming out close to the corner could push us out of bounds, which we don't want on Android. 169 // Calculate where we'll end up so we know how much to translate back to stay in bounds. 170 const t = createTransform() 171 prependPan(t, panTranslation.get()) 172 prependPinch(t, nextPinchScale, pinchOrigin.get(), pinchTranslation.get()) 173 prependTransform(t, committedTransform.get()) 174 const [dx, dy] = getExtraTranslationToStayInBounds(t, screenSize) 175 if (dx !== 0 || dy !== 0) { 176 const pt = pinchTranslation.get() 177 pinchTranslation.set({ 178 x: pt.x + dx, 179 y: pt.y + dy, 180 }) 181 } 182 }) 183 .onEnd(() => { 184 'worklet' 185 // Commit just the pinch. 186 let t = createTransform() 187 prependPinch( 188 t, 189 pinchScale.get(), 190 pinchOrigin.get(), 191 pinchTranslation.get(), 192 ) 193 prependTransform(t, committedTransform.get()) 194 applyRounding(t) 195 committedTransform.set(t) 196 197 // Reset just the pinch. 198 pinchScale.set(1) 199 pinchOrigin.set({x: 0, y: 0}) 200 pinchTranslation.set({x: 0, y: 0}) 201 }) 202 203 const pan = Gesture.Pan() 204 .averageTouches(true) 205 // Unlike .enabled(isScaled), this ensures that an initial pinch can turn into a pan midway: 206 .minPointers(isScaled ? 1 : 2) 207 .onChange(e => { 208 'worklet' 209 const screenSize = measureSafeArea() 210 if (!imageDimensions) { 211 return 212 } 213 214 const nextPanTranslation = {x: e.translationX, y: e.translationY} 215 let t = createTransform() 216 prependPan(t, nextPanTranslation) 217 prependPinch( 218 t, 219 pinchScale.get(), 220 pinchOrigin.get(), 221 pinchTranslation.get(), 222 ) 223 prependTransform(t, committedTransform.get()) 224 225 // Prevent panning from going out of bounds. 226 const [dx, dy] = getExtraTranslationToStayInBounds(t, screenSize) 227 nextPanTranslation.x += dx 228 nextPanTranslation.y += dy 229 panTranslation.set(nextPanTranslation) 230 }) 231 .onEnd(() => { 232 'worklet' 233 // Commit just the pan. 234 let t = createTransform() 235 prependPan(t, panTranslation.get()) 236 prependTransform(t, committedTransform.get()) 237 applyRounding(t) 238 committedTransform.set(t) 239 240 // Reset just the pan. 241 panTranslation.set({x: 0, y: 0}) 242 }) 243 244 const singleTap = Gesture.Tap().onEnd(() => { 245 'worklet' 246 runOnJS(onTap)() 247 }) 248 249 const doubleTap = Gesture.Tap() 250 .numberOfTaps(2) 251 .onEnd(e => { 252 'worklet' 253 const screenSize = measureSafeArea() 254 if (!imageDimensions || !imageAspect) { 255 return 256 } 257 const [, , committedScale] = readTransform(committedTransform.get()) 258 if (committedScale !== 1) { 259 // Go back to 1:1 using the identity vector. 260 let t = createTransform() 261 committedTransform.set(withClampedSpring(t)) 262 return 263 } 264 265 // Try to zoom in so that we get rid of the black bars (whatever the orientation was). 266 const screenAspect = screenSize.width / screenSize.height 267 const candidateScale = Math.max( 268 imageAspect / screenAspect, 269 screenAspect / imageAspect, 270 MIN_SCREEN_ZOOM, 271 ) 272 // But don't zoom in so close that the picture gets blurry. 273 const maxScale = Math.max( 274 MIN_SCREEN_ZOOM, 275 (imageDimensions.width / screenSize.width) * MAX_ORIGINAL_IMAGE_ZOOM, 276 ) 277 const scale = Math.min(candidateScale, maxScale) 278 279 // Calculate where we would be if the user pinched into the double tapped point. 280 // We won't use this transform directly because it may go out of bounds. 281 const candidateTransform = createTransform() 282 const origin = { 283 x: e.absoluteX - screenSize.width / 2, 284 y: e.absoluteY - screenSize.height / 2, 285 } 286 prependPinch(candidateTransform, scale, origin, {x: 0, y: 0}) 287 288 // Now we know how much we went out of bounds, so we can shoot correctly. 289 const [dx, dy] = getExtraTranslationToStayInBounds( 290 candidateTransform, 291 screenSize, 292 ) 293 const finalTransform = createTransform() 294 prependPinch(finalTransform, scale, origin, {x: dx, y: dy}) 295 committedTransform.set(withClampedSpring(finalTransform)) 296 }) 297 298 const composedGesture = isScrollViewBeingDragged 299 ? // If the parent is not at rest, provide a no-op gesture. 300 Gesture.Manual() 301 : Gesture.Exclusive( 302 dismissSwipePan, 303 Gesture.Simultaneous(pinch, pan), 304 doubleTap, 305 singleTap, 306 ) 307 308 const containerStyle = useAnimatedStyle(() => { 309 const {scaleAndMoveTransform, isHidden} = transforms.get() 310 // Apply the active adjustments on top of the committed transform before the gestures. 311 // This is matrix multiplication, so operations are applied in the reverse order. 312 let t = createTransform() 313 prependPan(t, panTranslation.get()) 314 prependPinch(t, pinchScale.get(), pinchOrigin.get(), pinchTranslation.get()) 315 prependTransform(t, committedTransform.get()) 316 const [translateX, translateY, scale] = readTransform(t) 317 const manipulationTransform = [ 318 {translateX}, 319 {translateY: translateY}, 320 {scale}, 321 ] 322 const screenSize = measureSafeArea() 323 return { 324 opacity: isHidden ? 0 : 1, 325 transform: scaleAndMoveTransform.concat(manipulationTransform), 326 width: screenSize.width, 327 maxHeight: screenSize.height, 328 alignSelf: 'center', 329 aspectRatio: imageAspect ?? 1 /* force onLoad */, 330 } 331 }) 332 333 const imageCropStyle = useAnimatedStyle(() => { 334 const {cropFrameTransform, borderRadius: br} = transforms.get() 335 return { 336 flex: 1, 337 overflow: 'hidden', 338 transform: cropFrameTransform, 339 borderRadius: br, 340 } 341 }) 342 343 const imageStyle = useAnimatedStyle(() => { 344 const {cropContentTransform} = transforms.get() 345 return { 346 flex: 1, 347 transform: cropContentTransform, 348 opacity: imageAspect === undefined ? 0 : 1, 349 } 350 }) 351 352 const [showLoader, setShowLoader] = useState(false) 353 const [hasLoaded, setHasLoaded] = useState(false) 354 useAnimatedReaction( 355 () => { 356 return transforms.get().isResting && !hasLoaded 357 }, 358 (show, prevShow) => { 359 if (!prevShow && show) { 360 runOnJS(setShowLoader)(true) 361 } else if (prevShow && !show) { 362 runOnJS(setShowLoader)(false) 363 } 364 }, 365 ) 366 367 const type = imageSrc.type 368 const borderRadius = 369 type === 'circle-avi' ? 1e5 : type === 'rect-avi' ? 20 : 0 370 371 return ( 372 <GestureDetector gesture={composedGesture}> 373 <Animated.View 374 ref={containerRef} 375 style={[styles.container]} 376 renderToHardwareTextureAndroid> 377 <Animated.View style={containerStyle}> 378 {showLoader && ( 379 <ActivityIndicator 380 size="small" 381 color="#FFF" 382 style={styles.loading} 383 /> 384 )} 385 <Animated.View style={imageCropStyle}> 386 <Animated.View style={imageStyle}> 387 <Image 388 contentFit="contain" 389 source={{uri: imageSrc.uri}} 390 placeholderContentFit="contain" 391 placeholder={{uri: imageSrc.thumbUri}} 392 accessibilityLabel={imageSrc.alt} 393 onLoad={ 394 hasLoaded 395 ? undefined 396 : e => { 397 setHasLoaded(true) 398 onLoad({width: e.source.width, height: e.source.height}) 399 } 400 } 401 style={{flex: 1, borderRadius}} 402 accessibilityHint="" 403 accessibilityIgnoresInvertColors 404 cachePolicy="memory" 405 /> 406 </Animated.View> 407 </Animated.View> 408 </Animated.View> 409 </Animated.View> 410 </GestureDetector> 411 ) 412} 413 414const styles = StyleSheet.create({ 415 container: { 416 height: '100%', 417 overflow: 'hidden', 418 justifyContent: 'center', 419 }, 420 loading: { 421 position: 'absolute', 422 left: 0, 423 right: 0, 424 top: 0, 425 bottom: 0, 426 justifyContent: 'center', 427 }, 428}) 429 430function getScaledDimensions( 431 imageAspect: number, 432 scale: number, 433 screenSize: {width: number; height: number}, 434): ImageDimensions { 435 'worklet' 436 const screenAspect = screenSize.width / screenSize.height 437 const isLandscape = imageAspect > screenAspect 438 if (isLandscape) { 439 return { 440 width: scale * screenSize.width, 441 height: (scale * screenSize.width) / imageAspect, 442 } 443 } else { 444 return { 445 width: scale * screenSize.height * imageAspect, 446 height: scale * screenSize.height, 447 } 448 } 449} 450 451function clampTranslation( 452 value: number, 453 scaledSize: number, 454 screenSize: number, 455): number { 456 'worklet' 457 // Figure out how much the user should be allowed to pan, and constrain the translation. 458 const panDistance = Math.max(0, (scaledSize - screenSize) / 2) 459 const clampedValue = Math.min(Math.max(-panDistance, value), panDistance) 460 return clampedValue 461} 462 463function withClampedSpring(value: any) { 464 'worklet' 465 return withSpring(value, {overshootClamping: true}) 466} 467 468export default memo(ImageItem)