diff --git a/app.config.js b/app.config.js index fed1b17a31..ecdbffb6c1 100644 --- a/app.config.js +++ b/app.config.js @@ -99,6 +99,8 @@ module.exports = function (config) { dark: DARK_SPLASH_CONFIG, }, entitlements: { + 'com.apple.developer.kernel.increased-memory-limit': true, + 'com.apple.developer.kernel.extended-virtual-addressing': true, 'com.apple.security.application-groups': 'group.app.bsky', }, privacyManifests: { diff --git a/src/components/dms/ActionsWrapper.tsx b/src/components/dms/ActionsWrapper.tsx index 9b06bd0b20..b77516e7b7 100644 --- a/src/components/dms/ActionsWrapper.tsx +++ b/src/components/dms/ActionsWrapper.tsx @@ -53,9 +53,11 @@ export function ActionsWrapper({ .numberOfTaps(2) .hitSlop(HITSLOP_10) .onEnd(open) + .runOnJS(true) const pressAndHoldGesture = Gesture.LongPress() .onStart(() => { + 'worklet' scale.value = withTiming(1.05, {duration: 200}, finished => { if (!finished) return runOnJS(open)() @@ -65,7 +67,6 @@ export function ActionsWrapper({ .onTouchesUp(shrink) .onTouchesMove(shrink) .cancelsTouchesInView(false) - .runOnJS(true) const composedGestures = Gesture.Exclusive( doubleTapGesture, diff --git a/src/screens/Messages/Conversation.tsx b/src/screens/Messages/Conversation.tsx index 651915738a..e2e646a3d3 100644 --- a/src/screens/Messages/Conversation.tsx +++ b/src/screens/Messages/Conversation.tsx @@ -127,9 +127,7 @@ function Inner() { setHasScrolled={setHasScrolled} /> ) : ( - <> - - + )} {!readyToShow && ( layoutHeight.value) { convoState.fetchMessageHistory() } - }, [convoState, hasScrolled, layoutHeight.value]) + }, [convoState, hasScrolled, layoutHeight]) const onScroll = React.useCallback( (e: ReanimatedScrollEvent) => { @@ -374,7 +374,7 @@ export function MessagesList({ }, [ flatListRef, - keyboardIsOpening.value, + keyboardIsOpening, layoutScrollWithoutAnimation, layoutHeight, ], diff --git a/src/screens/Profile/Header/Shell.tsx b/src/screens/Profile/Header/Shell.tsx index 925066d72e..fe325c1e5f 100644 --- a/src/screens/Profile/Header/Shell.tsx +++ b/src/screens/Profile/Header/Shell.tsx @@ -64,6 +64,7 @@ let ProfileHeaderShell = ({ height: 1000, width: 1000, }, + type: 'circle-avi', }, ], index: 0, diff --git a/src/view/com/lightbox/ImageViewing/@types/index.ts b/src/view/com/lightbox/ImageViewing/@types/index.ts index f5ab8bba9a..dc636a4495 100644 --- a/src/view/com/lightbox/ImageViewing/@types/index.ts +++ b/src/view/com/lightbox/ImageViewing/@types/index.ts @@ -21,4 +21,5 @@ export type ImageSource = { thumbUri: string alt?: string dimensions: Dimensions | null + type: 'image' | 'circle-avi' | 'rect-avi' } diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx index ed6020000a..f882dcf9eb 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx @@ -1,6 +1,10 @@ import React, {useState} from 'react' -import {ActivityIndicator, StyleSheet, View} from 'react-native' -import {Gesture, GestureDetector} from 'react-native-gesture-handler' +import {ActivityIndicator, StyleProp, StyleSheet, View} from 'react-native' +import { + Gesture, + GestureDetector, + PanGesture, +} from 'react-native-gesture-handler' import Animated, { AnimatedRef, measure, @@ -9,12 +13,10 @@ import Animated, { useAnimatedRef, useAnimatedStyle, useSharedValue, - withDecay, withSpring, } from 'react-native-reanimated' -import {Image} from 'expo-image' +import {Image, ImageStyle} from 'expo-image' -import {useImageDimensions} from '#/lib/media/image-sizes' import type {Dimensions as ImageDimensions, ImageSource} from '../../@types' import { applyRounding, @@ -26,6 +28,8 @@ import { TransformMatrix, } from '../../transforms' +const AnimatedImage = Animated.createAnimatedComponent(Image) + const MIN_SCREEN_ZOOM = 2 const MAX_ORIGINAL_IMAGE_ZOOM = 2 @@ -39,26 +43,28 @@ type Props = { isScrollViewBeingDragged: boolean showControls: boolean safeAreaRef: AnimatedRef + imageAspect: number | undefined + imageDimensions: ImageDimensions | undefined + imageStyle: StyleProp + dismissSwipePan: PanGesture } const ImageItem = ({ imageSrc, onTap, onZoom, - onRequestClose, isScrollViewBeingDragged, safeAreaRef, + imageAspect, + imageDimensions, + imageStyle, + dismissSwipePan, }: Props) => { const [isScaled, setIsScaled] = useState(false) - const [imageAspect, imageDimensions] = useImageDimensions({ - src: imageSrc.uri, - knownDimensions: imageSrc.dimensions, - }) const committedTransform = useSharedValue(initialTransform) const panTranslation = useSharedValue({x: 0, y: 0}) const pinchOrigin = useSharedValue({x: 0, y: 0}) const pinchScale = useSharedValue(1) const pinchTranslation = useSharedValue({x: 0, y: 0}) - const dismissSwipeTranslateY = useSharedValue(0) const containerRef = useAnimatedRef() // Keep track of when we're entering or leaving scaled rendering. @@ -97,19 +103,8 @@ const ImageItem = ({ prependPinch(t, pinchScale.value, pinchOrigin.value, pinchTranslation.value) prependTransform(t, committedTransform.value) const [translateX, translateY, scale] = readTransform(t) - - const dismissDistance = dismissSwipeTranslateY.value - const screenSize = measure(safeAreaRef) - const dismissProgress = screenSize - ? Math.min(Math.abs(dismissDistance) / (screenSize.height / 2), 1) - : 0 return { - opacity: 1 - dismissProgress, - transform: [ - {translateX}, - {translateY: translateY + dismissDistance}, - {scale}, - ], + transform: [{translateX}, {translateY: translateY}, {scale}], } }) @@ -307,27 +302,10 @@ const ImageItem = ({ committedTransform.value = withClampedSpring(finalTransform) }) - const dismissSwipePan = Gesture.Pan() - .enabled(!isScaled) - .activeOffsetY([-10, 10]) - .failOffsetX([-10, 10]) - .maxPointers(1) - .onUpdate(e => { - 'worklet' - dismissSwipeTranslateY.value = e.translationY - }) - .onEnd(e => { - 'worklet' - if (Math.abs(e.velocityY) > 1000) { - dismissSwipeTranslateY.value = withDecay({velocity: e.velocityY}) - runOnJS(onRequestClose)() - } else { - dismissSwipeTranslateY.value = withSpring(0, { - stiffness: 700, - damping: 50, - }) - } - }) + const innerStyle = useAnimatedStyle(() => ({ + width: '100%', + aspectRatio: imageAspect, + })) const composedGesture = isScrollViewBeingDragged ? // If the parent is not at rest, provide a no-op gesture. @@ -339,27 +317,32 @@ const ImageItem = ({ singleTap, ) + const type = imageSrc.type + const borderRadius = + type === 'circle-avi' ? 1e5 : type === 'rect-avi' ? 20 : 0 return ( - - - - - - + + + + + + + + ) } @@ -367,9 +350,7 @@ const styles = StyleSheet.create({ container: { height: '100%', overflow: 'hidden', - }, - image: { - flex: 1, + justifyContent: 'center', }, loading: { position: 'absolute', diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx index a17d4fe66c..e876479a39 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx @@ -7,26 +7,27 @@ */ import React, {useState} from 'react' -import {ActivityIndicator, StyleSheet, View} from 'react-native' -import {Gesture, GestureDetector} from 'react-native-gesture-handler' +import {ActivityIndicator, StyleProp, StyleSheet, View} from 'react-native' +import { + Gesture, + GestureDetector, + PanGesture, +} from 'react-native-gesture-handler' import Animated, { AnimatedRef, - interpolate, measure, runOnJS, useAnimatedRef, useAnimatedStyle, - useSharedValue, } from 'react-native-reanimated' import {useSafeAreaFrame} from 'react-native-safe-area-context' -import {Image} from 'expo-image' +import {Image, ImageStyle} from 'expo-image' import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' -import {useImageDimensions} from '#/lib/media/image-sizes' -import {ImageSource} from '../../@types' +import {Dimensions as ImageDimensions, ImageSource} from '../../@types' + +const AnimatedImage = Animated.createAnimatedComponent(Image) -const SWIPE_CLOSE_OFFSET = 75 -const SWIPE_CLOSE_VELOCITY = 1 const MAX_ORIGINAL_IMAGE_ZOOM = 2 const MIN_SCREEN_ZOOM = 2 @@ -38,24 +39,26 @@ type Props = { isScrollViewBeingDragged: boolean showControls: boolean safeAreaRef: AnimatedRef + imageAspect: number | undefined + imageDimensions: ImageDimensions | undefined + imageStyle: StyleProp + dismissSwipePan: PanGesture } const ImageItem = ({ imageSrc, onTap, onZoom, - onRequestClose, showControls, safeAreaRef, + imageAspect, + imageDimensions, + imageStyle, + dismissSwipePan, }: Props) => { const scrollViewRef = useAnimatedRef() - const translationY = useSharedValue(0) const [scaled, setScaled] = useState(false) const screenSizeDelayedForJSThreadOnly = useSafeAreaFrame() - const [imageAspect, imageDimensions] = useImageDimensions({ - src: imageSrc.uri, - knownDimensions: imageSrc.dimensions, - }) const maxZoomScale = Math.max( MIN_SCREEN_ZOOM, imageDimensions @@ -65,33 +68,21 @@ const ImageItem = ({ ) const animatedStyle = useAnimatedStyle(() => { + const screenSize = measure(safeAreaRef) ?? screenSizeDelayedForJSThreadOnly return { - flex: 1, - opacity: interpolate( - translationY.value, - [-SWIPE_CLOSE_OFFSET, 0, SWIPE_CLOSE_OFFSET], - [0.5, 1, 0.5], - ), + width: screenSize.width, + maxHeight: screenSize.height, + alignSelf: 'center', + aspectRatio: imageAspect, } }) const scrollHandler = useAnimatedScrollHandler({ onScroll(e) { - const nextIsScaled = e.zoomScale > 1 - translationY.value = nextIsScaled ? 0 : e.contentOffset.y - if (scaled !== nextIsScaled) { - runOnJS(handleZoom)(nextIsScaled) - } - }, - onEndDrag(e) { - const velocityY = e.velocity?.y ?? 0 const nextIsScaled = e.zoomScale > 1 if (scaled !== nextIsScaled) { runOnJS(handleZoom)(nextIsScaled) } - if (!nextIsScaled && Math.abs(velocityY) > SWIPE_CLOSE_VELOCITY) { - runOnJS(onRequestClose)() - } }, }) @@ -146,8 +137,15 @@ const ImageItem = ({ runOnJS(zoomTo)(nextZoomRect) }) - const composedGesture = Gesture.Exclusive(doubleTap, singleTap) + const composedGesture = Gesture.Exclusive( + dismissSwipePan, + doubleTap, + singleTap, + ) + const type = imageSrc.type + const borderRadius = + type === 'circle-avi' ? 1e5 : type === 'rect-avi' ? 20 : 0 return ( - - - - + bounces={scaled} + bouncesZoom={true} + style={imageStyle} + centerContent> + + ) @@ -186,9 +185,6 @@ const styles = StyleSheet.create({ right: 0, bottom: 0, }, - scrollContainer: { - flex: 1, - }, image: { flex: 1, }, diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx index 383bec9951..1cd6b00204 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx @@ -1,10 +1,11 @@ // default implementation fallback for web import React from 'react' -import {View} from 'react-native' +import {ImageStyle, StyleProp, View} from 'react-native' +import {PanGesture} from 'react-native-gesture-handler' import {AnimatedRef} from 'react-native-reanimated' -import {ImageSource} from '../../@types' +import {Dimensions as ImageDimensions, ImageSource} from '../../@types' type Props = { imageSrc: ImageSource @@ -14,6 +15,10 @@ type Props = { isScrollViewBeingDragged: boolean showControls: boolean safeAreaRef: AnimatedRef + imageAspect: number | undefined + imageDimensions: ImageDimensions | undefined + imageStyle: StyleProp + dismissSwipePan: PanGesture } const ImageItem = (_props: Props) => { diff --git a/src/view/com/lightbox/ImageViewing/index.tsx b/src/view/com/lightbox/ImageViewing/index.tsx index 791701bca8..7a3a506914 100644 --- a/src/view/com/lightbox/ImageViewing/index.tsx +++ b/src/view/com/lightbox/ImageViewing/index.tsx @@ -10,17 +10,26 @@ import React, {useCallback, useState} from 'react' import {LayoutAnimation, Platform, StyleSheet, View} from 'react-native' +import {Gesture} from 'react-native-gesture-handler' import PagerView from 'react-native-pager-view' import Animated, { AnimatedRef, + cancelAnimation, + measure, + runOnJS, + SharedValue, + useAnimatedReaction, useAnimatedRef, useAnimatedStyle, + useSharedValue, + withDecay, withSpring, } from 'react-native-reanimated' import {Edge, SafeAreaView} from 'react-native-safe-area-context' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {Trans} from '@lingui/macro' +import {useImageDimensions} from '#/lib/media/image-sizes' import {colors, s} from '#/lib/styles' import {isIOS} from '#/platform/detection' import {Lightbox} from '#/state/lightbox' @@ -90,26 +99,55 @@ function ImageView({ const [isDragging, setIsDragging] = useState(false) const [imageIndex, setImageIndex] = useState(initialImageIndex) const [showControls, setShowControls] = useState(true) + const [isAltExpanded, setAltExpanded] = React.useState(false) + const dismissSwipeTranslateY = useSharedValue(0) + const isFlyingAway = useSharedValue(false) - const animatedHeaderStyle = useAnimatedStyle(() => ({ - pointerEvents: showControls ? 'box-none' : 'none', - opacity: withClampedSpring(showControls ? 1 : 0), - transform: [ - { - translateY: withClampedSpring(showControls ? 0 : -30), - }, - ], - })) - const animatedFooterStyle = useAnimatedStyle(() => ({ - flexGrow: 1, - pointerEvents: showControls ? 'box-none' : 'none', - opacity: withClampedSpring(showControls ? 1 : 0), - transform: [ - { - translateY: withClampedSpring(showControls ? 0 : 30), - }, - ], - })) + const containerStyle = useAnimatedStyle(() => { + if (isFlyingAway.value) { + return {pointerEvents: 'none'} + } + return {pointerEvents: 'auto'} + }) + const backdropStyle = useAnimatedStyle(() => { + const screenSize = measure(safeAreaRef) + let opacity = 1 + if (screenSize) { + const dragProgress = Math.min( + Math.abs(dismissSwipeTranslateY.value) / (screenSize.height / 2), + 1, + ) + opacity -= dragProgress + } + return { + opacity, + } + }) + const animatedHeaderStyle = useAnimatedStyle(() => { + const show = showControls && dismissSwipeTranslateY.value === 0 + return { + pointerEvents: show ? 'box-none' : 'none', + opacity: withClampedSpring(show ? 1 : 0), + transform: [ + { + translateY: withClampedSpring(show ? 0 : -30), + }, + ], + } + }) + const animatedFooterStyle = useAnimatedStyle(() => { + const show = showControls && dismissSwipeTranslateY.value === 0 + return { + flexGrow: 1, + pointerEvents: show ? 'box-none' : 'none', + opacity: withClampedSpring(show ? 1 : 0), + transform: [ + { + translateY: withClampedSpring(show ? 0 : 30), + }, + ], + } + }) const onTap = useCallback(() => { setShowControls(show => !show) @@ -123,7 +161,11 @@ function ImageView({ }, []) return ( - + + - {images.map(imageSrc => ( + {images.map((imageSrc, i) => ( - ))} - + - + setAltExpanded(e => !e)} onPressSave={onPressSave} onPressShare={onPressShare} /> - + + ) +} + +function LightboxImage({ + imageSrc, + onTap, + onZoom, + onRequestClose, + isScrollViewBeingDragged, + isScaled, + isFlyingAway, + isActive, + showControls, + safeAreaRef, + dismissSwipeTranslateY, +}: { + imageSrc: ImageSource + onRequestClose: () => void + onTap: () => void + onZoom: (scaled: boolean) => void + isScrollViewBeingDragged: boolean + isScaled: boolean + isActive: boolean + isFlyingAway: SharedValue + showControls: boolean + safeAreaRef: AnimatedRef + dismissSwipeTranslateY: SharedValue +}) { + const [imageAspect, imageDimensions] = useImageDimensions({ + src: imageSrc.uri, + knownDimensions: imageSrc.dimensions, + }) + + const dismissSwipePan = Gesture.Pan() + .enabled(isActive && !isScaled) + .activeOffsetY([-10, 10]) + .failOffsetX([-10, 10]) + .maxPointers(1) + .onUpdate(e => { + 'worklet' + dismissSwipeTranslateY.value = e.translationY + }) + .onEnd(e => { + 'worklet' + if (Math.abs(e.velocityY) > 1000) { + isFlyingAway.value = true + dismissSwipeTranslateY.value = withDecay({ + velocity: e.velocityY, + velocityFactor: Math.max(3000 / Math.abs(e.velocityY), 1), // Speed up if it's too slow. + deceleration: 1, // Danger! This relies on the reaction below stopping it. + }) + } else { + dismissSwipeTranslateY.value = withSpring(0, { + stiffness: 700, + damping: 50, + }) + } + }) + useAnimatedReaction( + () => { + const screenSize = measure(safeAreaRef) + return ( + !screenSize || + Math.abs(dismissSwipeTranslateY.value) > screenSize.height + ) + }, + (isOut, wasOut) => { + if (isOut && !wasOut) { + // Stop the animation from blocking the screen forever. + cancelAnimation(dismissSwipeTranslateY) + runOnJS(onRequestClose)() + } + }, + ) + + const imageStyle = useAnimatedStyle(() => { + return { + transform: [{translateY: dismissSwipeTranslateY.value}], + } + }) + return ( + ) } function LightboxFooter({ images, index, + isAltExpanded, + toggleAltExpanded, onPressSave, onPressShare, }: { images: ImageSource[] index: number + isAltExpanded: boolean + toggleAltExpanded: () => void onPressSave: (uri: string) => void onPressShare: (uri: string) => void }) { const {alt: altText, uri} = images[index] - const [isAltExpanded, setAltExpanded] = React.useState(false) const isMomentumScrolling = React.useRef(false) return ( !prev) + toggleAltExpanded() }} onLongPress={() => {}}> {altText} @@ -256,7 +405,14 @@ const styles = StyleSheet.create({ }, container: { flex: 1, + }, + backdrop: { backgroundColor: '#000', + position: 'absolute', + top: 0, + bottom: 0, + left: 0, + right: 0, }, controls: { position: 'absolute', diff --git a/src/view/com/lightbox/Lightbox.web.tsx b/src/view/com/lightbox/Lightbox.web.tsx index 2ba7d06cc5..f9b147b297 100644 --- a/src/view/com/lightbox/Lightbox.web.tsx +++ b/src/view/com/lightbox/Lightbox.web.tsx @@ -21,13 +21,9 @@ import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {colors, s} from '#/lib/styles' import {useLightbox, useLightboxControls} from '#/state/lightbox' import {Text} from '../util/text/Text' +import {ImageSource} from './ImageViewing/@types' import ImageDefaultHeader from './ImageViewing/components/ImageDefaultHeader' -interface Img { - uri: string - alt?: string -} - export function Lightbox() { const {activeLightbox} = useLightbox() const {closeLightbox} = useLightboxControls() @@ -54,7 +50,7 @@ function LightboxInner({ initialIndex = 0, onClose, }: { - imgs: Img[] + imgs: ImageSource[] initialIndex: number onClose: () => void }) { @@ -101,6 +97,8 @@ function LightboxInner({ return isTabletOrDesktop ? 32 : 24 }, [isTabletOrDesktop]) + const img = imgs[index] + const isAvi = img.type === 'circle-avi' || img.type === 'rect-avi' return ( - - - {canGoLeft && ( - - - - )} - {canGoRight && ( - - - - )} - + {isAvi ? ( + + {img.alt} + + ) : ( + + + {canGoLeft && ( + + + + )} + {canGoRight && ( + + + + )} + + )} - {imgs[index].alt ? ( + {img.alt ? ( - {imgs[index].alt} + {img.alt} @@ -203,6 +222,19 @@ const styles = StyleSheet.create({ height: '100%', resizeMode: 'contain', }, + aviCenterer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + avi: { + // @ts-ignore web-only + maxWidth: `calc(min(400px, 100vw))`, + // @ts-ignore web-only + maxHeight: `calc(min(400px, 100vh))`, + padding: 16, + boxSizing: 'border-box', + }, icon: { color: colors.white, }, diff --git a/src/view/com/profile/ProfileSubpageHeader.tsx b/src/view/com/profile/ProfileSubpageHeader.tsx index b712b346b5..5208224c50 100644 --- a/src/view/com/profile/ProfileSubpageHeader.tsx +++ b/src/view/com/profile/ProfileSubpageHeader.tsx @@ -80,6 +80,7 @@ export function ProfileSubpageHeader({ height: 1000, width: 1000, }, + type: 'rect-avi', }, ], index: 0, diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx index d686d2bd32..ea0badab00 100644 --- a/src/view/com/util/post-embeds/index.tsx +++ b/src/view/com/util/post-embeds/index.tsx @@ -152,7 +152,10 @@ export function PostEmbeds({ thumbDims: MeasuredDimensions | null, ) => { openLightbox({ - images: items, + images: items.map(item => ({ + ...item, + type: 'image', + })), index, thumbDims, }) diff --git a/src/view/shell/desktop/Feeds.tsx b/src/view/shell/desktop/Feeds.tsx index 2f5f954274..bb6b8cadd3 100644 --- a/src/view/shell/desktop/Feeds.tsx +++ b/src/view/shell/desktop/Feeds.tsx @@ -41,7 +41,7 @@ export function DesktopFeeds() { onPress={() => { setSelectedFeed(feed) navigation.navigate('Home') - if (feed === selectedFeed) { + if (route.name === 'Home' && feed === selectedFeed) { emitSoftReset() } }}