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 891 lines 25 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// Original code copied and simplified from the link below as the codebase is currently not maintained: 9// https://github.com/jobtoday/react-native-image-viewing 10import {useCallback, useEffect, useMemo, useRef, useState} from 'react' 11import { 12 LayoutAnimation, 13 PixelRatio, 14 StyleSheet, 15 useWindowDimensions, 16 View, 17} from 'react-native' 18import {SystemBars} from 'react-native-edge-to-edge' 19import {Gesture} from 'react-native-gesture-handler' 20import PagerView from 'react-native-pager-view' 21import Animated, { 22 type AnimatedRef, 23 cancelAnimation, 24 interpolate, 25 measure, 26 type MeasuredDimensions, 27 ReduceMotion, 28 runOnJS, 29 runOnUI, 30 type SharedValue, 31 useAnimatedReaction, 32 useAnimatedRef, 33 useAnimatedStyle, 34 useDerivedValue, 35 useSharedValue, 36 withDecay, 37 withSpring, 38 type WithSpringConfig, 39} from 'react-native-reanimated' 40import {SafeAreaView} from 'react-native-safe-area-context' 41import * as ScreenOrientation from 'expo-screen-orientation' 42import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 43import {Trans} from '@lingui/react/macro' 44 45import {type Dimensions} from '#/lib/media/types' 46import {colors, s} from '#/lib/styles' 47import {type Lightbox} from '#/state/lightbox' 48import {Button} from '#/view/com/util/forms/Button' 49import {Text} from '#/view/com/util/text/Text' 50import {ScrollView} from '#/view/com/util/Views' 51import {useTheme} from '#/alf' 52import {setSystemUITheme} from '#/alf/util/systemUI' 53import {IS_IOS} from '#/env' 54import {PlatformInfo} from '../../../../../modules/expo-bluesky-swiss-army' 55import { 56 type ImageSource, 57 type LightboxTransforms, 58 type Transform, 59} from './@types' 60import ImageDefaultHeader from './components/ImageDefaultHeader' 61import ImageItem from './components/ImageItem/ImageItem' 62 63type Rect = {x: number; y: number; width: number; height: number} 64 65const PORTRAIT_UP = ScreenOrientation.OrientationLock.PORTRAIT_UP 66const PIXEL_RATIO = PixelRatio.get() 67 68const SLOW_SPRING: WithSpringConfig = { 69 mass: IS_IOS ? 1.25 : 0.75, 70 damping: 300, 71 stiffness: 800, 72 restDisplacementThreshold: 0.001, 73} 74const FAST_SPRING: WithSpringConfig = { 75 mass: IS_IOS ? 1.25 : 0.75, 76 damping: 150, 77 stiffness: 900, 78 restDisplacementThreshold: 0.001, 79} 80 81function canAnimate(lightbox: Lightbox): boolean { 82 if (PlatformInfo.getIsReducedMotionEnabled()) { 83 return false 84 } 85 const img = lightbox.images[lightbox.index] 86 return !!img.thumbRect && !!(img.dimensions || img.thumbDimensions) 87} 88 89export default function ImageViewRoot({ 90 lightbox: nextLightbox, 91 onRequestClose, 92 onPressSave, 93 onPressShare, 94}: { 95 lightbox: Lightbox | null 96 onRequestClose: () => void 97 onPressSave: (uri: string) => void 98 onPressShare: (uri: string) => void 99}) { 100 'use no memo' 101 const ref = useAnimatedRef<View>() 102 const [activeLightbox, setActiveLightbox] = useState(nextLightbox) 103 const [orientation, setOrientation] = useState<'portrait' | 'landscape'>( 104 'portrait', 105 ) 106 const openProgress = useSharedValue(0) 107 const thumbRects = useSharedValue<Record<number, MeasuredDimensions | null>>( 108 {}, 109 ) 110 111 if (!activeLightbox && nextLightbox) { 112 setActiveLightbox(nextLightbox) 113 } 114 115 useEffect(() => { 116 if (!nextLightbox) { 117 return 118 } 119 120 const initial: Record<number, MeasuredDimensions | null> = {} 121 nextLightbox.images.forEach((img, i) => { 122 initial[i] = img.thumbRect ?? null 123 }) 124 thumbRects.set(initial) 125 126 const isAnimated = canAnimate(nextLightbox) 127 128 // https://github.com/software-mansion/react-native-reanimated/issues/6677 129 rAF_FIXED(() => { 130 openProgress.set(() => 131 isAnimated ? withClampedSpring(1, SLOW_SPRING) : 1, 132 ) 133 }) 134 return () => { 135 // https://github.com/software-mansion/react-native-reanimated/issues/6677 136 rAF_FIXED(() => { 137 openProgress.set(() => 138 isAnimated ? withClampedSpring(0, SLOW_SPRING) : 0, 139 ) 140 }) 141 } 142 }, [nextLightbox, openProgress, thumbRects]) 143 144 const onFullyClosed = useCallback(() => { 145 setActiveLightbox(null) 146 runOnUI(() => { 147 'worklet' 148 thumbRects.set({}) 149 })() 150 }, [thumbRects]) 151 152 useAnimatedReaction( 153 () => openProgress.get() === 0, 154 (isGone, wasGone) => { 155 if (isGone && !wasGone) { 156 runOnJS(onFullyClosed)() 157 } 158 }, 159 ) 160 161 // Delay the unlock until after we've finished the scale up animation. 162 // It's complicated to do the same for locking it back so we don't attempt that. 163 useAnimatedReaction( 164 () => openProgress.get() === 1, 165 (isOpen, wasOpen) => { 166 if (isOpen && !wasOpen) { 167 runOnJS(ScreenOrientation.unlockAsync)() 168 } else if (!isOpen && wasOpen) { 169 // default is PORTRAIT_UP - set via config plugin in app.config.js -sfn 170 runOnJS(ScreenOrientation.lockAsync)(PORTRAIT_UP) 171 } 172 }, 173 ) 174 175 const onFlyAway = useCallback(() => { 176 'worklet' 177 openProgress.set(0) 178 runOnJS(onRequestClose)() 179 }, [onRequestClose, openProgress]) 180 181 return ( 182 // Keep it always mounted to avoid flicker on the first frame. 183 <View 184 style={[styles.screen, !activeLightbox && styles.screenHidden]} 185 aria-modal 186 accessibilityViewIsModal 187 aria-hidden={!activeLightbox}> 188 <Animated.View 189 ref={ref} 190 style={{flex: 1}} 191 collapsable={false} 192 onLayout={e => { 193 const layout = e.nativeEvent.layout 194 setOrientation( 195 layout.height > layout.width ? 'portrait' : 'landscape', 196 ) 197 }}> 198 {activeLightbox && ( 199 <ImageView 200 key={activeLightbox.id + '-' + orientation} 201 lightbox={activeLightbox} 202 orientation={orientation} 203 onRequestClose={onRequestClose} 204 onPressSave={onPressSave} 205 onPressShare={onPressShare} 206 onFlyAway={onFlyAway} 207 safeAreaRef={ref} 208 openProgress={openProgress} 209 thumbRects={thumbRects} 210 /> 211 )} 212 </Animated.View> 213 </View> 214 ) 215} 216 217function ImageView({ 218 lightbox, 219 orientation, 220 onRequestClose, 221 onPressSave, 222 onPressShare, 223 onFlyAway, 224 safeAreaRef, 225 openProgress, 226 thumbRects, 227}: { 228 lightbox: Lightbox 229 orientation: 'portrait' | 'landscape' 230 onRequestClose: () => void 231 onPressSave: (uri: string) => void 232 onPressShare: (uri: string) => void 233 onFlyAway: () => void 234 safeAreaRef: AnimatedRef<View> 235 openProgress: SharedValue<number> 236 thumbRects: SharedValue<Record<number, MeasuredDimensions | null>> 237}) { 238 const {images, index: initialImageIndex} = lightbox 239 const isAnimated = useMemo(() => canAnimate(lightbox), [lightbox]) 240 const [isScaled, setIsScaled] = useState(false) 241 const [isDragging, setIsDragging] = useState(false) 242 const [imageIndex, setImageIndex] = useState(initialImageIndex) 243 const [showControls, setShowControls] = useState(true) 244 const [isAltExpanded, setIsAltExpanded] = useState(false) 245 const dismissSwipeTranslateY = useSharedValue(0) 246 const isFlyingAway = useSharedValue(false) 247 248 const containerStyle = useAnimatedStyle(() => { 249 if (openProgress.get() < 1) { 250 return { 251 pointerEvents: 'none', 252 opacity: isAnimated ? 1 : 0, 253 } 254 } 255 if (isFlyingAway.get()) { 256 return { 257 pointerEvents: 'none', 258 opacity: 1, 259 } 260 } 261 return {pointerEvents: 'auto', opacity: 1} 262 }) 263 264 const backdropStyle = useAnimatedStyle(() => { 265 const screenSize = measure(safeAreaRef) 266 let opacity = 1 267 const openProgressValue = openProgress.get() 268 if (openProgressValue < 1) { 269 opacity = Math.sqrt(openProgressValue) 270 } else if (screenSize && orientation === 'portrait') { 271 const dragProgress = Math.min( 272 Math.abs(dismissSwipeTranslateY.get()) / (screenSize.height / 2), 273 1, 274 ) 275 opacity -= dragProgress 276 } 277 const factor = IS_IOS ? 100 : 50 278 return { 279 opacity: Math.round(opacity * factor) / factor, 280 } 281 }) 282 283 const animatedHeaderStyle = useAnimatedStyle(() => { 284 const show = showControls && dismissSwipeTranslateY.get() === 0 285 return { 286 pointerEvents: show ? 'box-none' : 'none', 287 opacity: withClampedSpring( 288 show && openProgress.get() === 1 ? 1 : 0, 289 FAST_SPRING, 290 ), 291 transform: [ 292 { 293 translateY: withClampedSpring(show ? 0 : -30, FAST_SPRING), 294 }, 295 ], 296 } 297 }) 298 const animatedFooterStyle = useAnimatedStyle(() => { 299 const show = showControls && dismissSwipeTranslateY.get() === 0 300 return { 301 flexGrow: 1, 302 pointerEvents: show ? 'box-none' : 'none', 303 opacity: withClampedSpring( 304 show && openProgress.get() === 1 ? 1 : 0, 305 FAST_SPRING, 306 ), 307 transform: [ 308 { 309 translateY: withClampedSpring(show ? 0 : 30, FAST_SPRING), 310 }, 311 ], 312 } 313 }) 314 315 const handleRequestClose = useCallback(() => { 316 const activeRef = images[imageIndex]?.thumbRef 317 if (isAnimated && activeRef) { 318 runOnUI(() => { 319 'worklet' 320 const rect = measure(activeRef) 321 thumbRects.modify(rects => { 322 'worklet' 323 rects[imageIndex] = rect 324 return rects 325 }) 326 runOnJS(onRequestClose)() 327 })() 328 } else { 329 onRequestClose() 330 } 331 }, [isAnimated, images, imageIndex, thumbRects, onRequestClose]) 332 333 const onTap = useCallback(() => { 334 setShowControls(show => !show) 335 }, []) 336 337 const onZoom = useCallback((nextIsScaled: boolean) => { 338 setIsScaled(nextIsScaled) 339 if (nextIsScaled) { 340 setShowControls(false) 341 } 342 }, []) 343 344 useAnimatedReaction( 345 () => { 346 const screenSize = measure(safeAreaRef) 347 return ( 348 !screenSize || 349 Math.abs(dismissSwipeTranslateY.get()) > screenSize.height 350 ) 351 }, 352 (isOut, wasOut) => { 353 if (isOut && !wasOut) { 354 // Stop the animation from blocking the screen forever. 355 cancelAnimation(dismissSwipeTranslateY) 356 onFlyAway() 357 } 358 }, 359 ) 360 361 // style system ui on android 362 const t = useTheme() 363 useEffect(() => { 364 setSystemUITheme('lightbox', t) 365 return () => { 366 setSystemUITheme('theme', t) 367 } 368 }, [t]) 369 370 return ( 371 <Animated.View style={[styles.container, containerStyle]}> 372 <SystemBars 373 style={{statusBar: 'light', navigationBar: 'light'}} 374 hidden={{ 375 statusBar: isScaled || !showControls, 376 navigationBar: false, 377 }} 378 /> 379 <Animated.View 380 style={[styles.backdrop, backdropStyle]} 381 renderToHardwareTextureAndroid 382 /> 383 <PagerView 384 scrollEnabled={!isScaled} 385 initialPage={initialImageIndex} 386 onPageSelected={e => { 387 setImageIndex(e.nativeEvent.position) 388 setIsScaled(false) 389 }} 390 onPageScrollStateChanged={e => { 391 setIsDragging(e.nativeEvent.pageScrollState !== 'idle') 392 }} 393 overdrag={true} 394 style={styles.pager}> 395 {images.map((imageSrc, i) => ( 396 <View key={imageSrc.uri}> 397 <LightboxImage 398 onTap={onTap} 399 onZoom={onZoom} 400 imageSrc={imageSrc} 401 onRequestClose={handleRequestClose} 402 isScrollViewBeingDragged={isDragging} 403 showControls={showControls} 404 safeAreaRef={safeAreaRef} 405 isScaled={isScaled} 406 isFlyingAway={isFlyingAway} 407 isActive={i === imageIndex} 408 dismissSwipeTranslateY={dismissSwipeTranslateY} 409 openProgress={openProgress} 410 thumbRects={thumbRects} 411 imageIndex={i} 412 /> 413 </View> 414 ))} 415 </PagerView> 416 <View style={styles.controls}> 417 <Animated.View 418 style={animatedHeaderStyle} 419 renderToHardwareTextureAndroid> 420 <ImageDefaultHeader onRequestClose={handleRequestClose} /> 421 </Animated.View> 422 <Animated.View 423 style={animatedFooterStyle} 424 renderToHardwareTextureAndroid={!isAltExpanded}> 425 <LightboxFooter 426 images={images} 427 index={imageIndex} 428 isAltExpanded={isAltExpanded} 429 toggleAltExpanded={() => setIsAltExpanded(e => !e)} 430 onPressSave={onPressSave} 431 onPressShare={onPressShare} 432 /> 433 </Animated.View> 434 </View> 435 </Animated.View> 436 ) 437} 438 439function LightboxImage({ 440 imageSrc, 441 onTap, 442 onZoom, 443 onRequestClose, 444 isScrollViewBeingDragged, 445 isScaled, 446 isFlyingAway, 447 isActive, 448 showControls, 449 safeAreaRef, 450 openProgress, 451 dismissSwipeTranslateY, 452 thumbRects, 453 imageIndex, 454}: { 455 imageSrc: ImageSource 456 onRequestClose: () => void 457 onTap: () => void 458 onZoom: (scaled: boolean) => void 459 isScrollViewBeingDragged: boolean 460 isScaled: boolean 461 isActive: boolean 462 isFlyingAway: SharedValue<boolean> 463 showControls: boolean 464 safeAreaRef: AnimatedRef<View> 465 openProgress: SharedValue<number> 466 dismissSwipeTranslateY: SharedValue<number> 467 thumbRects: SharedValue<Record<number, MeasuredDimensions | null>> 468 imageIndex: number 469}) { 470 const [fetchedDims, setFetchedDims] = useState<Dimensions | null>(null) 471 const dims = fetchedDims ?? imageSrc.dimensions ?? imageSrc.thumbDimensions 472 let imageAspect: number | undefined 473 if (dims) { 474 imageAspect = dims.width / dims.height 475 if (Number.isNaN(imageAspect)) { 476 imageAspect = undefined 477 } 478 } 479 480 const { 481 width: widthDelayedForJSThreadOnly, 482 height: heightDelayedForJSThreadOnly, 483 } = useWindowDimensions() 484 const measureSafeArea = useCallback(() => { 485 'worklet' 486 let safeArea: Rect | null = measure(safeAreaRef) 487 if (!safeArea) { 488 if (_WORKLET) { 489 console.error('Expected to always be able to measure safe area.') 490 } 491 safeArea = { 492 x: 0, 493 y: 0, 494 width: widthDelayedForJSThreadOnly, 495 height: heightDelayedForJSThreadOnly, 496 } 497 } 498 return safeArea 499 }, [safeAreaRef, heightDelayedForJSThreadOnly, widthDelayedForJSThreadOnly]) 500 501 const {thumbRect: thumbRectJS, thumbBorderRadius} = imageSrc 502 const transforms = useDerivedValue<LightboxTransforms>(() => { 503 'worklet' 504 const safeArea = measureSafeArea() 505 const openProgressValue = openProgress.get() 506 const dismissTranslateY = 507 isActive && openProgressValue === 1 ? dismissSwipeTranslateY.get() : 0 508 509 if (openProgressValue === 0 && isFlyingAway.get()) { 510 return { 511 isHidden: true, 512 isResting: false, 513 borderRadius: 0, 514 scaleAndMoveTransform: [], 515 cropFrameTransform: [], 516 cropContentTransform: [], 517 } 518 } 519 520 if (isActive && imageAspect && openProgressValue < 1) { 521 let thumbRect 522 if (_WORKLET) { 523 thumbRect = thumbRects.get()[imageIndex] 524 } else { 525 thumbRect = thumbRectJS 526 } 527 if (thumbRect) { 528 return interpolateTransform( 529 openProgressValue, 530 thumbRect, 531 safeArea, 532 imageAspect, 533 thumbBorderRadius, 534 ) 535 } 536 } 537 return { 538 isHidden: false, 539 isResting: dismissTranslateY === 0, 540 borderRadius: 0, 541 scaleAndMoveTransform: [{translateY: dismissTranslateY}], 542 cropFrameTransform: [], 543 cropContentTransform: [], 544 } 545 }) 546 547 const dismissSwipePan = Gesture.Pan() 548 .enabled(isActive && !isScaled) 549 .activeOffsetY([-10, 10]) 550 .failOffsetX([-10, 10]) 551 .maxPointers(1) 552 .onUpdate(e => { 553 'worklet' 554 if (openProgress.get() !== 1 || isFlyingAway.get()) { 555 return 556 } 557 dismissSwipeTranslateY.set(e.translationY) 558 }) 559 .onEnd(e => { 560 'worklet' 561 if (openProgress.get() !== 1 || isFlyingAway.get()) { 562 return 563 } 564 if (Math.abs(e.velocityY) > 200) { 565 isFlyingAway.set(true) 566 if (dismissSwipeTranslateY.get() === 0) { 567 // HACK: If the initial value is 0, withDecay() animation doesn't start. 568 // This is a bug in Reanimated, but for now we'll work around it like this. 569 dismissSwipeTranslateY.set(1) 570 } 571 dismissSwipeTranslateY.set(() => { 572 'worklet' 573 return withDecay({ 574 velocity: e.velocityY, 575 velocityFactor: Math.max(3500 / Math.abs(e.velocityY), 1), // Speed up if it's too slow. 576 deceleration: 1, // Danger! This relies on the reaction below stopping it. 577 reduceMotion: ReduceMotion.Never, // If this animation doesn't run, the image gets stuck - therefore override Reduce Motion 578 }) 579 }) 580 } else { 581 dismissSwipeTranslateY.set(() => { 582 'worklet' 583 return withSpring(0, { 584 stiffness: 700, 585 damping: 50, 586 reduceMotion: ReduceMotion.Never, 587 }) 588 }) 589 } 590 }) 591 592 return ( 593 <ImageItem 594 imageSrc={imageSrc} 595 onTap={onTap} 596 onZoom={onZoom} 597 onRequestClose={onRequestClose} 598 onLoad={setFetchedDims} 599 isScrollViewBeingDragged={isScrollViewBeingDragged} 600 showControls={showControls} 601 measureSafeArea={measureSafeArea} 602 imageAspect={imageAspect} 603 imageDimensions={dims ?? undefined} 604 dismissSwipePan={dismissSwipePan} 605 transforms={transforms} 606 /> 607 ) 608} 609 610function LightboxFooter({ 611 images, 612 index, 613 isAltExpanded, 614 toggleAltExpanded, 615 onPressSave, 616 onPressShare, 617}: { 618 images: ImageSource[] 619 index: number 620 isAltExpanded: boolean 621 toggleAltExpanded: () => void 622 onPressSave: (uri: string) => void 623 onPressShare: (uri: string) => void 624}) { 625 const {alt: altText, uri} = images[index] 626 const isMomentumScrolling = useRef(false) 627 return ( 628 <ScrollView 629 style={styles.footerScrollView} 630 scrollEnabled={isAltExpanded} 631 onMomentumScrollBegin={() => { 632 isMomentumScrolling.current = true 633 }} 634 onMomentumScrollEnd={() => { 635 isMomentumScrolling.current = false 636 }} 637 contentContainerStyle={{ 638 paddingVertical: 12, 639 paddingHorizontal: 24, 640 }}> 641 <SafeAreaView edges={['bottom']}> 642 {altText ? ( 643 <View accessibilityRole="button" style={styles.footerText}> 644 <Text 645 style={{color: colors.gray3}} 646 numberOfLines={isAltExpanded ? undefined : 3} 647 selectable 648 onPress={() => { 649 if (isMomentumScrolling.current) { 650 return 651 } 652 LayoutAnimation.configureNext({ 653 duration: 450, 654 update: {type: 'spring', springDamping: 1}, 655 }) 656 toggleAltExpanded() 657 }} 658 onLongPress={() => {}} 659 emoji> 660 {altText} 661 </Text> 662 </View> 663 ) : null} 664 <View style={styles.footerBtns}> 665 <Button 666 type="primary-outline" 667 style={styles.footerBtn} 668 onPress={() => onPressSave(uri)}> 669 <FontAwesomeIcon icon={['far', 'floppy-disk']} style={s.white} /> 670 <Text type="xl" style={s.white}> 671 <Trans context="action">Save</Trans> 672 </Text> 673 </Button> 674 <Button 675 type="primary-outline" 676 style={styles.footerBtn} 677 onPress={() => onPressShare(uri)}> 678 <FontAwesomeIcon icon="arrow-up-from-bracket" style={s.white} /> 679 <Text type="xl" style={s.white}> 680 <Trans context="action">Share</Trans> 681 </Text> 682 </Button> 683 </View> 684 </SafeAreaView> 685 </ScrollView> 686 ) 687} 688 689const styles = StyleSheet.create({ 690 screen: { 691 position: 'absolute', 692 top: 0, 693 left: 0, 694 bottom: 0, 695 right: 0, 696 }, 697 screenHidden: { 698 opacity: 0, 699 pointerEvents: 'none', 700 }, 701 container: { 702 flex: 1, 703 }, 704 backdrop: { 705 backgroundColor: '#000', 706 position: 'absolute', 707 top: 0, 708 bottom: 0, 709 left: 0, 710 right: 0, 711 }, 712 controls: { 713 position: 'absolute', 714 top: 0, 715 bottom: 0, 716 left: 0, 717 right: 0, 718 gap: 20, 719 zIndex: 1, 720 pointerEvents: 'box-none', 721 }, 722 pager: { 723 flex: 1, 724 }, 725 header: { 726 position: 'absolute', 727 width: '100%', 728 top: 0, 729 pointerEvents: 'box-none', 730 }, 731 footer: { 732 position: 'absolute', 733 width: '100%', 734 maxHeight: '100%', 735 bottom: 0, 736 }, 737 footerScrollView: { 738 backgroundColor: '#000d', 739 flex: 1, 740 position: 'absolute', 741 bottom: 0, 742 width: '100%', 743 maxHeight: '100%', 744 }, 745 footerText: { 746 paddingBottom: IS_IOS ? 20 : 16, 747 }, 748 footerBtns: { 749 flexDirection: 'row', 750 justifyContent: 'center', 751 gap: 8, 752 }, 753 footerBtn: { 754 flexDirection: 'row', 755 alignItems: 'center', 756 gap: 8, 757 backgroundColor: 'transparent', 758 borderColor: colors.white, 759 }, 760}) 761 762function interpolatePx( 763 px: number, 764 inputRange: readonly number[], 765 outputRange: readonly number[], 766) { 767 'worklet' 768 const value = interpolate(px, inputRange, outputRange) 769 return Math.round(value * PIXEL_RATIO) / PIXEL_RATIO 770} 771 772function interpolateTransform( 773 progress: number, 774 thumbnailDims: { 775 pageX: number 776 width: number 777 pageY: number 778 height: number 779 }, 780 safeArea: {width: number; height: number; x: number; y: number}, 781 imageAspect: number, 782 thumbBorderRadius?: number, 783): { 784 scaleAndMoveTransform: Transform 785 cropFrameTransform: Transform 786 cropContentTransform: Transform 787 borderRadius: number 788 isResting: boolean 789 isHidden: boolean 790} { 791 'worklet' 792 const thumbAspect = thumbnailDims.width / thumbnailDims.height 793 let uncroppedInitialWidth 794 let uncroppedInitialHeight 795 if (imageAspect > thumbAspect) { 796 uncroppedInitialWidth = thumbnailDims.height * imageAspect 797 uncroppedInitialHeight = thumbnailDims.height 798 } else { 799 uncroppedInitialWidth = thumbnailDims.width 800 uncroppedInitialHeight = thumbnailDims.width / imageAspect 801 } 802 const safeAreaAspect = safeArea.width / safeArea.height 803 let finalWidth 804 let finalHeight 805 if (safeAreaAspect > imageAspect) { 806 finalWidth = safeArea.height * imageAspect 807 finalHeight = safeArea.height 808 } else { 809 finalWidth = safeArea.width 810 finalHeight = safeArea.width / imageAspect 811 } 812 const initialScale = Math.min( 813 uncroppedInitialWidth / finalWidth, 814 uncroppedInitialHeight / finalHeight, 815 ) 816 const croppedFinalWidth = thumbnailDims.width / initialScale 817 const croppedFinalHeight = thumbnailDims.height / initialScale 818 const screenCenterX = safeArea.width / 2 819 const screenCenterY = safeArea.height / 2 820 const thumbnailSafeAreaX = thumbnailDims.pageX - safeArea.x 821 const thumbnailSafeAreaY = thumbnailDims.pageY - safeArea.y 822 const thumbnailCenterX = thumbnailSafeAreaX + thumbnailDims.width / 2 823 const thumbnailCenterY = thumbnailSafeAreaY + thumbnailDims.height / 2 824 const initialTranslateX = thumbnailCenterX - screenCenterX 825 const initialTranslateY = thumbnailCenterY - screenCenterY 826 const scale = interpolate(progress, [0, 1], [initialScale, 1]) 827 const translateX = interpolatePx(progress, [0, 1], [initialTranslateX, 0]) 828 const translateY = interpolatePx(progress, [0, 1], [initialTranslateY, 0]) 829 const cropScaleX = interpolate( 830 progress, 831 [0, 1], 832 [croppedFinalWidth / finalWidth, 1], 833 ) 834 const cropScaleY = interpolate( 835 progress, 836 [0, 1], 837 [croppedFinalHeight / finalHeight, 1], 838 ) 839 // The border radius in the source thumbnail needs to be scaled to account 840 // for the crop frame and overall scale so it visually matches at progress=0. 841 const sourceBorderRadius = thumbBorderRadius ?? 0 842 const initialCropScaleX = croppedFinalWidth / finalWidth 843 const borderRadius = interpolate( 844 progress, 845 [0, 1], 846 [sourceBorderRadius / (initialScale * initialCropScaleX), 0], 847 ) 848 849 return { 850 isHidden: false, 851 isResting: progress === 1, 852 scaleAndMoveTransform: [{translateX}, {translateY}, {scale}], 853 cropFrameTransform: [{scaleX: cropScaleX}, {scaleY: cropScaleY}], 854 cropContentTransform: [{scaleX: 1 / cropScaleX}, {scaleY: 1 / cropScaleY}], 855 borderRadius, 856 } 857} 858 859function withClampedSpring(value: any, config: WithSpringConfig) { 860 'worklet' 861 return withSpring(value, {...config, overshootClamping: true}) 862} 863 864// We have to do this because we can't trust RN's rAF to fire in order. 865// https://github.com/facebook/react-native/issues/48005 866let isFrameScheduled = false 867let pendingFrameCallbacks: Array<() => void> = [] 868function rAF_FIXED(callback: () => void) { 869 pendingFrameCallbacks.push(callback) 870 if (!isFrameScheduled) { 871 isFrameScheduled = true 872 requestAnimationFrame(() => { 873 const callbacks = pendingFrameCallbacks.slice() 874 isFrameScheduled = false 875 pendingFrameCallbacks = [] 876 let hasError = false 877 let error 878 for (let i = 0; i < callbacks.length; i++) { 879 try { 880 callbacks[i]() 881 } catch (e) { 882 hasError = true 883 error = e 884 } 885 } 886 if (hasError) { 887 throw error 888 } 889 }) 890 } 891}