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 359 lines 9.9 kB view raw
1/** 2 * Copyright (c) JOB TODAY S.A. and its affiliates. 3 * 4 * This source code is licensed under the MIT license found in the 5 * LICENSE file in the root directory of this source tree. 6 * 7 */ 8 9import {memo, useState} from 'react' 10import {ActivityIndicator, StyleSheet} from 'react-native' 11import { 12 Gesture, 13 GestureDetector, 14 type PanGesture, 15} from 'react-native-gesture-handler' 16import Animated, { 17 runOnJS, 18 type SharedValue, 19 useAnimatedProps, 20 useAnimatedReaction, 21 useAnimatedRef, 22 useAnimatedScrollHandler, 23 useAnimatedStyle, 24 useSharedValue, 25} from 'react-native-reanimated' 26import {useSafeAreaFrame} from 'react-native-safe-area-context' 27import {Image} from 'expo-image' 28 29import { 30 type Dimensions as ImageDimensions, 31 type ImageSource, 32 type LightboxTransforms, 33} from '../../@types' 34 35const MAX_ORIGINAL_IMAGE_ZOOM = 2 36const MIN_SCREEN_ZOOM = 2 37 38type Props = { 39 imageSrc: ImageSource 40 onRequestClose: () => void 41 onTap: () => void 42 onZoom: (scaled: boolean) => void 43 onLoad: (dims: ImageDimensions) => void 44 isScrollViewBeingDragged: boolean 45 showControls: boolean 46 measureSafeArea: () => { 47 x: number 48 y: number 49 width: number 50 height: number 51 } 52 imageAspect: number | undefined 53 imageDimensions: ImageDimensions | undefined 54 dismissSwipePan: PanGesture 55 transforms: Readonly<SharedValue<LightboxTransforms>> 56} 57 58const ImageItem = ({ 59 imageSrc, 60 onTap, 61 onZoom, 62 onLoad, 63 showControls, 64 measureSafeArea, 65 imageAspect, 66 imageDimensions, 67 dismissSwipePan, 68 transforms, 69}: Props) => { 70 const scrollViewRef = useAnimatedRef<Animated.ScrollView>() 71 const [scaled, setScaled] = useState(false) 72 const isDragging = useSharedValue(false) 73 const screenSizeDelayedForJSThreadOnly = useSafeAreaFrame() 74 const maxZoomScale = Math.max( 75 MIN_SCREEN_ZOOM, 76 imageDimensions 77 ? (imageDimensions.width / screenSizeDelayedForJSThreadOnly.width) * 78 MAX_ORIGINAL_IMAGE_ZOOM 79 : 1, 80 ) 81 82 const scrollHandler = useAnimatedScrollHandler({ 83 onScroll(e) { 84 'worklet' 85 const nextIsScaled = e.zoomScale > 1 86 if (scaled !== nextIsScaled) { 87 runOnJS(handleZoom)(nextIsScaled) 88 } 89 }, 90 onBeginDrag() { 91 'worklet' 92 isDragging.value = true 93 }, 94 onEndDrag() { 95 'worklet' 96 isDragging.value = false 97 }, 98 }) 99 100 function handleZoom(nextIsScaled: boolean) { 101 onZoom(nextIsScaled) 102 setScaled(nextIsScaled) 103 } 104 105 function zoomTo(nextZoomRect: { 106 x: number 107 y: number 108 width: number 109 height: number 110 }) { 111 const scrollResponderRef = scrollViewRef?.current?.getScrollResponder() 112 // @ts-ignore 113 scrollResponderRef?.scrollResponderZoomTo({ 114 ...nextZoomRect, // This rect is in screen coordinates 115 animated: true, 116 }) 117 } 118 119 const singleTap = Gesture.Tap().onEnd(() => { 120 'worklet' 121 runOnJS(onTap)() 122 }) 123 124 const doubleTap = Gesture.Tap() 125 .numberOfTaps(2) 126 .onEnd(e => { 127 'worklet' 128 const screenSize = measureSafeArea() 129 const {absoluteX, absoluteY} = e 130 let nextZoomRect = { 131 x: 0, 132 y: 0, 133 width: screenSize.width, 134 height: screenSize.height, 135 } 136 const willZoom = !scaled 137 if (willZoom) { 138 nextZoomRect = getZoomRectAfterDoubleTap( 139 imageAspect, 140 absoluteX, 141 absoluteY, 142 screenSize, 143 ) 144 } 145 runOnJS(zoomTo)(nextZoomRect) 146 }) 147 148 const composedGesture = Gesture.Exclusive( 149 dismissSwipePan, 150 doubleTap, 151 singleTap, 152 ) 153 154 const containerStyle = useAnimatedStyle(() => { 155 const {scaleAndMoveTransform, isHidden} = transforms.get() 156 return { 157 flex: 1, 158 transform: scaleAndMoveTransform, 159 opacity: isHidden ? 0 : 1, 160 } 161 }) 162 163 const imageCropStyle = useAnimatedStyle(() => { 164 const screenSize = measureSafeArea() 165 const {cropFrameTransform, borderRadius: br} = transforms.get() 166 return { 167 overflow: 'hidden', 168 transform: cropFrameTransform, 169 borderRadius: br, 170 width: screenSize.width, 171 maxHeight: screenSize.height, 172 alignSelf: 'center', 173 aspectRatio: imageAspect ?? 1 /* force onLoad */, 174 opacity: imageAspect === undefined ? 0 : 1, 175 } 176 }) 177 178 const imageStyle = useAnimatedStyle(() => { 179 const {cropContentTransform} = transforms.get() 180 return { 181 transform: cropContentTransform, 182 width: '100%', 183 aspectRatio: imageAspect ?? 1 /* force onLoad */, 184 opacity: imageAspect === undefined ? 0 : 1, 185 } 186 }) 187 188 const [showLoader, setShowLoader] = useState(false) 189 const [hasLoaded, setHasLoaded] = useState(false) 190 useAnimatedReaction( 191 () => { 192 return transforms.get().isResting && !hasLoaded 193 }, 194 (show, prevShow) => { 195 if (!prevShow && show) { 196 runOnJS(setShowLoader)(true) 197 } else if (prevShow && !show) { 198 runOnJS(setShowLoader)(false) 199 } 200 }, 201 ) 202 203 const type = imageSrc.type 204 const borderRadius = 205 type === 'circle-avi' ? 1e5 : type === 'rect-avi' ? 20 : 0 206 207 const scrollViewProps = useAnimatedProps(() => ({ 208 // Don't allow bounce at 1:1 rest so it can be swiped away. 209 bounces: scaled || isDragging.value, 210 })) 211 212 return ( 213 <GestureDetector gesture={composedGesture}> 214 <Animated.ScrollView 215 // @ts-ignore Something's up with the types here 216 ref={scrollViewRef} 217 pinchGestureEnabled 218 showsHorizontalScrollIndicator={false} 219 showsVerticalScrollIndicator={false} 220 maximumZoomScale={maxZoomScale} 221 onScroll={scrollHandler} 222 style={containerStyle} 223 animatedProps={scrollViewProps} 224 centerContent> 225 {showLoader && ( 226 <ActivityIndicator size="small" color="#FFF" style={styles.loading} /> 227 )} 228 <Animated.View style={imageCropStyle}> 229 <Animated.View style={imageStyle}> 230 <Image 231 contentFit="contain" 232 source={{uri: imageSrc.uri}} 233 placeholderContentFit="contain" 234 placeholder={{uri: imageSrc.thumbUri}} 235 style={{flex: 1, borderRadius}} 236 accessibilityLabel={imageSrc.alt} 237 accessibilityHint="" 238 enableLiveTextInteraction={showControls && !scaled} 239 accessibilityIgnoresInvertColors 240 onLoad={ 241 hasLoaded 242 ? undefined 243 : e => { 244 setHasLoaded(true) 245 onLoad({width: e.source.width, height: e.source.height}) 246 } 247 } 248 cachePolicy="memory" 249 /> 250 </Animated.View> 251 </Animated.View> 252 </Animated.ScrollView> 253 </GestureDetector> 254 ) 255} 256 257const styles = StyleSheet.create({ 258 loading: { 259 position: 'absolute', 260 top: 0, 261 left: 0, 262 right: 0, 263 bottom: 0, 264 }, 265 image: { 266 flex: 1, 267 }, 268}) 269 270const getZoomRectAfterDoubleTap = ( 271 imageAspect: number | undefined, 272 touchX: number, 273 touchY: number, 274 screenSize: {width: number; height: number}, 275): { 276 x: number 277 y: number 278 width: number 279 height: number 280} => { 281 'worklet' 282 if (!imageAspect) { 283 return { 284 x: 0, 285 y: 0, 286 width: screenSize.width, 287 height: screenSize.height, 288 } 289 } 290 291 // First, let's figure out how much we want to zoom in. 292 // We want to try to zoom in at least close enough to get rid of black bars. 293 const screenAspect = screenSize.width / screenSize.height 294 const zoom = Math.max( 295 imageAspect / screenAspect, 296 screenAspect / imageAspect, 297 MIN_SCREEN_ZOOM, 298 ) 299 // Unlike in the Android version, we don't constrain the *max* zoom level here. 300 // Instead, this is done in the ScrollView props so that it constraints pinch too. 301 302 // Next, we'll be calculating the rectangle to "zoom into" in screen coordinates. 303 // We already know the zoom level, so this gives us the rectangle size. 304 let rectWidth = screenSize.width / zoom 305 let rectHeight = screenSize.height / zoom 306 307 // Before we settle on the zoomed rect, figure out the safe area it has to be inside. 308 // We don't want to introduce new black bars or make existing black bars unbalanced. 309 let minX = 0 310 let minY = 0 311 let maxX = screenSize.width - rectWidth 312 let maxY = screenSize.height - rectHeight 313 if (imageAspect >= screenAspect) { 314 // The image has horizontal black bars. Exclude them from the safe area. 315 const renderedHeight = screenSize.width / imageAspect 316 const horizontalBarHeight = (screenSize.height - renderedHeight) / 2 317 minY += horizontalBarHeight 318 maxY -= horizontalBarHeight 319 } else { 320 // The image has vertical black bars. Exclude them from the safe area. 321 const renderedWidth = screenSize.height * imageAspect 322 const verticalBarWidth = (screenSize.width - renderedWidth) / 2 323 minX += verticalBarWidth 324 maxX -= verticalBarWidth 325 } 326 327 // Finally, we can position the rect according to its size and the safe area. 328 let rectX 329 if (maxX >= minX) { 330 // Content fills the screen horizontally so we have horizontal wiggle room. 331 // Try to keep the tapped point under the finger after zoom. 332 rectX = touchX - touchX / zoom 333 rectX = Math.min(rectX, maxX) 334 rectX = Math.max(rectX, minX) 335 } else { 336 // Keep the rect centered on the screen so that black bars are balanced. 337 rectX = screenSize.width / 2 - rectWidth / 2 338 } 339 let rectY 340 if (maxY >= minY) { 341 // Content fills the screen vertically so we have vertical wiggle room. 342 // Try to keep the tapped point under the finger after zoom. 343 rectY = touchY - touchY / zoom 344 rectY = Math.min(rectY, maxY) 345 rectY = Math.max(rectY, minY) 346 } else { 347 // Keep the rect centered on the screen so that black bars are balanced. 348 rectY = screenSize.height / 2 - rectHeight / 2 349 } 350 351 return { 352 x: rectX, 353 y: rectY, 354 height: rectHeight, 355 width: rectWidth, 356 } 357} 358 359export default memo(ImageItem)