From 5d0610d419906be0ef2c7c7ab0d1f66c366f3aed Mon Sep 17 00:00:00 2001 From: dan Date: Fri, 8 Nov 2024 02:49:32 +0000 Subject: [PATCH 01/35] [Lightbox] New dismiss gesture (#6135) * Make iOS scrollview bounded to the image I've had to remove the dismiss handling because the scroll view no longer scrolls at rest. * Fix double-tap not working right after a vertical swipe It seems like for some reason the vertical swipe is still being handled by the scroll view, so double tap gets eaten while it's "coming back". But you don't really see it moving. Weird. * Add an intermediate LightboxImage component * Hoist useImageDimensions up * Implement xplat dismiss gesture This is now shared between platforms, letting us animate the backdrop and add a consistent "fly away" behavior. * Optimize Android compositing perf * Fix supertall images For example, https://bsky.app/profile/schlagteslinks.bsky.social/post/3l7y4l6yur72e * Fix oopsie --- .../ImageItem/ImageItem.android.tsx | 105 ++++----- .../components/ImageItem/ImageItem.ios.tsx | 95 ++++---- .../components/ImageItem/ImageItem.tsx | 9 +- src/view/com/lightbox/ImageViewing/index.tsx | 210 +++++++++++++++--- 4 files changed, 274 insertions(+), 145 deletions(-) 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..17c386771c 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,28 +302,6 @@ 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 composedGesture = isScrollViewBeingDragged ? // If the parent is not at rest, provide a no-op gesture. Gesture.Manual() @@ -340,26 +313,28 @@ const ImageItem = ({ ) return ( - - - - - - + + + + + + + + ) } 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..b4bbfb4d53 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,34 +68,22 @@ 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)() - } - }, }) function handleZoom(nextIsScaled: boolean) { @@ -146,7 +137,11 @@ const ImageItem = ({ runOnJS(zoomTo)(nextZoomRect) }) - const composedGesture = Gesture.Exclusive(doubleTap, singleTap) + const composedGesture = Gesture.Exclusive( + dismissSwipePan, + doubleTap, + singleTap, + ) return ( @@ -158,21 +153,22 @@ const ImageItem = ({ showsVerticalScrollIndicator={false} maximumZoomScale={maxZoomScale} onScroll={scrollHandler} - contentContainerStyle={styles.scrollContainer}> - - - - + bounces={scaled} + bouncesZoom={true} + style={imageStyle} + centerContent> + + ) @@ -186,9 +182,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', From 22dd4947f7d88166350c13367f2af7a51a55a36b Mon Sep 17 00:00:00 2001 From: dan Date: Fri, 8 Nov 2024 02:52:03 +0000 Subject: [PATCH 02/35] [Lightbox] Add border radius to avatars (#6136) * Preserve shape in lightbox * Rename shapes to semantic meanings It looks like after all I do want to fork based on those. * Round avatars on the web * Oops --- src/screens/Profile/Header/Shell.tsx | 1 + .../com/lightbox/ImageViewing/@types/index.ts | 1 + .../ImageItem/ImageItem.android.tsx | 14 +- .../components/ImageItem/ImageItem.ios.tsx | 5 +- src/view/com/lightbox/Lightbox.web.tsx | 142 +++++++++++------- src/view/com/profile/ProfileSubpageHeader.tsx | 1 + src/view/com/util/post-embeds/index.tsx | 5 +- 7 files changed, 108 insertions(+), 61 deletions(-) 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 17c386771c..f882dcf9eb 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx @@ -302,6 +302,11 @@ const ImageItem = ({ committedTransform.value = withClampedSpring(finalTransform) }) + const innerStyle = useAnimatedStyle(() => ({ + width: '100%', + aspectRatio: imageAspect, + })) + const composedGesture = isScrollViewBeingDragged ? // If the parent is not at rest, provide a no-op gesture. Gesture.Manual() @@ -312,6 +317,9 @@ const ImageItem = ({ singleTap, ) + const type = imageSrc.type + const borderRadius = + type === 'circle-avi' ? 1e5 : type === 'rect-avi' ? 20 : 0 return ( @@ -326,7 +334,7 @@ const ImageItem = ({ source={{uri: imageSrc.uri}} placeholderContentFit="contain" placeholder={{uri: imageSrc.thumbUri}} - style={[styles.image]} + style={[innerStyle, {borderRadius}]} accessibilityLabel={imageSrc.alt} accessibilityHint="" accessibilityIgnoresInvertColors @@ -342,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 b4bbfb4d53..e876479a39 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx @@ -143,6 +143,9 @@ const ImageItem = ({ singleTap, ) + const type = imageSrc.type + const borderRadius = + type === 'circle-avi' ? 1e5 : type === 'rect-avi' ? 20 : 0 return ( 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, }) From 468c4b8f5ae68f537f2844797472b4c3794b094b Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 8 Nov 2024 03:38:32 +0000 Subject: [PATCH 03/35] Improve chat performance (#6157) * fix worklet funcs on gestures * don't access .value in render --- src/components/dms/ActionsWrapper.tsx | 3 ++- src/screens/Messages/Conversation.tsx | 4 +--- src/screens/Messages/components/MessagesList.tsx | 10 +++++----- 3 files changed, 8 insertions(+), 9 deletions(-) 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, ], From 6b1ffffce91fcad23548e63b6f912f855f46df0c Mon Sep 17 00:00:00 2001 From: gpp-0 <52042597+gpp-0@users.noreply.github.com> Date: Fri, 8 Nov 2024 06:19:52 +0200 Subject: [PATCH 04/35] Fix non-home screen soft resetting when feed is selected from right nav (#6158) --- src/view/shell/desktop/Feeds.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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() } }} From 7c45f7dcc7044f46b6d2a3596130db2a108c5e2e Mon Sep 17 00:00:00 2001 From: dan Date: Fri, 8 Nov 2024 16:24:03 +0000 Subject: [PATCH 05/35] Revert "Increase memory on iOS (#6141)" (#6165) This reverts commit 6570f56d8e22b22d099338c24731f525b860583a. --- app.config.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/app.config.js b/app.config.js index ecdbffb6c1..fed1b17a31 100644 --- a/app.config.js +++ b/app.config.js @@ -99,8 +99,6 @@ 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: { From ce8d62f8c98182b6498c853c436ae13afa7e21e0 Mon Sep 17 00:00:00 2001 From: dan Date: Fri, 8 Nov 2024 17:48:01 +0000 Subject: [PATCH 06/35] Fix stuck lightbox (#6166) * Add early exit guards once the gesture is over * Work around the Reanimated bug * Move derived reaction upwards to avoid duplicating it --- src/view/com/lightbox/ImageViewing/index.tsx | 44 +++++++++++++------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/src/view/com/lightbox/ImageViewing/index.tsx b/src/view/com/lightbox/ImageViewing/index.tsx index 7a3a506914..0a01c7fb3a 100644 --- a/src/view/com/lightbox/ImageViewing/index.tsx +++ b/src/view/com/lightbox/ImageViewing/index.tsx @@ -160,6 +160,23 @@ function ImageView({ } }, []) + 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)() + } + }, + ) + return ( { 'worklet' + if (isFlyingAway.value) { + return + } dismissSwipeTranslateY.value = e.translationY }) .onEnd(e => { 'worklet' + if (isFlyingAway.value) { + return + } if (Math.abs(e.velocityY) > 1000) { isFlyingAway.value = true + if (dismissSwipeTranslateY.value === 0) { + // HACK: If the initial value is 0, withDecay() animation doesn't start. + // This is a bug in Reanimated, but for now we'll work around it like this. + dismissSwipeTranslateY.value = 1 + } dismissSwipeTranslateY.value = withDecay({ velocity: e.velocityY, velocityFactor: Math.max(3000 / Math.abs(e.velocityY), 1), // Speed up if it's too slow. @@ -274,22 +302,6 @@ function LightboxImage({ }) } }) - 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 { From e9d7c444cea0d60b4d2fe1b3c7195edd3db98e5b Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 8 Nov 2024 18:00:39 +0000 Subject: [PATCH 07/35] fix 3 image grid in DMs (#6163) --- src/view/com/util/images/ImageLayoutGrid.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/view/com/util/images/ImageLayoutGrid.tsx b/src/view/com/util/images/ImageLayoutGrid.tsx index 830040ba6c..9d6a498362 100644 --- a/src/view/com/util/images/ImageLayoutGrid.tsx +++ b/src/view/com/util/images/ImageLayoutGrid.tsx @@ -27,11 +27,10 @@ export function ImageLayoutGrid({style, ...props}: ImageLayoutGridProps) { ? a.gap_xs : a.gap_2xs : a.gap_xs - const count = props.images.length - const aspectRatio = count === 3 ? 2 : undefined + return ( - + @@ -78,14 +77,14 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) { case 3: return ( - + - + Date: Fri, 8 Nov 2024 19:04:18 +0000 Subject: [PATCH 08/35] [Settings] Improved account switcher (#6131) * move out avatarstack to own file * improved settings switch * prefix with @ * fix types * up chevron * respect reduced motion setting * respect reduced motion in other place --- .../personPlus_stroke2_corner2_rounded.svg | 1 + src/components/AvatarStack.tsx | 76 +++++ src/components/icons/Person.tsx | 4 + src/screens/Settings/Settings.tsx | 296 ++++++++++++------ 4 files changed, 273 insertions(+), 104 deletions(-) create mode 100644 assets/icons/personPlus_stroke2_corner2_rounded.svg create mode 100644 src/components/AvatarStack.tsx diff --git a/assets/icons/personPlus_stroke2_corner2_rounded.svg b/assets/icons/personPlus_stroke2_corner2_rounded.svg new file mode 100644 index 0000000000..148c29e0f0 --- /dev/null +++ b/assets/icons/personPlus_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/src/components/AvatarStack.tsx b/src/components/AvatarStack.tsx new file mode 100644 index 0000000000..5f790fb671 --- /dev/null +++ b/src/components/AvatarStack.tsx @@ -0,0 +1,76 @@ +import React from 'react' +import {View} from 'react-native' +import {moderateProfile} from '@atproto/api' + +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useProfilesQuery} from '#/state/queries/profile' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {atoms as a, useTheme} from '#/alf' + +export function AvatarStack({ + profiles, + size = 26, +}: { + profiles: string[] + size?: number +}) { + const halfSize = size / 2 + const {data, error} = useProfilesQuery({handles: profiles}) + const t = useTheme() + const moderationOpts = useModerationOpts() + + if (error) { + console.error(error) + return null + } + + const isPending = !data || !moderationOpts + + const items = isPending + ? Array.from({length: profiles.length}).map((_, i) => ({ + key: i, + profile: null, + moderation: null, + })) + : data.profiles.map(item => ({ + key: item.did, + profile: item, + moderation: moderateProfile(item, moderationOpts), + })) + + return ( + + {items.map((item, i) => ( + + {item.profile && ( + + )} + + ))} + + ) +} diff --git a/src/components/icons/Person.tsx b/src/components/icons/Person.tsx index 31d7078d9c..8428fcce1b 100644 --- a/src/components/icons/Person.tsx +++ b/src/components/icons/Person.tsx @@ -24,6 +24,10 @@ export const PersonPlus_Filled_Stroke2_Corner0_Rounded = createSinglePathSVG({ path: 'M7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM12 12c-4.758 0-8.083 3.521-8.496 7.906A1 1 0 0 0 4.5 21H15a3 3 0 1 1 0-6c0-.824.332-1.571.87-2.113C14.739 12.32 13.435 12 12 12Zm6 2a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2h-2a1 1 0 1 1 0-2h2v-2a1 1 0 0 1 1-1Z', }) +export const PersonPlus_Stroke2_Corner2_Rounded = createSinglePathSVG({ + path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM12 14c-2.95 0-5.163 1.733-6.08 4.21a.47.47 0 0 0 .09.493.9.9 0 0 0 .687.297H11a1 1 0 1 1 0 2H6.697a2.9 2.9 0 0 1-2.219-1.011 2.46 2.46 0 0 1-.433-2.473C5.235 14.296 8.168 12 12 12c.787 0 1.54.097 2.252.282a1 1 0 1 1-.504 1.936A7 7 0 0 0 12 14Zm6 0a1 1 0 0 1 1 1v2h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2h-2a1 1 0 1 1 0-2h2v-2a1 1 0 0 1 1-1Z', +}) + export const PersonGroup_Stroke2_Corner2_Rounded = createSinglePathSVG({ path: 'M8 5a2 2 0 1 0 0 4 2 2 0 0 0 0-4ZM4 7a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm13-1a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm-3.5 1.5a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0Zm7.301 9.7c-.836-2.6-2.88-3.503-4.575-3.111a1 1 0 0 1-.451-1.949c2.815-.651 5.81.966 6.93 4.448a2.49 2.49 0 0 1-.506 2.43A2.92 2.92 0 0 1 20 20h-2a1 1 0 1 1 0-2h2a.92.92 0 0 0 .69-.295.49.49 0 0 0 .112-.505ZM8 14c-1.865 0-3.878 1.274-4.681 4.151a.57.57 0 0 0 .132.55c.15.171.4.299.695.299h7.708a.93.93 0 0 0 .695-.299.57.57 0 0 0 .132-.55C11.878 15.274 9.865 14 8 14Zm0-2c2.87 0 5.594 1.98 6.607 5.613.53 1.9-1.09 3.387-2.753 3.387H4.146c-1.663 0-3.283-1.487-2.753-3.387C2.406 13.981 5.129 12 8 12Z', }) diff --git a/src/screens/Settings/Settings.tsx b/src/screens/Settings/Settings.tsx index 2062fe9180..adea42e5ed 100644 --- a/src/screens/Settings/Settings.tsx +++ b/src/screens/Settings/Settings.tsx @@ -1,6 +1,7 @@ import React, {useState} from 'react' -import {LayoutAnimation, View} from 'react-native' +import {LayoutAnimation, Pressable, View} from 'react-native' import {Linking} from 'react-native' +import {useReducedMotion} from 'react-native-reanimated' import {AppBskyActorDefs, moderateProfile} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -9,13 +10,15 @@ import {NativeStackScreenProps} from '@react-navigation/native-stack' import {IS_INTERNAL} from '#/lib/app-info' import {HELP_DESK_URL} from '#/lib/constants' +import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher' import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types' +import {sanitizeHandle} from '#/lib/strings/handles' import {useProfileShadow} from '#/state/cache/profile-shadow' import {clearStorage} from '#/state/persisted' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useDeleteActorDeclaration} from '#/state/queries/messages/actor-declaration' import {useProfileQuery, useProfilesQuery} from '#/state/queries/profile' -import {useSession, useSessionApi} from '#/state/session' +import {SessionAccount, useSession, useSessionApi} from '#/state/session' import {useOnboardingDispatch} from '#/state/shell' import {useLoggedOutViewControls} from '#/state/shell/logged-out' import {useCloseAllActiveElements} from '#/state/util' @@ -24,42 +27,50 @@ import {UserAvatar} from '#/view/com/util/UserAvatar' import {ProfileHeaderDisplayName} from '#/screens/Profile/Header/DisplayName' import {ProfileHeaderHandle} from '#/screens/Profile/Header/Handle' import * as SettingsList from '#/screens/Settings/components/SettingsList' -import {atoms as a, useTheme} from '#/alf' +import {atoms as a, tokens, useTheme} from '#/alf' +import {AvatarStack} from '#/components/AvatarStack' import {useDialogControl} from '#/components/Dialog' import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount' import {Accessibility_Stroke2_Corner2_Rounded as AccessibilityIcon} from '#/components/icons/Accessibility' import {BubbleInfo_Stroke2_Corner2_Rounded as BubbleInfoIcon} from '#/components/icons/BubbleInfo' +import {ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon} from '#/components/icons/Chevron' import {CircleQuestion_Stroke2_Corner2_Rounded as CircleQuestionIcon} from '#/components/icons/CircleQuestion' import {CodeBrackets_Stroke2_Corner2_Rounded as CodeBracketsIcon} from '#/components/icons/CodeBrackets' +import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid' import {Earth_Stroke2_Corner2_Rounded as EarthIcon} from '#/components/icons/Globe' import {Lock_Stroke2_Corner2_Rounded as LockIcon} from '#/components/icons/Lock' import {PaintRoller_Stroke2_Corner2_Rounded as PaintRollerIcon} from '#/components/icons/PaintRoller' import { Person_Stroke2_Corner2_Rounded as PersonIcon, PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon, + PersonPlus_Stroke2_Corner2_Rounded as PersonPlusIcon, + PersonX_Stroke2_Corner0_Rounded as PersonXIcon, } from '#/components/icons/Person' import {RaisingHand4Finger_Stroke2_Corner2_Rounded as HandIcon} from '#/components/icons/RaisingHand' import {Window_Stroke2_Corner2_Rounded as WindowIcon} from '#/components/icons/Window' import * as Layout from '#/components/Layout' +import {Loader} from '#/components/Loader' +import * as Menu from '#/components/Menu' import * as Prompt from '#/components/Prompt' type Props = NativeStackScreenProps export function SettingsScreen({}: Props) { const {_} = useLingui() + const reducedMotion = useReducedMotion() const {logoutEveryAccount} = useSessionApi() const {accounts, currentAccount} = useSession() const switchAccountControl = useDialogControl() const signOutPromptControl = Prompt.usePromptControl() const {data: profile} = useProfileQuery({did: currentAccount?.did}) - const {setShowLoggedOut} = useLoggedOutViewControls() - const closeEverything = useCloseAllActiveElements() + const {data: otherProfiles} = useProfilesQuery({ + handles: accounts + .filter(acc => acc.did !== currentAccount?.did) + .map(acc => acc.handle), + }) + const {pendingDid, onPressSwitchAccount} = useAccountSwitcher() + const [showAccounts, setShowAccounts] = useState(false) const [showDevOptions, setShowDevOptions] = useState(false) - const onAddAnotherAccount = () => { - setShowLoggedOut(true) - closeEverything() - } - return ( @@ -77,34 +88,59 @@ export function SettingsScreen({}: Props) { ]}> {profile && } - 1 - ? _(msg`Switch account`) - : _(msg`Add another account`) - } - onPress={() => - accounts.length > 1 - ? switchAccountControl.open() - : onAddAnotherAccount() - }> - - - {accounts.length > 1 ? ( - Switch account - ) : ( - Add another account + {accounts.length > 1 ? ( + <> + { + if (!reducedMotion) { + LayoutAnimation.configureNext( + LayoutAnimation.Presets.easeInEaseOut, + ) + } + setShowAccounts(s => !s) + }}> + + + Switch account + + {showAccounts ? ( + + ) : ( + acc.did) + .filter(did => did !== currentAccount?.did) + .slice(0, 5)} + /> + )} + + {showAccounts && ( + <> + + {accounts + .filter(acc => acc.did !== currentAccount?.did) + .map(account => ( + p.did === account.did, + )} + pendingDid={pendingDid} + onPressSwitchAccount={onPressSwitchAccount} + /> + ))} + + )} - - {accounts.length > 1 && ( - acc.did) - .filter(did => did !== currentAccount?.did) - .slice(0, 5)} - /> - )} - + + ) : ( + + )} @@ -188,9 +224,11 @@ export function SettingsScreen({}: Props) { { - LayoutAnimation.configureNext( - LayoutAnimation.Presets.easeInEaseOut, - ) + if (!reducedMotion) { + LayoutAnimation.configureNext( + LayoutAnimation.Presets.easeInEaseOut, + ) + } setShowDevOptions(d => !d) }} label={_(msg`Developer options`)}> @@ -245,70 +283,6 @@ function ProfilePreview({ ) } -const AVI_SIZE = 26 -const HALF_AVI_SIZE = AVI_SIZE / 2 - -function AvatarStack({profiles}: {profiles: string[]}) { - const {data, error} = useProfilesQuery({handles: profiles}) - const t = useTheme() - const moderationOpts = useModerationOpts() - - if (error) { - console.error(error) - return null - } - - const isPending = !data || !moderationOpts - - const items = isPending - ? Array.from({length: profiles.length}).map((_, i) => ({ - key: i, - profile: null, - moderation: null, - })) - : data.profiles.map(item => ({ - key: item.did, - profile: item, - moderation: moderateProfile(item, moderationOpts), - })) - - return ( - - {items.map((item, i) => ( - - {item.profile && ( - - )} - - ))} - - ) -} - function DevOptions() { const {_} = useLingui() const onboardingDispatch = useOnboardingDispatch() @@ -373,3 +347,117 @@ function DevOptions() { ) } + +function AddAccountRow() { + const {_} = useLingui() + const {setShowLoggedOut} = useLoggedOutViewControls() + const closeEverything = useCloseAllActiveElements() + + const onAddAnotherAccount = () => { + setShowLoggedOut(true) + closeEverything() + } + + return ( + + + + Add another account + + + ) +} + +function AccountRow({ + profile, + account, + pendingDid, + onPressSwitchAccount, +}: { + profile?: AppBskyActorDefs.ProfileViewDetailed + account: SessionAccount + pendingDid: string | null + onPressSwitchAccount: ( + account: SessionAccount, + logContext: 'Settings', + ) => void +}) { + const {_} = useLingui() + const t = useTheme() + + const moderationOpts = useModerationOpts() + const removePromptControl = Prompt.usePromptControl() + const {removeAccount} = useSessionApi() + + const onSwitchAccount = () => { + if (pendingDid) return + onPressSwitchAccount(account, 'Settings') + } + + return ( + + + {moderationOpts && profile ? ( + + ) : ( + + )} + + {sanitizeHandle(account.handle, '@')} + + {pendingDid === account.did && } + + {!pendingDid && ( + + + {({props, state}) => ( + + + + )} + + + removePromptControl.open()}> + + Remove account + + + + + + )} + + { + removeAccount(account) + Toast.show(_(msg`Account removed from quick access`)) + }} + confirmButtonCta={_(msg`Remove`)} + confirmButtonColor="negative" + /> + + ) +} From e8a03058bb993bda993e78d24877884d39666d63 Mon Sep 17 00:00:00 2001 From: Hailey Date: Fri, 8 Nov 2024 14:28:52 -0800 Subject: [PATCH 09/35] options to checkbox (#6175) --- src/view/com/composer/labels/LabelsBtn.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/view/com/composer/labels/LabelsBtn.tsx b/src/view/com/composer/labels/LabelsBtn.tsx index 540e01f674..75eaa33d72 100644 --- a/src/view/com/composer/labels/LabelsBtn.tsx +++ b/src/view/com/composer/labels/LabelsBtn.tsx @@ -144,19 +144,19 @@ function DialogInner({ }}> - + Suggestive - + Nudity - + Porn From 5da3f29498fda9ab1181df19a718e37099cb2cf6 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 8 Nov 2024 22:42:18 +0000 Subject: [PATCH 10/35] [Settings] Ungate, and remove old settings (#6144) * move export car dialog * move disableemail2fadialog * delete old settings screens * fix type error * Update Navigation.tsx * Delete AccountDropdownBtn.tsx * remove old change handle modal * delete add app paswords * forgot to actually delete the change handle modal --- src/Navigation.tsx | 22 +- src/lib/hooks/useCustomPalette.ts | 14 - src/screens/Moderation/index.tsx | 131 -- src/screens/Settings/AccountSettings.tsx | 2 +- .../components}/DisableEmail2FADialog.tsx | 0 .../Settings/components/Email2FAToggle.tsx | 2 +- .../Settings/components}/ExportCarDialog.tsx | 0 src/state/modals/index.tsx | 11 - src/view/com/modals/AddAppPasswords.tsx | 307 ----- src/view/com/modals/ChangeHandle.tsx | 614 ---------- src/view/com/modals/Modal.tsx | 8 - src/view/com/modals/Modal.web.tsx | 6 - src/view/com/util/AccountDropdownBtn.tsx | 66 - src/view/com/util/List.web.tsx | 4 +- src/view/screens/AccessibilitySettings.tsx | 157 --- src/view/screens/AppPasswords.tsx | 375 ------ src/view/screens/LanguageSettings.tsx | 336 ----- .../screens/PreferencesExternalEmbeds.tsx | 147 --- src/view/screens/PreferencesFollowingFeed.tsx | 249 ---- src/view/screens/PreferencesThreads.tsx | 198 --- src/view/screens/Settings/Email2FAToggle.tsx | 58 - src/view/screens/Settings/index.tsx | 1077 ----------------- 22 files changed, 16 insertions(+), 3768 deletions(-) delete mode 100644 src/lib/hooks/useCustomPalette.ts rename src/{view/screens/Settings => screens/Settings/components}/DisableEmail2FADialog.tsx (100%) rename src/{view/screens/Settings => screens/Settings/components}/ExportCarDialog.tsx (100%) delete mode 100644 src/view/com/modals/AddAppPasswords.tsx delete mode 100644 src/view/com/modals/ChangeHandle.tsx delete mode 100644 src/view/com/util/AccountDropdownBtn.tsx delete mode 100644 src/view/screens/AccessibilitySettings.tsx delete mode 100644 src/view/screens/AppPasswords.tsx delete mode 100644 src/view/screens/LanguageSettings.tsx delete mode 100644 src/view/screens/PreferencesExternalEmbeds.tsx delete mode 100644 src/view/screens/PreferencesFollowingFeed.tsx delete mode 100644 src/view/screens/PreferencesThreads.tsx delete mode 100644 src/view/screens/Settings/Email2FAToggle.tsx delete mode 100644 src/view/screens/Settings/index.tsx diff --git a/src/Navigation.tsx b/src/Navigation.tsx index efe4b8c292..0ab4bb613f 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -40,14 +40,11 @@ import { shouldRequestEmailConfirmation, snoozeEmailConfirmationPrompt, } from '#/state/shell/reminders' -import {AccessibilitySettingsScreen} from '#/view/screens/AccessibilitySettings' -import {AppPasswords} from '#/view/screens/AppPasswords' import {CommunityGuidelinesScreen} from '#/view/screens/CommunityGuidelines' import {CopyrightPolicyScreen} from '#/view/screens/CopyrightPolicy' import {DebugModScreen} from '#/view/screens/DebugMod' import {FeedsScreen} from '#/view/screens/Feeds' import {HomeScreen} from '#/view/screens/Home' -import {LanguageSettingsScreen} from '#/view/screens/LanguageSettings' import {ListsScreen} from '#/view/screens/Lists' import {LogScreen} from '#/view/screens/Log' import {ModerationBlockedAccounts} from '#/view/screens/ModerationBlockedAccounts' @@ -56,9 +53,6 @@ import {ModerationMutedAccounts} from '#/view/screens/ModerationMutedAccounts' import {NotFoundScreen} from '#/view/screens/NotFound' import {NotificationsScreen} from '#/view/screens/Notifications' import {PostThreadScreen} from '#/view/screens/PostThread' -import {PreferencesExternalEmbeds} from '#/view/screens/PreferencesExternalEmbeds' -import {PreferencesFollowingFeed} from '#/view/screens/PreferencesFollowingFeed' -import {PreferencesThreads} from '#/view/screens/PreferencesThreads' import {PrivacyPolicyScreen} from '#/view/screens/PrivacyPolicy' import {ProfileScreen} from '#/view/screens/Profile' import {ProfileFeedScreen} from '#/view/screens/ProfileFeed' @@ -68,7 +62,6 @@ import {ProfileFollowsScreen} from '#/view/screens/ProfileFollows' import {ProfileListScreen} from '#/view/screens/ProfileList' import {SavedFeeds} from '#/view/screens/SavedFeeds' import {SearchScreen} from '#/view/screens/Search' -import {SettingsScreen} from '#/view/screens/Settings' import {Storybook} from '#/view/screens/Storybook' import {SupportScreen} from '#/view/screens/Support' import {TermsOfServiceScreen} from '#/view/screens/TermsOfService' @@ -96,9 +89,16 @@ import {useTheme} from '#/alf' import {router} from '#/routes' import {Referrer} from '../modules/expo-bluesky-swiss-army' import {AboutSettingsScreen} from './screens/Settings/AboutSettings' +import {AccessibilitySettingsScreen} from './screens/Settings/AccessibilitySettings' import {AccountSettingsScreen} from './screens/Settings/AccountSettings' +import {AppPasswordsScreen} from './screens/Settings/AppPasswords' import {ContentAndMediaSettingsScreen} from './screens/Settings/ContentAndMediaSettings' +import {ExternalMediaPreferencesScreen} from './screens/Settings/ExternalMediaPreferences' +import {FollowingFeedPreferencesScreen} from './screens/Settings/FollowingFeedPreferences' +import {LanguageSettingsScreen} from './screens/Settings/LanguageSettings' import {PrivacyAndSecuritySettingsScreen} from './screens/Settings/PrivacyAndSecuritySettings' +import {SettingsScreen} from './screens/Settings/Settings' +import {ThreadPreferencesScreen} from './screens/Settings/ThreadPreferences' const navigationRef = createNavigationContainerRef() @@ -285,7 +285,7 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { /> AppPasswords} + getComponent={() => AppPasswordsScreen} options={{title: title(msg`App Passwords`), requireAuth: true}} /> PreferencesFollowingFeed} + getComponent={() => FollowingFeedPreferencesScreen} options={{ title: title(msg`Following Feed Preferences`), requireAuth: true, @@ -303,12 +303,12 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { /> PreferencesThreads} + getComponent={() => ThreadPreferencesScreen} options={{title: title(msg`Threads Preferences`), requireAuth: true}} /> PreferencesExternalEmbeds} + getComponent={() => ExternalMediaPreferencesScreen} options={{ title: title(msg`External Media Preferences`), requireAuth: true, diff --git a/src/lib/hooks/useCustomPalette.ts b/src/lib/hooks/useCustomPalette.ts deleted file mode 100644 index 5691ea79b5..0000000000 --- a/src/lib/hooks/useCustomPalette.ts +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react' - -import {choose} from '#/lib/functions' -import {useTheme} from '#/lib/ThemeContext' - -export function useCustomPalette({light, dark}: {light: T; dark: T}) { - const theme = useTheme() - return React.useMemo(() => { - return choose>(theme.colorScheme, { - dark, - light, - }) - }, [theme.colorScheme, dark, light]) -} diff --git a/src/screens/Moderation/index.tsx b/src/screens/Moderation/index.tsx index d5a2daffdf..5f340cd560 100644 --- a/src/screens/Moderation/index.tsx +++ b/src/screens/Moderation/index.tsx @@ -1,13 +1,11 @@ import React from 'react' import {Linking, View} from 'react-native' import {useSafeAreaFrame} from 'react-native-safe-area-context' -import {ComAtprotoLabelDefs} from '@atproto/api' import {LABELS} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useFocusEffect} from '@react-navigation/native' -import {IS_INTERNAL} from '#/lib/app-info' import {getLabelingServiceTitle} from '#/lib/moderation' import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' import {logger} from '#/logger' @@ -18,11 +16,6 @@ import { UsePreferencesQueryResponse, usePreferencesSetAdultContentMutation, } from '#/state/queries/preferences' -import { - useProfileQuery, - useProfileUpdateMutation, -} from '#/state/queries/profile' -import {useSession} from '#/state/session' import {isNonConfigurableModerationAuthority} from '#/state/session/additional-moderation-authorities' import {useSetMinimalShellMode} from '#/state/shell' import {ViewHeader} from '#/view/com/util/ViewHeader' @@ -469,131 +462,7 @@ export function ModerationScreenInner({ })} )} - - {!IS_INTERNAL && ( - <> - - Logged-out visibility - - - - - )} - ) } - -function PwiOptOut() { - const t = useTheme() - const {_} = useLingui() - const {currentAccount} = useSession() - const {data: profile} = useProfileQuery({did: currentAccount?.did}) - const updateProfile = useProfileUpdateMutation() - - const isOptedOut = - profile?.labels?.some(l => l.val === '!no-unauthenticated') || false - const canToggle = profile && !updateProfile.isPending - - const onToggleOptOut = React.useCallback(() => { - if (!profile) { - return - } - let wasAdded = false - updateProfile.mutate({ - profile, - updates: existing => { - // create labels attr if needed - existing.labels = ComAtprotoLabelDefs.isSelfLabels(existing.labels) - ? existing.labels - : { - $type: 'com.atproto.label.defs#selfLabels', - values: [], - } - - // toggle the label - const hasLabel = existing.labels.values.some( - l => l.val === '!no-unauthenticated', - ) - if (hasLabel) { - wasAdded = false - existing.labels.values = existing.labels.values.filter( - l => l.val !== '!no-unauthenticated', - ) - } else { - wasAdded = true - existing.labels.values.push({val: '!no-unauthenticated'}) - } - - // delete if no longer needed - if (existing.labels.values.length === 0) { - delete existing.labels - } - return existing - }, - checkCommitted: res => { - const exists = !!res.data.labels?.some( - l => l.val === '!no-unauthenticated', - ) - return exists === wasAdded - }, - }) - }, [updateProfile, profile]) - - return ( - - - - - - - Discourage apps from showing my account to logged-out users - - - - - {updateProfile.isPending && } - - - - - - Bluesky will not show your profile and posts to logged-out users. - Other apps may not honor this request. This does not make your - account private. - - - - - Note: Bluesky is an open and public network. This setting only - limits the visibility of your content on the Bluesky app and - website, and other apps may not respect this setting. Your content - may still be shown to logged-out users by other apps and websites. - - - - - Learn more about what is public on Bluesky. - - - - ) -} diff --git a/src/screens/Settings/AccountSettings.tsx b/src/screens/Settings/AccountSettings.tsx index f34810a68c..35c5f3aa09 100644 --- a/src/screens/Settings/AccountSettings.tsx +++ b/src/screens/Settings/AccountSettings.tsx @@ -6,7 +6,6 @@ import {NativeStackScreenProps} from '@react-navigation/native-stack' import {CommonNavigatorParams} from '#/lib/routes/types' import {useModalControls} from '#/state/modals' import {useSession} from '#/state/session' -import {ExportCarDialog} from '#/view/screens/Settings/ExportCarDialog' import * as SettingsList from '#/screens/Settings/components/SettingsList' import {atoms as a, useTheme} from '#/alf' import {useDialogControl} from '#/components/Dialog' @@ -24,6 +23,7 @@ import {Verified_Stroke2_Corner2_Rounded as VerifiedIcon} from '#/components/ico import * as Layout from '#/components/Layout' import {ChangeHandleDialog} from './components/ChangeHandleDialog' import {DeactivateAccountDialog} from './components/DeactivateAccountDialog' +import {ExportCarDialog} from './components/ExportCarDialog' type Props = NativeStackScreenProps export function AccountSettingsScreen({}: Props) { diff --git a/src/view/screens/Settings/DisableEmail2FADialog.tsx b/src/screens/Settings/components/DisableEmail2FADialog.tsx similarity index 100% rename from src/view/screens/Settings/DisableEmail2FADialog.tsx rename to src/screens/Settings/components/DisableEmail2FADialog.tsx diff --git a/src/screens/Settings/components/Email2FAToggle.tsx b/src/screens/Settings/components/Email2FAToggle.tsx index 85ae89deaa..a74f9fce71 100644 --- a/src/screens/Settings/components/Email2FAToggle.tsx +++ b/src/screens/Settings/components/Email2FAToggle.tsx @@ -4,9 +4,9 @@ import {useLingui} from '@lingui/react' import {useModalControls} from '#/state/modals' import {useAgent, useSession} from '#/state/session' -import {DisableEmail2FADialog} from '#/view/screens/Settings/DisableEmail2FADialog' import {useDialogControl} from '#/components/Dialog' import * as Prompt from '#/components/Prompt' +import {DisableEmail2FADialog} from './DisableEmail2FADialog' import * as SettingsList from './SettingsList' export function Email2FAToggle() { diff --git a/src/view/screens/Settings/ExportCarDialog.tsx b/src/screens/Settings/components/ExportCarDialog.tsx similarity index 100% rename from src/view/screens/Settings/ExportCarDialog.tsx rename to src/screens/Settings/components/ExportCarDialog.tsx diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx index 78f476d526..483de99e49 100644 --- a/src/state/modals/index.tsx +++ b/src/state/modals/index.tsx @@ -48,11 +48,6 @@ export interface DeleteAccountModal { name: 'delete-account' } -export interface ChangeHandleModal { - name: 'change-handle' - onChanged: () => void -} - export interface WaitlistModal { name: 'waitlist' } @@ -61,10 +56,6 @@ export interface InviteCodesModal { name: 'invite-codes' } -export interface AddAppPasswordModal { - name: 'add-app-password' -} - export interface ContentLanguagesSettingsModal { name: 'content-languages-settings' } @@ -101,8 +92,6 @@ export interface InAppBrowserConsentModal { export type Modal = // Account - | AddAppPasswordModal - | ChangeHandleModal | DeleteAccountModal | VerifyEmailModal | ChangeEmailModal diff --git a/src/view/com/modals/AddAppPasswords.tsx b/src/view/com/modals/AddAppPasswords.tsx deleted file mode 100644 index f7991f59bf..0000000000 --- a/src/view/com/modals/AddAppPasswords.tsx +++ /dev/null @@ -1,307 +0,0 @@ -import React, {useState} from 'react' -import {StyleSheet, TextInput, TouchableOpacity, View} from 'react-native' -import {setStringAsync} from 'expo-clipboard' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import {usePalette} from '#/lib/hooks/usePalette' -import {s} from '#/lib/styles' -import {logger} from '#/logger' -import {isNative} from '#/platform/detection' -import {useModalControls} from '#/state/modals' -import { - useAppPasswordCreateMutation, - useAppPasswordsQuery, -} from '#/state/queries/app-passwords' -import {Button} from '#/view/com/util/forms/Button' -import {Text} from '#/view/com/util/text/Text' -import * as Toast from '#/view/com/util/Toast' -import {atoms as a} from '#/alf' -import * as Toggle from '#/components/forms/Toggle' - -export const snapPoints = ['90%'] - -const shadesOfBlue: string[] = [ - 'AliceBlue', - 'Aqua', - 'Aquamarine', - 'Azure', - 'BabyBlue', - 'Blue', - 'BlueViolet', - 'CadetBlue', - 'CornflowerBlue', - 'Cyan', - 'DarkBlue', - 'DarkCyan', - 'DarkSlateBlue', - 'DeepSkyBlue', - 'DodgerBlue', - 'ElectricBlue', - 'LightBlue', - 'LightCyan', - 'LightSkyBlue', - 'LightSteelBlue', - 'MediumAquaMarine', - 'MediumBlue', - 'MediumSlateBlue', - 'MidnightBlue', - 'Navy', - 'PowderBlue', - 'RoyalBlue', - 'SkyBlue', - 'SlateBlue', - 'SteelBlue', - 'Teal', - 'Turquoise', -] - -export function Component({}: {}) { - const pal = usePalette('default') - const {_} = useLingui() - const {closeModal} = useModalControls() - const {data: passwords} = useAppPasswordsQuery() - const {mutateAsync: mutateAppPassword, isPending} = - useAppPasswordCreateMutation() - const [name, setName] = useState( - shadesOfBlue[Math.floor(Math.random() * shadesOfBlue.length)], - ) - const [appPassword, setAppPassword] = useState() - const [wasCopied, setWasCopied] = useState(false) - const [privileged, setPrivileged] = useState(false) - - const onCopy = React.useCallback(() => { - if (appPassword) { - setStringAsync(appPassword) - Toast.show(_(msg`Copied to clipboard`), 'clipboard-check') - setWasCopied(true) - } - }, [appPassword, _]) - - const onDone = React.useCallback(() => { - closeModal() - }, [closeModal]) - - const createAppPassword = async () => { - // if name is all whitespace, we don't allow it - if (!name || !name.trim()) { - Toast.show( - _( - msg`Please enter a name for your app password. All spaces is not allowed.`, - ), - 'xmark', - ) - return - } - // if name is too short (under 4 chars), we don't allow it - if (name.length < 4) { - Toast.show( - _(msg`App Password names must be at least 4 characters long.`), - 'xmark', - ) - return - } - - if (passwords?.find(p => p.name === name)) { - Toast.show(_(msg`This name is already in use`), 'xmark') - return - } - - try { - const newPassword = await mutateAppPassword({name, privileged}) - if (newPassword) { - setAppPassword(newPassword.password) - } else { - Toast.show(_(msg`Failed to create app password.`), 'xmark') - // TODO: better error handling (?) - } - } catch (e) { - Toast.show(_(msg`Failed to create app password.`), 'xmark') - logger.error('Failed to create app password', {message: e}) - } - } - - const _onChangeText = (text: string) => { - // sanitize input - // we only all alphanumeric characters, spaces, dashes, and underscores - // if the user enters anything else, we ignore it and shake the input container - // also, it cannot start with a space - if (text.match(/^[a-zA-Z0-9-_ ]*$/)) { - setName(text) - } else { - Toast.show( - _( - msg`App Password names can only contain letters, numbers, spaces, dashes, and underscores.`, - ), - 'xmark', - ) - } - } - - return ( - - {!appPassword ? ( - <> - - - - Please enter a unique name for this App Password or use our - randomly generated one. - - - - - - - - - Can only contain letters, numbers, spaces, dashes, and - underscores. Must be at least 4 characters long, but no more than - 32 characters long. - - - setPrivileged(val)} - name="privileged" - style={a.my_md}> - - - Allow access to your direct messages - - - - ) : ( - <> - - - - Here is your app password. - - - Use this to sign into the other app along with your handle. - - - - - {appPassword} - - {wasCopied ? ( - - Copied - - ) : ( - - )} - - - - - For security reasons, you won't be able to view this again. If you - lose this password, you'll need to generate a new one. - - - - )} - - - {canSave === true && ( - - - Domain verified! - - - )} - {error ? ( - - - {error} - - - ) : null} - - - - - Nevermind, create a handle for me - - - - ) -} - -const styles = StyleSheet.create({ - inner: { - padding: 14, - }, - footer: { - padding: 14, - }, - spacer: { - height: 20, - }, - dimmed: { - opacity: 0.7, - }, - - selectableBtns: { - flexDirection: 'row', - }, - - title: { - flexDirection: 'row', - alignItems: 'center', - paddingTop: 25, - paddingHorizontal: 20, - paddingBottom: 15, - borderBottomWidth: 1, - }, - titleLeft: { - width: 80, - }, - titleRight: { - width: 80, - flexDirection: 'row', - justifyContent: 'flex-end', - }, - titleMiddle: { - flex: 1, - textAlign: 'center', - fontSize: 21, - }, - - textInputWrapper: { - borderRadius: 8, - flexDirection: 'row', - alignItems: 'center', - }, - textInputIcon: { - marginLeft: 12, - }, - textInput: { - flex: 1, - width: '100%', - paddingVertical: 10, - paddingHorizontal: 8, - fontSize: 17, - letterSpacing: 0.25, - fontWeight: '400', - borderRadius: 10, - }, - - valueContainer: { - borderRadius: 4, - paddingVertical: 16, - }, - - dnsTable: { - borderRadius: 4, - paddingTop: 2, - paddingBottom: 16, - }, - dnsLabel: { - paddingHorizontal: 14, - paddingTop: 10, - }, - dnsValue: { - paddingHorizontal: 14, - borderRadius: 4, - }, - monoText: { - fontSize: 18, - lineHeight: 20, - }, - - message: { - paddingHorizontal: 12, - paddingVertical: 10, - borderRadius: 8, - marginBottom: 10, - }, - - btn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - width: '100%', - borderRadius: 32, - padding: 10, - marginBottom: 10, - }, - errorContainer: {marginBottom: 10}, -}) diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index becb39ff3d..78f4a01176 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -7,9 +7,7 @@ import {usePalette} from '#/lib/hooks/usePalette' import {useModalControls, useModals} from '#/state/modals' import {FullWindowOverlay} from '#/components/FullWindowOverlay' import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop' -import * as AddAppPassword from './AddAppPasswords' import * as ChangeEmailModal from './ChangeEmail' -import * as ChangeHandleModal from './ChangeHandle' import * as ChangePasswordModal from './ChangePassword' import * as CreateOrEditListModal from './CreateOrEditList' import * as DeleteAccountModal from './DeleteAccount' @@ -69,15 +67,9 @@ export function ModalsContainer() { } else if (activeModal?.name === 'delete-account') { snapPoints = DeleteAccountModal.snapPoints element = - } else if (activeModal?.name === 'change-handle') { - snapPoints = ChangeHandleModal.snapPoints - element = } else if (activeModal?.name === 'invite-codes') { snapPoints = InviteCodesModal.snapPoints element = - } else if (activeModal?.name === 'add-app-password') { - snapPoints = AddAppPassword.snapPoints - element = } else if (activeModal?.name === 'content-languages-settings') { snapPoints = ContentLanguagesSettingsModal.snapPoints element = diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index 46ced58d9a..e9d9c01dd8 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -7,9 +7,7 @@ import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import type {Modal as ModalIface} from '#/state/modals' import {useModalControls, useModals} from '#/state/modals' -import * as AddAppPassword from './AddAppPasswords' import * as ChangeEmailModal from './ChangeEmail' -import * as ChangeHandleModal from './ChangeHandle' import * as ChangePasswordModal from './ChangePassword' import * as CreateOrEditListModal from './CreateOrEditList' import * as CropImageModal from './CropImage.web' @@ -74,12 +72,8 @@ function Modal({modal}: {modal: ModalIface}) { element = } else if (modal.name === 'delete-account') { element = - } else if (modal.name === 'change-handle') { - element = } else if (modal.name === 'invite-codes') { element = - } else if (modal.name === 'add-app-password') { - element = } else if (modal.name === 'content-languages-settings') { element = } else if (modal.name === 'post-languages-settings') { diff --git a/src/view/com/util/AccountDropdownBtn.tsx b/src/view/com/util/AccountDropdownBtn.tsx deleted file mode 100644 index e7985bccfb..0000000000 --- a/src/view/com/util/AccountDropdownBtn.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import React from 'react' -import {Pressable} from 'react-native' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import {usePalette} from '#/lib/hooks/usePalette' -import {s} from '#/lib/styles' -import {SessionAccount, useSessionApi} from '#/state/session' -import {useDialogControl} from '#/components/Dialog' -import * as Prompt from '#/components/Prompt' -import * as Toast from '../../com/util/Toast' -import {DropdownItem, NativeDropdown} from './forms/NativeDropdown' - -export function AccountDropdownBtn({account}: {account: SessionAccount}) { - const pal = usePalette('default') - const {removeAccount} = useSessionApi() - const removePromptControl = useDialogControl() - const {_} = useLingui() - - const items: DropdownItem[] = [ - { - label: _(msg`Remove account`), - onPress: removePromptControl.open, - icon: { - ios: { - name: 'trash', - }, - android: 'ic_delete', - web: ['far', 'trash-can'], - }, - }, - ] - return ( - <> - - - - - - { - removeAccount(account) - Toast.show(_(msg`Account removed from quick access`)) - }} - confirmButtonCta={_(msg`Remove`)} - confirmButtonColor="negative" - /> - - ) -} diff --git a/src/view/com/util/List.web.tsx b/src/view/com/util/List.web.tsx index 59a79b5313..5ddc4ea8a2 100644 --- a/src/view/com/util/List.web.tsx +++ b/src/view/com/util/List.web.tsx @@ -448,7 +448,9 @@ let Row = function RowImpl({ onItemSeen: ((item: any) => void) | undefined }): React.ReactNode { const rowRef = React.useRef(null) - const intersectionTimeout = React.useRef(undefined) + const intersectionTimeout = React.useRef< + ReturnType | undefined + >(undefined) const handleIntersection = useNonReactiveCallback( (entries: IntersectionObserverEntry[]) => { diff --git a/src/view/screens/AccessibilitySettings.tsx b/src/view/screens/AccessibilitySettings.tsx deleted file mode 100644 index 4dd5aa97be..0000000000 --- a/src/view/screens/AccessibilitySettings.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import React from 'react' -import {StyleSheet, View} from 'react-native' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useFocusEffect} from '@react-navigation/native' - -import {IS_INTERNAL} from '#/lib/app-info' -import {usePalette} from '#/lib/hooks/usePalette' -import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' -import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' -import {s} from '#/lib/styles' -import {isNative} from '#/platform/detection' -import { - useAutoplayDisabled, - useHapticsDisabled, - useRequireAltTextEnabled, - useSetAutoplayDisabled, - useSetHapticsDisabled, - useSetRequireAltTextEnabled, -} from '#/state/preferences' -import { - useLargeAltBadgeEnabled, - useSetLargeAltBadgeEnabled, -} from '#/state/preferences/large-alt-badge' -import {useSetMinimalShellMode} from '#/state/shell' -import {ToggleButton} from '#/view/com/util/forms/ToggleButton' -import {SimpleViewHeader} from '#/view/com/util/SimpleViewHeader' -import {Text} from '#/view/com/util/text/Text' -import {ScrollView} from '#/view/com/util/Views' -import {AccessibilitySettingsScreen as NewAccessibilitySettingsScreen} from '#/screens/Settings/AccessibilitySettings' -import {atoms as a} from '#/alf' -import * as Layout from '#/components/Layout' - -type Props = NativeStackScreenProps< - CommonNavigatorParams, - 'AccessibilitySettings' -> -export function AccessibilitySettingsScreen(props: Props) { - return IS_INTERNAL ? ( - - ) : ( - - ) -} - -function LegacyAccessibilitySettingsScreen({}: Props) { - const pal = usePalette('default') - const setMinimalShellMode = useSetMinimalShellMode() - const {isMobile, isTabletOrMobile} = useWebMediaQueries() - const {_} = useLingui() - - const requireAltTextEnabled = useRequireAltTextEnabled() - const setRequireAltTextEnabled = useSetRequireAltTextEnabled() - const autoplayDisabled = useAutoplayDisabled() - const setAutoplayDisabled = useSetAutoplayDisabled() - const hapticsDisabled = useHapticsDisabled() - const setHapticsDisabled = useSetHapticsDisabled() - const largeAltBadgeEnabled = useLargeAltBadgeEnabled() - const setLargeAltBadgeEnabled = useSetLargeAltBadgeEnabled() - - useFocusEffect( - React.useCallback(() => { - setMinimalShellMode(false) - }, [setMinimalShellMode]), - ) - - return ( - - - - - Accessibility Settings - - - - - - Alt text - - - setRequireAltTextEnabled(!requireAltTextEnabled)} - /> - setLargeAltBadgeEnabled(!largeAltBadgeEnabled)} - /> - - - Media - - - setAutoplayDisabled(!autoplayDisabled)} - /> - - {isNative && ( - <> - - Haptics - - - setHapticsDisabled(!hapticsDisabled)} - /> - - - )} - - - ) -} - -const styles = StyleSheet.create({ - heading: { - paddingHorizontal: 18, - paddingTop: 14, - paddingBottom: 6, - }, - toggleCard: { - paddingVertical: 8, - paddingHorizontal: 6, - marginBottom: 1, - }, -}) diff --git a/src/view/screens/AppPasswords.tsx b/src/view/screens/AppPasswords.tsx deleted file mode 100644 index 09da3c1d2c..0000000000 --- a/src/view/screens/AppPasswords.tsx +++ /dev/null @@ -1,375 +0,0 @@ -import React from 'react' -import { - ActivityIndicator, - StyleSheet, - TouchableOpacity, - View, -} from 'react-native' -import {ScrollView} from 'react-native-gesture-handler' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useFocusEffect} from '@react-navigation/native' -import {NativeStackScreenProps} from '@react-navigation/native-stack' - -import {IS_INTERNAL} from '#/lib/app-info' -import {usePalette} from '#/lib/hooks/usePalette' -import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' -import {CommonNavigatorParams} from '#/lib/routes/types' -import {cleanError} from '#/lib/strings/errors' -import {useModalControls} from '#/state/modals' -import { - useAppPasswordDeleteMutation, - useAppPasswordsQuery, -} from '#/state/queries/app-passwords' -import {useSetMinimalShellMode} from '#/state/shell' -import {ErrorScreen} from '#/view/com/util/error/ErrorScreen' -import {Button} from '#/view/com/util/forms/Button' -import {Text} from '#/view/com/util/text/Text' -import * as Toast from '#/view/com/util/Toast' -import {ViewHeader} from '#/view/com/util/ViewHeader' -import {CenteredView} from '#/view/com/util/Views' -import {AppPasswordsScreen as NewAppPasswordsScreen} from '#/screens/Settings/AppPasswords' -import {atoms as a} from '#/alf' -import {useDialogControl} from '#/components/Dialog' -import * as Layout from '#/components/Layout' -import * as Prompt from '#/components/Prompt' - -type Props = NativeStackScreenProps -export function AppPasswords(props: Props) { - return IS_INTERNAL ? ( - - ) : ( - - - - ) -} - -function AppPasswordsInner() { - const pal = usePalette('default') - const {_} = useLingui() - const setMinimalShellMode = useSetMinimalShellMode() - const {isTabletOrDesktop} = useWebMediaQueries() - const {openModal} = useModalControls() - const {data: appPasswords, error} = useAppPasswordsQuery() - - useFocusEffect( - React.useCallback(() => { - setMinimalShellMode(false) - }, [setMinimalShellMode]), - ) - - const onAdd = React.useCallback(async () => { - openModal({name: 'add-app-password'}) - }, [openModal]) - - if (error) { - return ( - - - - ) - } - - // no app passwords (empty) state - if (appPasswords?.length === 0) { - return ( - - - - - - You have not created any app passwords yet. You can create one by - pressing the button below. - - - - {!isTabletOrDesktop && } - - - - - - - ) -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - paddingBottom: 90, - }, - desktopContainer: { - borderLeftWidth: 1, - borderRightWidth: 1, - paddingBottom: 40, - }, - button: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - }, -}) diff --git a/src/view/screens/PreferencesExternalEmbeds.tsx b/src/view/screens/PreferencesExternalEmbeds.tsx deleted file mode 100644 index ef3f73b3cd..0000000000 --- a/src/view/screens/PreferencesExternalEmbeds.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import React from 'react' -import {StyleSheet, View} from 'react-native' -import {Trans} from '@lingui/macro' -import {useFocusEffect} from '@react-navigation/native' - -import {IS_INTERNAL} from '#/lib/app-info' -import {usePalette} from '#/lib/hooks/usePalette' -import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' -import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' -import { - EmbedPlayerSource, - externalEmbedLabels, -} from '#/lib/strings/embed-player' -import { - useExternalEmbedsPrefs, - useSetExternalEmbedPref, -} from '#/state/preferences' -import {useSetMinimalShellMode} from '#/state/shell' -import {ToggleButton} from '#/view/com/util/forms/ToggleButton' -import {SimpleViewHeader} from '#/view/com/util/SimpleViewHeader' -import {Text} from '#/view/com/util/text/Text' -import {ScrollView} from '#/view/com/util/Views' -import {ExternalMediaPreferencesScreen} from '#/screens/Settings/ExternalMediaPreferences' -import {atoms as a} from '#/alf' -import * as Layout from '#/components/Layout' - -type Props = NativeStackScreenProps< - CommonNavigatorParams, - 'PreferencesExternalEmbeds' -> -export function PreferencesExternalEmbeds(props: Props) { - return IS_INTERNAL ? ( - - ) : ( - - ) -} - -function LegacyPreferencesExternalEmbeds({}: Props) { - const pal = usePalette('default') - const setMinimalShellMode = useSetMinimalShellMode() - const {isTabletOrMobile} = useWebMediaQueries() - - useFocusEffect( - React.useCallback(() => { - setMinimalShellMode(false) - }, [setMinimalShellMode]), - ) - - return ( - - - - - - External Media Preferences - - - Customize media from external sites. - - - - - - - - - External media may allow websites to collect information about - you and your device. No information is sent or requested until - you press the "play" button. - - - - - - Enable media players for - - {Object.entries(externalEmbedLabels) - // TODO: Remove special case when we disable the old integration. - .filter(([key]) => key !== 'tenor') - .map(([key, label]) => ( - - ))} - - - ) -} - -function PrefSelector({ - source, - label, -}: { - source: EmbedPlayerSource - label: string -}) { - const pal = usePalette('default') - const setExternalEmbedPref = useSetExternalEmbedPref() - const sources = useExternalEmbedsPrefs() - - return ( - - - - setExternalEmbedPref( - source, - sources?.[source] === 'show' ? 'hide' : 'show', - ) - } - /> - - - ) -} - -const styles = StyleSheet.create({ - heading: { - paddingHorizontal: 18, - paddingTop: 14, - paddingBottom: 14, - }, - spacer: { - height: 8, - }, - infoCard: { - paddingHorizontal: 20, - paddingVertical: 14, - }, - toggleCard: { - paddingVertical: 8, - paddingHorizontal: 6, - marginBottom: 1, - }, -}) diff --git a/src/view/screens/PreferencesFollowingFeed.tsx b/src/view/screens/PreferencesFollowingFeed.tsx deleted file mode 100644 index c31a23c49a..0000000000 --- a/src/view/screens/PreferencesFollowingFeed.tsx +++ /dev/null @@ -1,249 +0,0 @@ -import React from 'react' -import {StyleSheet, View} from 'react-native' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import {IS_INTERNAL} from '#/lib/app-info' -import {usePalette} from '#/lib/hooks/usePalette' -import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' -import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' -import {colors, s} from '#/lib/styles' -import { - usePreferencesQuery, - useSetFeedViewPreferencesMutation, -} from '#/state/queries/preferences' -import {ToggleButton} from '#/view/com/util/forms/ToggleButton' -import {SimpleViewHeader} from '#/view/com/util/SimpleViewHeader' -import {Text} from '#/view/com/util/text/Text' -import {ScrollView} from '#/view/com/util/Views' -import {FollowingFeedPreferencesScreen} from '#/screens/Settings/FollowingFeedPreferences' -import {atoms as a} from '#/alf' -import * as Layout from '#/components/Layout' - -type Props = NativeStackScreenProps< - CommonNavigatorParams, - 'PreferencesFollowingFeed' -> -export function PreferencesFollowingFeed(props: Props) { - return IS_INTERNAL ? ( - - ) : ( - - ) -} - -function LegacyPreferencesFollowingFeed({}: Props) { - const pal = usePalette('default') - const {_} = useLingui() - const {isTabletOrMobile} = useWebMediaQueries() - const {data: preferences} = usePreferencesQuery() - const {mutate: setFeedViewPref, variables} = - useSetFeedViewPreferencesMutation() - - const showReplies = !( - variables?.hideReplies ?? preferences?.feedViewPrefs?.hideReplies - ) - - return ( - - - - - - Following Feed Preferences - - - - Fine-tune the content you see on your Following feed. - - - - - - - - Show Replies - - - - Set this setting to "No" to hide all replies from your feed. - - - - setFeedViewPref({ - hideReplies: !( - variables?.hideReplies ?? - preferences?.feedViewPrefs?.hideReplies - ), - }) - } - /> - - - - Show Reposts - - - - Set this setting to "No" to hide all reposts from your feed. - - - - setFeedViewPref({ - hideReposts: !( - variables?.hideReposts ?? - preferences?.feedViewPrefs?.hideReposts - ), - }) - } - /> - - - - - Show Quote Posts - - - - Set this setting to "No" to hide all quote posts from your feed. - Reposts will still be visible. - - - - setFeedViewPref({ - hideQuotePosts: !( - variables?.hideQuotePosts ?? - preferences?.feedViewPrefs?.hideQuotePosts - ), - }) - } - /> - - - - - {' '} - Show Posts from My Feeds - - - - Set this setting to "Yes" to show samples of your saved feeds in - your Following feed. This is an experimental feature. - - - - setFeedViewPref({ - lab_mergeFeedEnabled: !( - variables?.lab_mergeFeedEnabled ?? - preferences?.feedViewPrefs?.lab_mergeFeedEnabled - ), - }) - } - /> - - - - - ) -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - desktopContainer: { - borderLeftWidth: 1, - borderRightWidth: 1, - }, - titleSection: { - paddingBottom: 30, - }, - title: { - textAlign: 'center', - marginBottom: 5, - }, - description: { - textAlign: 'center', - paddingHorizontal: 32, - }, - cardsContainer: { - paddingHorizontal: 20, - paddingVertical: 16, - }, - card: { - padding: 16, - borderRadius: 10, - marginBottom: 20, - }, - btn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - borderRadius: 32, - padding: 14, - backgroundColor: colors.blue3, - }, - btnDesktop: { - marginHorizontal: 'auto', - paddingHorizontal: 80, - }, - btnContainer: { - paddingTop: 20, - }, - dimmed: { - opacity: 0.3, - }, -}) diff --git a/src/view/screens/PreferencesThreads.tsx b/src/view/screens/PreferencesThreads.tsx deleted file mode 100644 index f511f4c59e..0000000000 --- a/src/view/screens/PreferencesThreads.tsx +++ /dev/null @@ -1,198 +0,0 @@ -import React from 'react' -import {ActivityIndicator, StyleSheet, View} from 'react-native' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import {IS_INTERNAL} from '#/lib/app-info' -import {usePalette} from '#/lib/hooks/usePalette' -import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' -import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' -import {colors, s} from '#/lib/styles' -import { - usePreferencesQuery, - useSetThreadViewPreferencesMutation, -} from '#/state/queries/preferences' -import {RadioGroup} from '#/view/com/util/forms/RadioGroup' -import {ToggleButton} from '#/view/com/util/forms/ToggleButton' -import {SimpleViewHeader} from '#/view/com/util/SimpleViewHeader' -import {Text} from '#/view/com/util/text/Text' -import {ScrollView} from '#/view/com/util/Views' -import {ThreadPreferencesScreen} from '#/screens/Settings/ThreadPreferences' -import {atoms as a} from '#/alf' -import * as Layout from '#/components/Layout' - -type Props = NativeStackScreenProps -export function PreferencesThreads(props: Props) { - return IS_INTERNAL ? ( - - ) : ( - - ) -} - -function LegacyPreferencesThreads({}: Props) { - const pal = usePalette('default') - const {_} = useLingui() - const {isTabletOrMobile} = useWebMediaQueries() - const {data: preferences} = usePreferencesQuery() - const {mutate: setThreadViewPrefs, variables} = - useSetThreadViewPreferencesMutation() - - const prioritizeFollowedUsers = Boolean( - variables?.prioritizeFollowedUsers ?? - preferences?.threadViewPrefs?.prioritizeFollowedUsers, - ) - const treeViewEnabled = Boolean( - variables?.lab_treeViewEnabled ?? - preferences?.threadViewPrefs?.lab_treeViewEnabled, - ) - - return ( - - - - - - Thread Preferences - - - Fine-tune the discussion threads. - - - - - {preferences ? ( - - - - Sort Replies - - - Sort replies to the same post by: - - - setThreadViewPrefs({sort: key})} - initialSelection={preferences?.threadViewPrefs?.sort} - /> - - - - - - Prioritize Your Follows - - - - Show replies by people you follow before all other replies. - - - - setThreadViewPrefs({ - prioritizeFollowedUsers: !prioritizeFollowedUsers, - }) - } - /> - - - - - {' '} - Threaded Mode - - - - Set this setting to "Yes" to show replies in a threaded view. - This is an experimental feature. - - - - setThreadViewPrefs({ - lab_treeViewEnabled: !treeViewEnabled, - }) - } - /> - - - ) : ( - - )} - - - ) -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - desktopContainer: { - borderLeftWidth: 1, - borderRightWidth: 1, - }, - titleSection: { - paddingBottom: 30, - }, - title: { - textAlign: 'center', - marginBottom: 5, - }, - description: { - textAlign: 'center', - paddingHorizontal: 32, - }, - cardsContainer: { - paddingHorizontal: 20, - paddingVertical: 16, - }, - card: { - padding: 16, - borderRadius: 10, - marginBottom: 20, - }, - btn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - borderRadius: 32, - padding: 14, - backgroundColor: colors.blue3, - }, - btnDesktop: { - marginHorizontal: 'auto', - paddingHorizontal: 80, - }, - btnContainer: { - paddingTop: 20, - }, - dimmed: { - opacity: 0.3, - }, -}) diff --git a/src/view/screens/Settings/Email2FAToggle.tsx b/src/view/screens/Settings/Email2FAToggle.tsx deleted file mode 100644 index f6ed19a212..0000000000 --- a/src/view/screens/Settings/Email2FAToggle.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import React from 'react' -import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import {useModalControls} from '#/state/modals' -import {useAgent, useSession} from '#/state/session' -import {ToggleButton} from '#/view/com/util/forms/ToggleButton' -import {useDialogControl} from '#/components/Dialog' -import {DisableEmail2FADialog} from './DisableEmail2FADialog' - -export function Email2FAToggle() { - const {_} = useLingui() - const {currentAccount} = useSession() - const {openModal} = useModalControls() - const disableDialogCtrl = useDialogControl() - const agent = useAgent() - - const enableEmailAuthFactor = React.useCallback(async () => { - if (currentAccount?.email) { - await agent.com.atproto.server.updateEmail({ - email: currentAccount.email, - emailAuthFactor: true, - }) - await agent.resumeSession(agent.session!) - } - }, [currentAccount, agent]) - - const onToggle = React.useCallback(() => { - if (!currentAccount) { - return - } - if (currentAccount.emailAuthFactor) { - disableDialogCtrl.open() - } else { - if (!currentAccount.emailConfirmed) { - openModal({ - name: 'verify-email', - onSuccess: enableEmailAuthFactor, - }) - return - } - enableEmailAuthFactor() - } - }, [currentAccount, enableEmailAuthFactor, openModal, disableDialogCtrl]) - - return ( - <> - - - - ) -} diff --git a/src/view/screens/Settings/index.tsx b/src/view/screens/Settings/index.tsx deleted file mode 100644 index 7ec7b5dce1..0000000000 --- a/src/view/screens/Settings/index.tsx +++ /dev/null @@ -1,1077 +0,0 @@ -import React from 'react' -import { - Platform, - Pressable, - StyleSheet, - TextStyle, - TouchableOpacity, - View, - ViewStyle, -} from 'react-native' -import {setStringAsync} from 'expo-clipboard' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import {msg, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useFocusEffect, useNavigation} from '@react-navigation/native' -import {useQueryClient} from '@tanstack/react-query' - -import {appVersion, BUNDLE_DATE, bundleInfo, IS_INTERNAL} from '#/lib/app-info' -import {STATUS_PAGE_URL} from '#/lib/constants' -import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher' -import {useCustomPalette} from '#/lib/hooks/useCustomPalette' -import {usePalette} from '#/lib/hooks/usePalette' -import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' -import {HandIcon, HashtagIcon} from '#/lib/icons' -import {makeProfileLink} from '#/lib/routes/links' -import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' -import {NavigationProp} from '#/lib/routes/types' -import {colors, s} from '#/lib/styles' -import {isNative} from '#/platform/detection' -import {useModalControls} from '#/state/modals' -import {clearStorage} from '#/state/persisted' -import { - useInAppBrowser, - useSetInAppBrowser, -} from '#/state/preferences/in-app-browser' -import {useDeleteActorDeclaration} from '#/state/queries/messages/actor-declaration' -import {useClearPreferencesMutation} from '#/state/queries/preferences' -import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile' -import {useProfileQuery} from '#/state/queries/profile' -import {SessionAccount, useSession, useSessionApi} from '#/state/session' -import {useOnboardingDispatch, useSetMinimalShellMode} from '#/state/shell' -import {useLoggedOutViewControls} from '#/state/shell/logged-out' -import {useCloseAllActiveElements} from '#/state/util' -import {AccountDropdownBtn} from '#/view/com/util/AccountDropdownBtn' -import {ToggleButton} from '#/view/com/util/forms/ToggleButton' -import {Link, TextLink} from '#/view/com/util/Link' -import {SimpleViewHeader} from '#/view/com/util/SimpleViewHeader' -import {Text} from '#/view/com/util/text/Text' -import * as Toast from '#/view/com/util/Toast' -import {UserAvatar} from '#/view/com/util/UserAvatar' -import {ScrollView} from '#/view/com/util/Views' -import {DeactivateAccountDialog} from '#/screens/Settings/components/DeactivateAccountDialog' -import {SettingsScreen as NewSettingsScreen} from '#/screens/Settings/Settings' -import {atoms as a, useTheme} from '#/alf' -import {useDialogControl} from '#/components/Dialog' -import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings' -import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' -import * as Layout from '#/components/Layout' -import {Email2FAToggle} from './Email2FAToggle' -import {ExportCarDialog} from './ExportCarDialog' - -function SettingsAccountCard({ - account, - pendingDid, - onPressSwitchAccount, -}: { - account: SessionAccount - pendingDid: string | null - onPressSwitchAccount: ( - account: SessionAccount, - logContext: 'Settings', - ) => void -}) { - const pal = usePalette('default') - const {_} = useLingui() - const t = useTheme() - const {currentAccount} = useSession() - const {data: profile} = useProfileQuery({did: account.did}) - const isCurrentAccount = account.did === currentAccount?.did - - const contents = ( - - - - - - - {profile?.displayName || account.handle} - - - {account.handle} - - - - - ) - - return isCurrentAccount ? ( - - {contents} - - ) : ( - onPressSwitchAccount(account, 'Settings') - } - accessibilityRole="button" - accessibilityLabel={_(msg`Switch to ${account.handle}`)} - accessibilityHint={_(msg`Switches the account you are logged in to`)} - activeOpacity={0.8}> - {contents} - - ) -} - -type Props = NativeStackScreenProps -export function SettingsScreen(props: Props) { - return IS_INTERNAL ? ( - - ) : ( - - ) -} - -function LegacySettingsScreen({}: Props) { - const queryClient = useQueryClient() - const pal = usePalette('default') - const {_} = useLingui() - const setMinimalShellMode = useSetMinimalShellMode() - const inAppBrowserPref = useInAppBrowser() - const setUseInAppBrowser = useSetInAppBrowser() - const onboardingDispatch = useOnboardingDispatch() - const navigation = useNavigation() - const {isMobile} = useWebMediaQueries() - const {openModal} = useModalControls() - const {accounts, currentAccount} = useSession() - const {mutate: clearPreferences} = useClearPreferencesMutation() - const {setShowLoggedOut} = useLoggedOutViewControls() - const {logoutEveryAccount} = useSessionApi() - const closeAllActiveElements = useCloseAllActiveElements() - const exportCarControl = useDialogControl() - const birthdayControl = useDialogControl() - const {pendingDid, onPressSwitchAccount} = useAccountSwitcher() - const isSwitchingAccounts = !!pendingDid - - // const primaryBg = useCustomPalette({ - // light: {backgroundColor: colors.blue0}, - // dark: {backgroundColor: colors.blue6}, - // }) - // const primaryText = useCustomPalette({ - // light: {color: colors.blue3}, - // dark: {color: colors.blue2}, - // }) - - const dangerBg = useCustomPalette({ - light: {backgroundColor: colors.red1}, - dark: {backgroundColor: colors.red7}, - }) - const dangerText = useCustomPalette({ - light: {color: colors.red4}, - dark: {color: colors.red2}, - }) - - useFocusEffect( - React.useCallback(() => { - setMinimalShellMode(false) - }, [setMinimalShellMode]), - ) - - const onPressAddAccount = React.useCallback(() => { - setShowLoggedOut(true) - closeAllActiveElements() - }, [setShowLoggedOut, closeAllActiveElements]) - - const onPressChangeHandle = React.useCallback(() => { - openModal({ - name: 'change-handle', - onChanged() { - if (currentAccount) { - // refresh my profile - queryClient.invalidateQueries({ - queryKey: RQKEY_PROFILE(currentAccount.did), - }) - } - }, - }) - }, [queryClient, openModal, currentAccount]) - - const onPressExportRepository = React.useCallback(() => { - exportCarControl.open() - }, [exportCarControl]) - - const onPressLanguageSettings = React.useCallback(() => { - navigation.navigate('LanguageSettings') - }, [navigation]) - - const onPressDeleteAccount = React.useCallback(() => { - openModal({name: 'delete-account'}) - }, [openModal]) - - const onPressLogoutEveryAccount = React.useCallback(() => { - logoutEveryAccount('Settings') - }, [logoutEveryAccount]) - - const onPressResetPreferences = React.useCallback(async () => { - clearPreferences() - }, [clearPreferences]) - - const onPressResetOnboarding = React.useCallback(async () => { - navigation.navigate('Home') - onboardingDispatch({type: 'start'}) - Toast.show(_(msg`Onboarding reset`)) - }, [navigation, onboardingDispatch, _]) - - const onPressBuildInfo = React.useCallback(() => { - setStringAsync( - `Build version: ${appVersion}; Bundle info: ${bundleInfo}; Bundle date: ${BUNDLE_DATE}; Platform: ${Platform.OS}`, - ) - Toast.show(_(msg`Copied build version to clipboard`)) - }, [_]) - - const openFollowingFeedPreferences = React.useCallback(() => { - navigation.navigate('PreferencesFollowingFeed') - }, [navigation]) - - const openThreadsPreferences = React.useCallback(() => { - navigation.navigate('PreferencesThreads') - }, [navigation]) - - const onPressAppPasswords = React.useCallback(() => { - navigation.navigate('AppPasswords') - }, [navigation]) - - const onPressSystemLog = React.useCallback(() => { - navigation.navigate('Log') - }, [navigation]) - - const onPressStorybook = React.useCallback(() => { - navigation.navigate('Debug') - }, [navigation]) - - const onPressDebugModeration = React.useCallback(() => { - navigation.navigate('DebugMod') - }, [navigation]) - - const onPressSavedFeeds = React.useCallback(() => { - navigation.navigate('SavedFeeds') - }, [navigation]) - - const onPressAccessibilitySettings = React.useCallback(() => { - navigation.navigate('AccessibilitySettings') - }, [navigation]) - - const onPressAppearanceSettings = React.useCallback(() => { - navigation.navigate('AppearanceSettings') - }, [navigation]) - - const onPressBirthday = React.useCallback(() => { - birthdayControl.open() - }, [birthdayControl]) - - const clearAllStorage = React.useCallback(async () => { - await clearStorage() - Toast.show(_(msg`Storage cleared, you need to restart the app now.`)) - }, [_]) - - const deactivateAccountControl = useDialogControl() - const onPressDeactivateAccount = React.useCallback(() => { - deactivateAccountControl.open() - }, [deactivateAccountControl]) - - const {mutate: onPressDeleteChatDeclaration} = useDeleteActorDeclaration() - - return ( - - - - - - - - Settings - - - - - - {currentAccount ? ( - <> - - Account - - - - Email:{' '} - - {currentAccount.emailConfirmed && ( - <> - - - )} - - {currentAccount.email || '(no email)'} - - openModal({name: 'change-email'})}> - - Change - - - - - - Birthday:{' '} - - - - Show - - - - - - {!currentAccount.emailConfirmed && } - - - - Signed in as - - - - - - - - ) : null} - - - {accounts.length > 1 && ( - - - Other accounts - - - - )} - - {accounts - .filter(a => a.did !== currentAccount?.did) - .map(account => ( - - ))} - - - - - - - Add account - - - - - - - - - {accounts.length > 1 ? ( - Sign out of all accounts - ) : ( - Sign out - )} - - - - - - - - Basics - - - - - - - Accessibility - - - - - - - - Appearance - - - - - - - - Languages - - - navigation.navigate('Moderation') - } - accessibilityRole="button" - accessibilityLabel={_(msg`Moderation settings`)} - accessibilityHint={_(msg`Opens moderation settings`)}> - - - - - Moderation - - - - - - - - Following Feed Preferences - - - - - - - - Thread Preferences - - - - - - - - My Saved Feeds - - - navigation.navigate('MessagesSettings') - } - accessibilityRole="button" - accessibilityLabel={_(msg`Chat settings`)} - accessibilityHint={_(msg`Opens chat settings`)}> - - - - - Chat Settings - - - - - - - Privacy - - - navigation.navigate('PreferencesExternalEmbeds') - } - accessibilityRole="button" - accessibilityLabel={_(msg`External media settings`)} - accessibilityHint={_(msg`Opens external embeds settings`)}> - - - - - External Media Preferences - - - - - - - Advanced - - - - - - - App Passwords - - - - - - - - Change Handle - - - {isNative && ( - - setUseInAppBrowser(!inAppBrowserPref)} - /> - - )} - - - Two-factor authentication - - - - - - - Account - - openModal({name: 'change-password'})} - accessibilityRole="button" - accessibilityLabel={_(msg`Change password`)} - accessibilityHint={_( - msg`Opens modal for changing your Bluesky password`, - )}> - - - - - Change Password - - - - - - - - Export My Data - - - - - - - - - Deactivate my account - - - - - - - - - - Delete My Account… - - - - - - System log - - - {__DEV__ ? ( - <> - - - Storybook - - - - - Debug Moderation - - - - - Reset preferences state - - - onPressDeleteChatDeclaration()} - accessibilityRole="button" - accessibilityLabel={_(msg`Delete chat declaration record`)} - accessibilityHint={_(msg`Deletes the chat declaration record`)}> - - Delete chat declaration record - - - - - Reset onboarding state - - - - - Clear all storage data (restart after this) - - - - ) : null} - - - - - Version {appVersion} {bundleInfo} - - - - - - - - - - - - - - ) -} - -function EmailConfirmationNotice() { - const pal = usePalette('default') - const palInverted = usePalette('inverted') - const {_} = useLingui() - const {isMobile} = useWebMediaQueries() - const verifyEmailDialogControl = useDialogControl() - - return ( - - - Verify email - - - - verifyEmailDialogControl.open()}> - - - Verify My Email - - - - - Protect your account by verifying your email. - - - - - ) -} - -const styles = StyleSheet.create({ - dimmed: { - opacity: 0.5, - }, - spacer20: { - height: 20, - }, - heading: { - paddingHorizontal: 18, - paddingBottom: 6, - }, - infoLine: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 18, - paddingBottom: 6, - }, - profile: { - flexDirection: 'row', - marginVertical: 6, - borderRadius: 4, - paddingVertical: 10, - paddingHorizontal: 10, - }, - linkCard: { - flexDirection: 'row', - alignItems: 'center', - paddingVertical: 12, - paddingHorizontal: 18, - marginBottom: 1, - }, - linkCardNoIcon: { - flexDirection: 'row', - alignItems: 'center', - paddingVertical: 20, - paddingHorizontal: 18, - marginBottom: 1, - }, - toggleCard: { - paddingVertical: 8, - paddingHorizontal: 6, - marginBottom: 1, - }, - avi: { - marginRight: 12, - }, - iconContainer: { - alignItems: 'center', - justifyContent: 'center', - width: 40, - height: 40, - borderRadius: 30, - marginRight: 12, - }, - buildInfo: { - paddingVertical: 8, - }, - - colorModeText: { - marginLeft: 10, - marginBottom: 6, - }, - - selectableBtns: { - flexDirection: 'row', - }, - - btn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - width: '100%', - borderRadius: 32, - padding: 14, - backgroundColor: colors.gray1, - }, - toggleBtn: { - paddingHorizontal: 0, - }, - footer: { - flex: 1, - flexDirection: 'row', - paddingLeft: 18, - }, -}) From a90830881014203ee0329b7aee89f4254cbdef4f Mon Sep 17 00:00:00 2001 From: dan Date: Sat, 9 Nov 2024 00:08:00 +0000 Subject: [PATCH 11/35] Revert "Revert "Increase memory on iOS (#6141)" (#6165)" (#6176) This reverts commit 7c45f7dcc7044f46b6d2a3596130db2a108c5e2e. --- app.config.js | 2 ++ 1 file changed, 2 insertions(+) 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: { From a6d009d06bb0d1b2fbb70948a6aba6a0faada415 Mon Sep 17 00:00:00 2001 From: khuddite <62555977+khuddite@users.noreply.github.com> Date: Fri, 8 Nov 2024 19:10:04 -0500 Subject: [PATCH 12/35] set includeFontPadding false for fixed TextInput height on Android (#6170) --- src/view/com/composer/text-input/TextInput.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index 42d9bfc549..10cf1a931b 100644 --- a/src/view/com/composer/text-input/TextInput.tsx +++ b/src/view/com/composer/text-input/TextInput.tsx @@ -252,7 +252,11 @@ export const TextInput = forwardRef(function TextInputImpl( style={[ inputTextStyle, a.w_full, - {textAlignVertical: 'top', minHeight: 60}, + { + textAlignVertical: 'top', + minHeight: 60, + includeFontPadding: false, + }, ]} {...props}> {textDecorated} From bf0a847ffe25a136caed8824d7f725ebef8490cf Mon Sep 17 00:00:00 2001 From: surfdude29 <149612116+surfdude29@users.noreply.github.com> Date: Sat, 9 Nov 2024 07:28:23 +0000 Subject: [PATCH 13/35] Tiny tweak to Thread Preferences string (#6177) --- src/screens/Settings/ThreadPreferences.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/screens/Settings/ThreadPreferences.tsx b/src/screens/Settings/ThreadPreferences.tsx index 96c0f9a117..24dd91bf6a 100644 --- a/src/screens/Settings/ThreadPreferences.tsx +++ b/src/screens/Settings/ThreadPreferences.tsx @@ -111,7 +111,7 @@ export function ThreadPreferencesScreen({}: Props) { style={[a.w_full, a.gap_md]}> - Show replies by people you follow before all other replies. + Show replies by people you follow before all other replies From e73d5c6c207a5da842cdb02a703ef3f130112fa2 Mon Sep 17 00:00:00 2001 From: Trivikram Kamat <16024985+trivikr@users.noreply.github.com> Date: Sat, 9 Nov 2024 13:24:48 -0800 Subject: [PATCH 14/35] Use ENV key=value in Dockerfile (#6100) --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 0fa0065a10..50bdd18cb9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,7 +17,7 @@ ENV GOEXPERIMENT="loopvar" # Expo ARG EXPO_PUBLIC_BUNDLE_IDENTIFIER -ENV EXPO_PUBLIC_BUNDLE_IDENTIFIER ${EXPO_PUBLIC_BUNDLE_IDENTIFIER:-dev} +ENV EXPO_PUBLIC_BUNDLE_IDENTIFIER=${EXPO_PUBLIC_BUNDLE_IDENTIFIER:-dev} COPY . . From 2d73c5a24cf8ad06dbebcf44c8f4f053eedda5a4 Mon Sep 17 00:00:00 2001 From: dan Date: Sat, 9 Nov 2024 22:34:46 +0000 Subject: [PATCH 15/35] [Lightbox] Open animation (#6159) * Measure all rects for embeds * Measure avi rects too * Animate lightbox in and out * Account for safe area in the animation * Tune spring times * Remove null checks for measurements * Remove superfluous view * Block swipe while opening * Interpolate width/height on native side for Android * Make it fast by animating only affine transforms * Fix tall image final state The initial animation frame is still off on both platforms. * Try to squeeze perf * Avoid blank images during animation on iOS * Fix bad rebase * Fix a huge memory issue due to expo/expo#24894 * Fix last frame flash * Fix thum dim calculation for tall images --- src/screens/Profile/Header/Shell.tsx | 48 ++- src/state/lightbox.tsx | 2 - .../com/lightbox/ImageViewing/@types/index.ts | 9 + .../ImageItem/ImageItem.android.tsx | 176 +++++++---- .../components/ImageItem/ImageItem.ios.tsx | 132 ++++++--- .../components/ImageItem/ImageItem.tsx | 27 +- src/view/com/lightbox/ImageViewing/index.tsx | 278 ++++++++++++++++-- src/view/com/profile/ProfileSubpageHeader.tsx | 63 ++-- src/view/com/util/images/AutoSizedImage.tsx | 13 +- src/view/com/util/images/Gallery.tsx | 14 +- src/view/com/util/images/ImageLayoutGrid.tsx | 37 ++- src/view/com/util/post-embeds/index.tsx | 22 +- 12 files changed, 629 insertions(+), 192 deletions(-) diff --git a/src/screens/Profile/Header/Shell.tsx b/src/screens/Profile/Header/Shell.tsx index fe325c1e5f..093b9190a6 100644 --- a/src/screens/Profile/Header/Shell.tsx +++ b/src/screens/Profile/Header/Shell.tsx @@ -1,5 +1,12 @@ import React, {memo} from 'react' import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native' +import Animated, { + measure, + MeasuredDimensions, + runOnJS, + runOnUI, + useAnimatedRef, +} from 'react-native-reanimated' import {AppBskyActorDefs, ModerationDecision} from '@atproto/api' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {msg} from '@lingui/macro' @@ -42,6 +49,7 @@ let ProfileHeaderShell = ({ const {openLightbox} = useLightboxControls() const navigation = useNavigation() const {isDesktop} = useWebMediaQueries() + const aviRef = useAnimatedRef() const onPressBack = React.useCallback(() => { if (navigation.canGoBack()) { @@ -51,14 +59,14 @@ let ProfileHeaderShell = ({ } }, [navigation]) - const onPressAvi = React.useCallback(() => { - const modui = moderation.ui('avatar') - if (profile.avatar && !(modui.blur && modui.noOverride)) { + const _openLightbox = React.useCallback( + (uri: string, thumbRect: MeasuredDimensions | null) => { openLightbox({ images: [ { - uri: profile.avatar, - thumbUri: profile.avatar, + uri, + thumbUri: uri, + thumbRect, dimensions: { // It's fine if it's actually smaller but we know it's 1:1. height: 1000, @@ -68,10 +76,22 @@ let ProfileHeaderShell = ({ }, ], index: 0, - thumbDims: null, }) + }, + [openLightbox], + ) + + const onPressAvi = React.useCallback(() => { + const modui = moderation.ui('avatar') + const avatar = profile.avatar + if (avatar && !(modui.blur && modui.noOverride)) { + runOnUI(() => { + 'worklet' + const rect = measure(aviRef) + runOnJS(_openLightbox)(avatar, rect) + })() } - }, [openLightbox, profile, moderation]) + }, [profile, moderation, _openLightbox, aviRef]) const isMe = React.useMemo( () => currentAccount?.did === profile.did, @@ -149,12 +169,14 @@ let ProfileHeaderShell = ({ styles.avi, profile.associated?.labeler && styles.aviLabeler, ]}> - + + + diff --git a/src/state/lightbox.tsx b/src/state/lightbox.tsx index 06541106e7..67a450991d 100644 --- a/src/state/lightbox.tsx +++ b/src/state/lightbox.tsx @@ -1,5 +1,4 @@ import React from 'react' -import type {MeasuredDimensions} from 'react-native-reanimated' import {nanoid} from 'nanoid/non-secure' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' @@ -8,7 +7,6 @@ import {ImageSource} from '#/view/com/lightbox/ImageViewing/@types' export type Lightbox = { id: string images: ImageSource[] - thumbDims: MeasuredDimensions | null index: number } diff --git a/src/view/com/lightbox/ImageViewing/@types/index.ts b/src/view/com/lightbox/ImageViewing/@types/index.ts index dc636a4495..1a3543c267 100644 --- a/src/view/com/lightbox/ImageViewing/@types/index.ts +++ b/src/view/com/lightbox/ImageViewing/@types/index.ts @@ -6,6 +6,9 @@ * */ +import {TransformsStyle} from 'react-native' +import {MeasuredDimensions} from 'react-native-reanimated' + export type Dimensions = { width: number height: number @@ -19,7 +22,13 @@ export type Position = { export type ImageSource = { uri: string thumbUri: string + thumbRect: MeasuredDimensions | null alt?: string dimensions: Dimensions | null type: 'image' | 'circle-avi' | 'rect-avi' } + +export type Transform = Exclude< + TransformsStyle['transform'], + string | undefined +> 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 f882dcf9eb..069f9eb40b 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx @@ -1,23 +1,26 @@ import React, {useState} from 'react' -import {ActivityIndicator, StyleProp, StyleSheet, View} from 'react-native' +import {ActivityIndicator, StyleSheet} from 'react-native' import { Gesture, GestureDetector, PanGesture, } from 'react-native-gesture-handler' import Animated, { - AnimatedRef, - measure, runOnJS, + SharedValue, useAnimatedReaction, useAnimatedRef, useAnimatedStyle, useSharedValue, withSpring, } from 'react-native-reanimated' -import {Image, ImageStyle} from 'expo-image' +import {Image} from 'expo-image' -import type {Dimensions as ImageDimensions, ImageSource} from '../../@types' +import type { + Dimensions as ImageDimensions, + ImageSource, + Transform, +} from '../../@types' import { applyRounding, createTransform, @@ -28,8 +31,6 @@ import { TransformMatrix, } from '../../transforms' -const AnimatedImage = Animated.createAnimatedComponent(Image) - const MIN_SCREEN_ZOOM = 2 const MAX_ORIGINAL_IMAGE_ZOOM = 2 @@ -42,22 +43,35 @@ type Props = { onZoom: (isZoomed: boolean) => void isScrollViewBeingDragged: boolean showControls: boolean - safeAreaRef: AnimatedRef + measureSafeArea: () => { + x: number + y: number + width: number + height: number + } imageAspect: number | undefined imageDimensions: ImageDimensions | undefined - imageStyle: StyleProp dismissSwipePan: PanGesture + transforms: Readonly< + SharedValue<{ + scaleAndMoveTransform: Transform + cropFrameTransform: Transform + cropContentTransform: Transform + isResting: boolean + isHidden: boolean + }> + > } const ImageItem = ({ imageSrc, onTap, onZoom, isScrollViewBeingDragged, - safeAreaRef, + measureSafeArea, imageAspect, imageDimensions, - imageStyle, dismissSwipePan, + transforms, }: Props) => { const [isScaled, setIsScaled] = useState(false) const committedTransform = useSharedValue(initialTransform) @@ -95,19 +109,6 @@ const ImageItem = ({ onZoom(nextIsScaled) } - const animatedStyle = useAnimatedStyle(() => { - // Apply the active adjustments on top of the committed transform before the gestures. - // This is matrix multiplication, so operations are applied in the reverse order. - let t = createTransform() - prependPan(t, panTranslation.value) - prependPinch(t, pinchScale.value, pinchOrigin.value, pinchTranslation.value) - prependTransform(t, committedTransform.value) - const [translateX, translateY, scale] = readTransform(t) - return { - transform: [{translateX}, {translateY: translateY}, {scale}], - } - }) - // On Android, stock apps prevent going "out of bounds" on pan or pinch. You should "bump" into edges. // If the user tried to pan too hard, this function will provide the negative panning to stay in bounds. function getExtraTranslationToStayInBounds( @@ -143,10 +144,7 @@ const ImageItem = ({ const pinch = Gesture.Pinch() .onStart(e => { 'worklet' - const screenSize = measure(safeAreaRef) - if (!screenSize) { - return - } + const screenSize = measureSafeArea() pinchOrigin.value = { x: e.focalX - screenSize.width / 2, y: e.focalY - screenSize.height / 2, @@ -154,8 +152,8 @@ const ImageItem = ({ }) .onChange(e => { 'worklet' - const screenSize = measure(safeAreaRef) - if (!imageDimensions || !screenSize) { + const screenSize = measureSafeArea() + if (!imageDimensions) { return } // Don't let the picture zoom in so close that it gets blurry. @@ -213,8 +211,8 @@ const ImageItem = ({ .minPointers(isScaled ? 1 : 2) .onChange(e => { 'worklet' - const screenSize = measure(safeAreaRef) - if (!imageDimensions || !screenSize) { + const screenSize = measureSafeArea() + if (!imageDimensions) { return } @@ -257,8 +255,8 @@ const ImageItem = ({ .numberOfTaps(2) .onEnd(e => { 'worklet' - const screenSize = measure(safeAreaRef) - if (!imageDimensions || !imageAspect || !screenSize) { + const screenSize = measureSafeArea() + if (!imageDimensions || !imageAspect) { return } const [, , committedScale] = readTransform(committedTransform.value) @@ -302,11 +300,6 @@ const ImageItem = ({ committedTransform.value = withClampedSpring(finalTransform) }) - const innerStyle = useAnimatedStyle(() => ({ - width: '100%', - aspectRatio: imageAspect, - })) - const composedGesture = isScrollViewBeingDragged ? // If the parent is not at rest, provide a no-op gesture. Gesture.Manual() @@ -317,29 +310,97 @@ const ImageItem = ({ singleTap, ) + const containerStyle = useAnimatedStyle(() => { + const {scaleAndMoveTransform, isHidden} = transforms.value + // Apply the active adjustments on top of the committed transform before the gestures. + // This is matrix multiplication, so operations are applied in the reverse order. + let t = createTransform() + prependPan(t, panTranslation.value) + prependPinch(t, pinchScale.value, pinchOrigin.value, pinchTranslation.value) + prependTransform(t, committedTransform.value) + const [translateX, translateY, scale] = readTransform(t) + const manipulationTransform = [ + {translateX}, + {translateY: translateY}, + {scale}, + ] + const screenSize = measureSafeArea() + return { + opacity: isHidden ? 0 : 1, + transform: scaleAndMoveTransform.concat(manipulationTransform), + width: screenSize.width, + maxHeight: screenSize.height, + aspectRatio: imageAspect, + alignSelf: 'center', + } + }) + + const imageCropStyle = useAnimatedStyle(() => { + const {cropFrameTransform} = transforms.value + return { + flex: 1, + overflow: 'hidden', + transform: cropFrameTransform, + } + }) + + const imageStyle = useAnimatedStyle(() => { + const {cropContentTransform} = transforms.value + return { + flex: 1, + transform: cropContentTransform, + } + }) + + const [showLoader, setShowLoader] = useState(false) + const [hasLoaded, setHasLoaded] = useState(false) + useAnimatedReaction( + () => { + return transforms.value.isResting && !hasLoaded + }, + (show, prevShow) => { + if (show && !prevShow) { + runOnJS(setShowLoader)(false) + } else if (!prevShow && show) { + runOnJS(setShowLoader)(true) + } + }, + ) + const type = imageSrc.type const borderRadius = type === 'circle-avi' ? 1e5 : type === 'rect-avi' ? 20 : 0 + return ( - - - - + + + {showLoader && ( + + )} + + + setHasLoaded(false)} + style={{flex: 1, borderRadius}} + accessibilityHint="" + accessibilityIgnoresInvertColors + cachePolicy="memory" + /> + + @@ -358,6 +419,7 @@ const styles = StyleSheet.create({ right: 0, top: 0, bottom: 0, + justifyContent: 'center', }, }) 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 e876479a39..7a9a18b91b 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,28 @@ */ import React, {useState} from 'react' -import {ActivityIndicator, StyleProp, StyleSheet, View} from 'react-native' +import {ActivityIndicator, StyleSheet} from 'react-native' import { Gesture, GestureDetector, PanGesture, } from 'react-native-gesture-handler' import Animated, { - AnimatedRef, - measure, runOnJS, + SharedValue, + useAnimatedReaction, useAnimatedRef, useAnimatedStyle, } from 'react-native-reanimated' import {useSafeAreaFrame} from 'react-native-safe-area-context' -import {Image, ImageStyle} from 'expo-image' +import {Image} from 'expo-image' import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' -import {Dimensions as ImageDimensions, ImageSource} from '../../@types' - -const AnimatedImage = Animated.createAnimatedComponent(Image) +import { + Dimensions as ImageDimensions, + ImageSource, + Transform, +} from '../../@types' const MAX_ORIGINAL_IMAGE_ZOOM = 2 const MIN_SCREEN_ZOOM = 2 @@ -38,11 +40,24 @@ type Props = { onZoom: (scaled: boolean) => void isScrollViewBeingDragged: boolean showControls: boolean - safeAreaRef: AnimatedRef + measureSafeArea: () => { + x: number + y: number + width: number + height: number + } imageAspect: number | undefined imageDimensions: ImageDimensions | undefined - imageStyle: StyleProp dismissSwipePan: PanGesture + transforms: Readonly< + SharedValue<{ + scaleAndMoveTransform: Transform + cropFrameTransform: Transform + cropContentTransform: Transform + isResting: boolean + isHidden: boolean + }> + > } const ImageItem = ({ @@ -50,11 +65,11 @@ const ImageItem = ({ onTap, onZoom, showControls, - safeAreaRef, + measureSafeArea, imageAspect, imageDimensions, - imageStyle, dismissSwipePan, + transforms, }: Props) => { const scrollViewRef = useAnimatedRef() const [scaled, setScaled] = useState(false) @@ -67,16 +82,6 @@ const ImageItem = ({ : 1, ) - const animatedStyle = useAnimatedStyle(() => { - const screenSize = measure(safeAreaRef) ?? screenSizeDelayedForJSThreadOnly - return { - width: screenSize.width, - maxHeight: screenSize.height, - alignSelf: 'center', - aspectRatio: imageAspect, - } - }) - const scrollHandler = useAnimatedScrollHandler({ onScroll(e) { const nextIsScaled = e.zoomScale > 1 @@ -114,10 +119,7 @@ const ImageItem = ({ .numberOfTaps(2) .onEnd(e => { 'worklet' - const screenSize = measure(safeAreaRef) - if (!screenSize) { - return - } + const screenSize = measureSafeArea() const {absoluteX, absoluteY} = e let nextZoomRect = { x: 0, @@ -143,9 +145,56 @@ const ImageItem = ({ singleTap, ) + const containerStyle = useAnimatedStyle(() => { + const {scaleAndMoveTransform, isHidden} = transforms.value + return { + flex: 1, + transform: scaleAndMoveTransform, + opacity: isHidden ? 0 : 1, + } + }) + + const imageCropStyle = useAnimatedStyle(() => { + const screenSize = measureSafeArea() + const {cropFrameTransform} = transforms.value + return { + overflow: 'hidden', + transform: cropFrameTransform, + width: screenSize.width, + maxHeight: screenSize.height, + aspectRatio: imageAspect, + alignSelf: 'center', + } + }) + + const imageStyle = useAnimatedStyle(() => { + const {cropContentTransform} = transforms.value + return { + transform: cropContentTransform, + width: '100%', + aspectRatio: imageAspect, + } + }) + + const [showLoader, setShowLoader] = useState(false) + const [hasLoaded, setHasLoaded] = useState(false) + useAnimatedReaction( + () => { + return transforms.value.isResting && !hasLoaded + }, + (show, prevShow) => { + if (show && !prevShow) { + runOnJS(setShowLoader)(false) + } else if (!prevShow && show) { + runOnJS(setShowLoader)(true) + } + }, + ) + const type = imageSrc.type const borderRadius = type === 'circle-avi' ? 1e5 : type === 'rect-avi' ? 20 : 0 + return ( - - + {showLoader && ( + + )} + + + setHasLoaded(true)} + /> + + ) diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx index 1cd6b00204..543fad7726 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx @@ -1,11 +1,15 @@ // default implementation fallback for web import React from 'react' -import {ImageStyle, StyleProp, View} from 'react-native' +import {View} from 'react-native' import {PanGesture} from 'react-native-gesture-handler' -import {AnimatedRef} from 'react-native-reanimated' +import {SharedValue} from 'react-native-reanimated' -import {Dimensions as ImageDimensions, ImageSource} from '../../@types' +import { + Dimensions as ImageDimensions, + ImageSource, + Transform, +} from '../../@types' type Props = { imageSrc: ImageSource @@ -14,11 +18,24 @@ type Props = { onZoom: (scaled: boolean) => void isScrollViewBeingDragged: boolean showControls: boolean - safeAreaRef: AnimatedRef + measureSafeArea: () => { + x: number + y: number + width: number + height: number + } imageAspect: number | undefined imageDimensions: ImageDimensions | undefined - imageStyle: StyleProp dismissSwipePan: PanGesture + transforms: Readonly< + SharedValue<{ + scaleAndMoveTransform: Transform + cropFrameTransform: Transform + cropContentTransform: Transform + isResting: boolean + isHidden: boolean + }> + > } const ImageItem = (_props: Props) => { diff --git a/src/view/com/lightbox/ImageViewing/index.tsx b/src/view/com/lightbox/ImageViewing/index.tsx index 0a01c7fb3a..030c8dcf30 100644 --- a/src/view/com/lightbox/ImageViewing/index.tsx +++ b/src/view/com/lightbox/ImageViewing/index.tsx @@ -9,23 +9,36 @@ // https://github.com/jobtoday/react-native-image-viewing import React, {useCallback, useState} from 'react' -import {LayoutAnimation, Platform, StyleSheet, View} from 'react-native' +import { + LayoutAnimation, + PixelRatio, + Platform, + StyleSheet, + View, +} from 'react-native' import {Gesture} from 'react-native-gesture-handler' import PagerView from 'react-native-pager-view' import Animated, { AnimatedRef, cancelAnimation, + interpolate, measure, runOnJS, SharedValue, useAnimatedReaction, useAnimatedRef, useAnimatedStyle, + useDerivedValue, useSharedValue, withDecay, withSpring, } from 'react-native-reanimated' -import {Edge, SafeAreaView} from 'react-native-safe-area-context' +import { + Edge, + SafeAreaView, + useSafeAreaFrame, + useSafeAreaInsets, +} from 'react-native-safe-area-context' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {Trans} from '@lingui/macro' @@ -36,17 +49,24 @@ import {Lightbox} from '#/state/lightbox' import {Button} from '#/view/com/util/forms/Button' import {Text} from '#/view/com/util/text/Text' import {ScrollView} from '#/view/com/util/Views' -import {ImageSource} from './@types' +import {PlatformInfo} from '../../../../../modules/expo-bluesky-swiss-army' +import {ImageSource, Transform} from './@types' import ImageDefaultHeader from './components/ImageDefaultHeader' import ImageItem from './components/ImageItem/ImageItem' +type Rect = {x: number; y: number; width: number; height: number} + +const PIXEL_RATIO = PixelRatio.get() const EDGES = Platform.OS === 'android' ? (['top', 'bottom', 'left', 'right'] satisfies Edge[]) : (['left', 'right'] satisfies Edge[]) // iOS, so no top/bottom safe area +const SLOW_SPRING = {stiffness: 120} +const FAST_SPRING = {stiffness: 700} + export default function ImageViewRoot({ - lightbox, + lightbox: nextLightbox, onRequestClose, onPressSave, onPressShare, @@ -56,24 +76,70 @@ export default function ImageViewRoot({ onPressSave: (uri: string) => void onPressShare: (uri: string) => void }) { + 'use no memo' const ref = useAnimatedRef() + const [activeLightbox, setActiveLightbox] = useState(nextLightbox) + const openProgress = useSharedValue(0) + + if (!activeLightbox && nextLightbox) { + setActiveLightbox(nextLightbox) + } + + React.useEffect(() => { + if (!nextLightbox) { + return + } + + const canAnimate = + !PlatformInfo.getIsReducedMotionEnabled() && + nextLightbox.images.every(img => img.dimensions && img.thumbRect) + + // https://github.com/software-mansion/react-native-reanimated/issues/6677 + requestAnimationFrame(() => { + openProgress.value = canAnimate ? withClampedSpring(1, SLOW_SPRING) : 1 + }) + return () => { + // https://github.com/software-mansion/react-native-reanimated/issues/6677 + requestAnimationFrame(() => { + openProgress.value = canAnimate ? withClampedSpring(0, SLOW_SPRING) : 0 + }) + } + }, [nextLightbox, openProgress]) + + useAnimatedReaction( + () => openProgress.value === 0, + (isGone, wasGone) => { + if (isGone && !wasGone) { + runOnJS(setActiveLightbox)(null) + } + }, + ) + + const onFlyAway = React.useCallback(() => { + 'worklet' + openProgress.value = 0 + runOnJS(onRequestClose)() + }, [onRequestClose, openProgress]) + return ( // Keep it always mounted to avoid flicker on the first frame. + aria-hidden={!activeLightbox}> - {lightbox && ( + {activeLightbox && ( )} @@ -86,13 +152,17 @@ function ImageView({ onRequestClose, onPressSave, onPressShare, + onFlyAway, safeAreaRef, + openProgress, }: { lightbox: Lightbox onRequestClose: () => void onPressSave: (uri: string) => void onPressShare: (uri: string) => void + onFlyAway: () => void safeAreaRef: AnimatedRef + openProgress: SharedValue }) { const {images, index: initialImageIndex} = lightbox const [isScaled, setIsScaled] = useState(false) @@ -104,33 +174,41 @@ function ImageView({ const isFlyingAway = useSharedValue(false) const containerStyle = useAnimatedStyle(() => { - if (isFlyingAway.value) { + if (openProgress.value < 1 || isFlyingAway.value) { return {pointerEvents: 'none'} } return {pointerEvents: 'auto'} }) + const backdropStyle = useAnimatedStyle(() => { const screenSize = measure(safeAreaRef) let opacity = 1 - if (screenSize) { + if (openProgress.value < 1) { + opacity = Math.sqrt(openProgress.value) + } else if (screenSize) { const dragProgress = Math.min( Math.abs(dismissSwipeTranslateY.value) / (screenSize.height / 2), 1, ) opacity -= dragProgress } + const factor = isIOS ? 100 : 50 return { - opacity, + opacity: Math.round(opacity * factor) / factor, } }) + const animatedHeaderStyle = useAnimatedStyle(() => { const show = showControls && dismissSwipeTranslateY.value === 0 return { pointerEvents: show ? 'box-none' : 'none', - opacity: withClampedSpring(show ? 1 : 0), + opacity: withClampedSpring( + show && openProgress.value === 1 ? 1 : 0, + FAST_SPRING, + ), transform: [ { - translateY: withClampedSpring(show ? 0 : -30), + translateY: withClampedSpring(show ? 0 : -30, FAST_SPRING), }, ], } @@ -140,10 +218,13 @@ function ImageView({ return { flexGrow: 1, pointerEvents: show ? 'box-none' : 'none', - opacity: withClampedSpring(show ? 1 : 0), + opacity: withClampedSpring( + show && openProgress.value === 1 ? 1 : 0, + FAST_SPRING, + ), transform: [ { - translateY: withClampedSpring(show ? 0 : 30), + translateY: withClampedSpring(show ? 0 : 30, FAST_SPRING), }, ], } @@ -172,7 +253,7 @@ function ImageView({ if (isOut && !wasOut) { // Stop the animation from blocking the screen forever. cancelAnimation(dismissSwipeTranslateY) - runOnJS(onRequestClose)() + onFlyAway() } }, ) @@ -209,6 +290,7 @@ function ImageView({ isFlyingAway={isFlyingAway} isActive={i === imageIndex} dismissSwipeTranslateY={dismissSwipeTranslateY} + openProgress={openProgress} /> ))} @@ -247,6 +329,7 @@ function LightboxImage({ isActive, showControls, safeAreaRef, + openProgress, dismissSwipeTranslateY, }: { imageSrc: ImageSource @@ -259,6 +342,7 @@ function LightboxImage({ isFlyingAway: SharedValue showControls: boolean safeAreaRef: AnimatedRef + openProgress: SharedValue dismissSwipeTranslateY: SharedValue }) { const [imageAspect, imageDimensions] = useImageDimensions({ @@ -266,6 +350,65 @@ function LightboxImage({ knownDimensions: imageSrc.dimensions, }) + const safeFrameDelayedForJSThreadOnly = useSafeAreaFrame() + const safeInsetsDelayedForJSThreadOnly = useSafeAreaInsets() + const measureSafeArea = React.useCallback(() => { + 'worklet' + let safeArea: Rect | null = measure(safeAreaRef) + if (!safeArea) { + if (_WORKLET) { + console.error('Expected to always be able to measure safe area.') + } + const frame = safeFrameDelayedForJSThreadOnly + const insets = safeInsetsDelayedForJSThreadOnly + safeArea = { + x: frame.x + insets.left, + y: frame.y + insets.top, + width: frame.width - insets.left - insets.right, + height: frame.height - insets.top - insets.bottom, + } + } + return safeArea + }, [ + safeFrameDelayedForJSThreadOnly, + safeInsetsDelayedForJSThreadOnly, + safeAreaRef, + ]) + + const {thumbRect} = imageSrc + const transforms = useDerivedValue(() => { + 'worklet' + const safeArea = measureSafeArea() + const dismissTranslateY = + isActive && openProgress.value === 1 ? dismissSwipeTranslateY.value : 0 + + if (openProgress.value === 0 && isFlyingAway.value) { + return { + isHidden: true, + isResting: false, + scaleAndMoveTransform: [], + cropFrameTransform: [], + cropContentTransform: [], + } + } + + if (isActive && thumbRect && imageAspect && openProgress.value < 1) { + return interpolateTransform( + openProgress.value, + thumbRect, + safeArea, + imageAspect, + ) + } + return { + isHidden: false, + isResting: dismissTranslateY === 0, + scaleAndMoveTransform: [{translateY: dismissTranslateY}], + cropFrameTransform: [], + cropContentTransform: [], + } + }) + const dismissSwipePan = Gesture.Pan() .enabled(isActive && !isScaled) .activeOffsetY([-10, 10]) @@ -273,14 +416,14 @@ function LightboxImage({ .maxPointers(1) .onUpdate(e => { 'worklet' - if (isFlyingAway.value) { + if (openProgress.value !== 1 || isFlyingAway.value) { return } dismissSwipeTranslateY.value = e.translationY }) .onEnd(e => { 'worklet' - if (isFlyingAway.value) { + if (openProgress.value !== 1 || isFlyingAway.value) { return } if (Math.abs(e.velocityY) > 1000) { @@ -303,11 +446,6 @@ function LightboxImage({ } }) - const imageStyle = useAnimatedStyle(() => { - return { - transform: [{translateY: dismissSwipeTranslateY.value}], - } - }) return ( ) } @@ -476,7 +614,91 @@ const styles = StyleSheet.create({ }, }) -function withClampedSpring(value: any) { +function interpolatePx( + px: number, + inputRange: readonly number[], + outputRange: readonly number[], +) { 'worklet' - return withSpring(value, {overshootClamping: true, stiffness: 300}) + const value = interpolate(px, inputRange, outputRange) + return Math.round(value * PIXEL_RATIO) / PIXEL_RATIO +} + +function interpolateTransform( + progress: number, + thumbnailDims: { + pageX: number + width: number + pageY: number + height: number + }, + safeArea: {width: number; height: number; x: number; y: number}, + imageAspect: number, +): { + scaleAndMoveTransform: Transform + cropFrameTransform: Transform + cropContentTransform: Transform + isResting: boolean + isHidden: boolean +} { + 'worklet' + const thumbAspect = thumbnailDims.width / thumbnailDims.height + let uncroppedInitialWidth + let uncroppedInitialHeight + if (imageAspect > thumbAspect) { + uncroppedInitialWidth = thumbnailDims.height * imageAspect + uncroppedInitialHeight = thumbnailDims.height + } else { + uncroppedInitialWidth = thumbnailDims.width + uncroppedInitialHeight = thumbnailDims.width / imageAspect + } + const safeAreaAspect = safeArea.width / safeArea.height + let finalWidth + let finalHeight + if (safeAreaAspect > imageAspect) { + finalWidth = safeArea.height * imageAspect + finalHeight = safeArea.height + } else { + finalWidth = safeArea.width + finalHeight = safeArea.width / imageAspect + } + const initialScale = Math.min( + uncroppedInitialWidth / finalWidth, + uncroppedInitialHeight / finalHeight, + ) + const croppedFinalWidth = thumbnailDims.width / initialScale + const croppedFinalHeight = thumbnailDims.height / initialScale + const screenCenterX = safeArea.width / 2 + const screenCenterY = safeArea.height / 2 + const thumbnailSafeAreaX = thumbnailDims.pageX - safeArea.x + const thumbnailSafeAreaY = thumbnailDims.pageY - safeArea.y + const thumbnailCenterX = thumbnailSafeAreaX + thumbnailDims.width / 2 + const thumbnailCenterY = thumbnailSafeAreaY + thumbnailDims.height / 2 + const initialTranslateX = thumbnailCenterX - screenCenterX + const initialTranslateY = thumbnailCenterY - screenCenterY + const scale = interpolate(progress, [0, 1], [initialScale, 1]) + const translateX = interpolatePx(progress, [0, 1], [initialTranslateX, 0]) + const translateY = interpolatePx(progress, [0, 1], [initialTranslateY, 0]) + const cropScaleX = interpolate( + progress, + [0, 1], + [croppedFinalWidth / finalWidth, 1], + ) + const cropScaleY = interpolate( + progress, + [0, 1], + [croppedFinalHeight / finalHeight, 1], + ) + return { + isHidden: false, + isResting: progress === 1, + scaleAndMoveTransform: [{translateX}, {translateY}, {scale}], + cropFrameTransform: [{scaleX: cropScaleX}, {scaleY: cropScaleY}], + cropContentTransform: [{scaleX: 1 / cropScaleX}, {scaleY: 1 / cropScaleY}], + } +} + +function withClampedSpring(value: any, {stiffness}: {stiffness: number}) { + 'worklet' + return withSpring(value, {overshootClamping: true, stiffness}) } diff --git a/src/view/com/profile/ProfileSubpageHeader.tsx b/src/view/com/profile/ProfileSubpageHeader.tsx index 5208224c50..13d14ec502 100644 --- a/src/view/com/profile/ProfileSubpageHeader.tsx +++ b/src/view/com/profile/ProfileSubpageHeader.tsx @@ -1,5 +1,12 @@ import React from 'react' import {Pressable, StyleSheet, View} from 'react-native' +import Animated, { + measure, + MeasuredDimensions, + runOnJS, + runOnUI, + useAnimatedRef, +} from 'react-native-reanimated' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -53,6 +60,7 @@ export function ProfileSubpageHeader({ const {openLightbox} = useLightboxControls() const pal = usePalette('default') const canGoBack = navigation.canGoBack() + const aviRef = useAnimatedRef() const onPressBack = React.useCallback(() => { if (navigation.canGoBack()) { @@ -66,15 +74,14 @@ export function ProfileSubpageHeader({ setDrawerOpen(true) }, [setDrawerOpen]) - const onPressAvi = React.useCallback(() => { - if ( - avatar // TODO && !(view.moderation.avatar.blur && view.moderation.avatar.noOverride) - ) { + const _openLightbox = React.useCallback( + (uri: string, thumbRect: MeasuredDimensions | null) => { openLightbox({ images: [ { - uri: avatar, - thumbUri: avatar, + uri, + thumbUri: uri, + thumbRect, dimensions: { // It's fine if it's actually smaller but we know it's 1:1. height: 1000, @@ -84,10 +91,22 @@ export function ProfileSubpageHeader({ }, ], index: 0, - thumbDims: null, }) + }, + [openLightbox], + ) + + const onPressAvi = React.useCallback(() => { + if ( + avatar // TODO && !(view.moderation.avatar.blur && view.moderation.avatar.noOverride) + ) { + runOnUI(() => { + 'worklet' + const rect = measure(aviRef) + runOnJS(_openLightbox)(avatar, rect) + })() } - }, [openLightbox, avatar]) + }, [_openLightbox, avatar, aviRef]) return ( @@ -135,19 +154,21 @@ export function ProfileSubpageHeader({ paddingBottom: 6, paddingHorizontal: isMobile ? 12 : 14, }}> - - {avatarType === 'starter-pack' ? ( - - ) : ( - - )} - + + + {avatarType === 'starter-pack' ? ( + + ) : ( + + )} + + {isLoading ? ( void + onPress?: (containerRef: AnimatedRef>) => void onLongPress?: () => void onPressIn?: () => void }) { @@ -107,12 +108,14 @@ export function AutoSizedImage({ src: image.thumb, knownDimensions: image.aspectRatio ?? null, }) + const containerRef = useAnimatedRef() + const cropDisabled = crop === 'none' const isCropped = rawIsCropped && !cropDisabled const hasAlt = !!image.alt const contents = ( - <> + ) : null} - + ) if (cropDisabled) { return ( onPress?.(containerRef)} onLongPress={onLongPress} onPressIn={onPressIn} // alt here is what screen readers actually use @@ -213,7 +216,7 @@ export function AutoSizedImage({ fullBleed={crop === 'square'} aspectRatio={constrained ?? 1}> onPress?.(containerRef)} onLongPress={onLongPress} onPressIn={onPressIn} // alt here is what screen readers actually use diff --git a/src/view/com/util/images/Gallery.tsx b/src/view/com/util/images/Gallery.tsx index d4d7d223d5..0c691ec9af 100644 --- a/src/view/com/util/images/Gallery.tsx +++ b/src/view/com/util/images/Gallery.tsx @@ -1,6 +1,6 @@ import React from 'react' import {Pressable, StyleProp, View, ViewStyle} from 'react-native' -import Animated, {AnimatedRef, useAnimatedRef} from 'react-native-reanimated' +import Animated, {AnimatedRef} from 'react-native-reanimated' import {Image, ImageStyle} from 'expo-image' import {AppBskyEmbedImages} from '@atproto/api' import {msg} from '@lingui/macro' @@ -19,13 +19,14 @@ interface Props { index: number onPress?: ( index: number, - containerRef: AnimatedRef>, + containerRefs: AnimatedRef>[], ) => void onLongPress?: EventFunction onPressIn?: EventFunction imageStyle?: StyleProp viewContext?: PostEmbedViewContext insetBorderStyle?: StyleProp + containerRefs: AnimatedRef>[] } export function GalleryItem({ @@ -37,6 +38,7 @@ export function GalleryItem({ onLongPress, viewContext, insetBorderStyle, + containerRefs, }: Props) { const t = useTheme() const {_} = useLingui() @@ -45,11 +47,13 @@ export function GalleryItem({ const hasAlt = !!image.alt const hideBadges = viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia - const containerRef = useAnimatedRef() return ( - + onPress(index, containerRef) : undefined} + onPress={onPress ? () => onPress(index, containerRefs) : undefined} onPressIn={onPressIn ? () => onPressIn(index) : undefined} onLongPress={onLongPress ? () => onLongPress(index) : undefined} style={[ diff --git a/src/view/com/util/images/ImageLayoutGrid.tsx b/src/view/com/util/images/ImageLayoutGrid.tsx index 9d6a498362..b9b966302a 100644 --- a/src/view/com/util/images/ImageLayoutGrid.tsx +++ b/src/view/com/util/images/ImageLayoutGrid.tsx @@ -1,6 +1,6 @@ import React from 'react' import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' -import {AnimatedRef} from 'react-native-reanimated' +import {AnimatedRef, useAnimatedRef} from 'react-native-reanimated' import {AppBskyEmbedImages} from '@atproto/api' import {PostEmbedViewContext} from '#/view/com/util/post-embeds/types' @@ -11,7 +11,7 @@ interface ImageLayoutGridProps { images: AppBskyEmbedImages.ViewImage[] onPress?: ( index: number, - containerRef: AnimatedRef>, + containerRefs: AnimatedRef>[], ) => void onLongPress?: (index: number) => void onPressIn?: (index: number) => void @@ -41,7 +41,7 @@ interface ImageLayoutGridInnerProps { images: AppBskyEmbedImages.ViewImage[] onPress?: ( index: number, - containerRef: AnimatedRef>, + containerRefs: AnimatedRef>[], ) => void onLongPress?: (index: number) => void onPressIn?: (index: number) => void @@ -53,8 +53,14 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) { const gap = props.gap const count = props.images.length + const containerRef1 = useAnimatedRef() + const containerRef2 = useAnimatedRef() + const containerRef3 = useAnimatedRef() + const containerRef4 = useAnimatedRef() + switch (count) { - case 2: + case 2: { + const containerRefs = [containerRef1, containerRef2] return ( @@ -62,6 +68,7 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) { {...props} index={0} insetBorderStyle={noCorners(['topRight', 'bottomRight'])} + containerRefs={containerRefs} /> @@ -69,12 +76,15 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) { {...props} index={1} insetBorderStyle={noCorners(['topLeft', 'bottomLeft'])} + containerRefs={containerRefs} /> ) + } - case 3: + case 3: { + const containerRefs = [containerRef1, containerRef2, containerRef3] return ( @@ -82,6 +92,7 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) { {...props} index={0} insetBorderStyle={noCorners(['topRight', 'bottomRight'])} + containerRefs={containerRefs} /> @@ -94,6 +105,7 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) { 'bottomLeft', 'bottomRight', ])} + containerRefs={containerRefs} /> @@ -105,13 +117,21 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) { 'bottomLeft', 'topRight', ])} + containerRefs={containerRefs} /> ) + } - case 4: + case 4: { + const containerRefs = [ + containerRef1, + containerRef2, + containerRef3, + containerRef4, + ] return ( <> @@ -124,6 +144,7 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) { 'topRight', 'bottomRight', ])} + containerRefs={containerRefs} /> @@ -135,6 +156,7 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) { 'bottomLeft', 'bottomRight', ])} + containerRefs={containerRefs} /> @@ -148,6 +170,7 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) { 'topRight', 'bottomRight', ])} + containerRefs={containerRefs} /> @@ -159,11 +182,13 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) { 'bottomLeft', 'topRight', ])} + containerRefs={containerRefs} /> ) + } default: return null diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx index ea0badab00..ab2471b33d 100644 --- a/src/view/com/util/post-embeds/index.tsx +++ b/src/view/com/util/post-embeds/index.tsx @@ -6,13 +6,12 @@ import { View, ViewStyle, } from 'react-native' -import Animated, { +import { AnimatedRef, measure, MeasuredDimensions, runOnJS, runOnUI, - useAnimatedRef, } from 'react-native-reanimated' import {Image} from 'expo-image' import { @@ -69,7 +68,6 @@ export function PostEmbeds({ viewContext?: PostEmbedViewContext }) { const {openLightbox} = useLightboxControls() - const containerRef = useAnimatedRef() // quote post with media // = @@ -149,25 +147,25 @@ export function PostEmbeds({ })) const _openLightbox = ( index: number, - thumbDims: MeasuredDimensions | null, + thumbRects: (MeasuredDimensions | null)[], ) => { openLightbox({ - images: items.map(item => ({ + images: items.map((item, i) => ({ ...item, + thumbRect: thumbRects[i] ?? null, type: 'image', })), index, - thumbDims, }) } const onPress = ( index: number, - ref: AnimatedRef>, + refs: AnimatedRef>[], ) => { runOnUI(() => { 'worklet' - const dims = measure(ref) - runOnJS(_openLightbox)(index, dims) + const rects = refs.map(ref => (ref ? measure(ref) : null)) + runOnJS(_openLightbox)(index, rects) })() } const onPressIn = (_: number) => { @@ -180,7 +178,7 @@ export function PostEmbeds({ const image = images[0] return ( - + onPress(0, containerRef)} + onPress={containerRef => onPress(0, [containerRef])} onPressIn={() => onPressIn(0)} hideBadge={ viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia } /> - + ) } From 42abd97f61b6813ab7dc6126d167b4130532326a Mon Sep 17 00:00:00 2001 From: dan Date: Sat, 9 Nov 2024 22:41:14 +0000 Subject: [PATCH 16/35] [Lightbox] Always rely on Expo Image cache (#6189) * Inline useImageAspectRatio * Switch AutoSizedImage to read dimensions from Expo Image cache * Include thumbnail dimensions in image data * Use dims from Expo Image cache in lightbox * Fix wiring so all thumbnails get dimensions * Fix type * Oops --- src/lib/media/image-sizes.ts | 93 ------------------- src/screens/Profile/Header/Shell.tsx | 1 + .../com/lightbox/ImageViewing/@types/index.ts | 3 +- .../ImageItem/ImageItem.android.tsx | 10 +- .../components/ImageItem/ImageItem.ios.tsx | 13 ++- .../components/ImageItem/ImageItem.tsx | 2 + src/view/com/lightbox/ImageViewing/index.tsx | 22 +++-- src/view/com/profile/ProfileSubpageHeader.tsx | 1 + src/view/com/util/images/AutoSizedImage.tsx | 67 ++++++------- src/view/com/util/images/Gallery.tsx | 16 +++- src/view/com/util/images/ImageLayoutGrid.tsx | 13 +++ src/view/com/util/post-embeds/index.tsx | 10 +- 12 files changed, 105 insertions(+), 146 deletions(-) delete mode 100644 src/lib/media/image-sizes.ts diff --git a/src/lib/media/image-sizes.ts b/src/lib/media/image-sizes.ts deleted file mode 100644 index 8eaa9467f9..0000000000 --- a/src/lib/media/image-sizes.ts +++ /dev/null @@ -1,93 +0,0 @@ -import {useEffect, useState} from 'react' -import {Image} from 'react-native' - -import type {Dimensions} from '#/lib/media/types' - -type CacheStorageItem = {key: string; value: T} -const createCache = (cacheSize: number) => ({ - _storage: [] as CacheStorageItem[], - get(key: string) { - const {value} = - this._storage.find(({key: storageKey}) => storageKey === key) || {} - return value - }, - set(key: string, value: T) { - if (this._storage.length >= cacheSize) { - this._storage.shift() - } - this._storage.push({key, value}) - }, -}) - -const sizes = createCache(50) -const activeRequests: Map> = new Map() - -export function get(uri: string): Dimensions | undefined { - return sizes.get(uri) -} - -export function fetch(uri: string): Promise { - const dims = sizes.get(uri) - if (dims) { - return Promise.resolve(dims) - } - const activeRequest = activeRequests.get(uri) - if (activeRequest) { - return activeRequest - } - const prom = new Promise((resolve, reject) => { - Image.getSize( - uri, - (width: number, height: number) => { - const size = {width, height} - sizes.set(uri, size) - resolve(size) - }, - (err: any) => { - console.error('Failed to fetch image dimensions for', uri, err) - reject(new Error('Could not fetch dimensions')) - }, - ) - }).finally(() => { - activeRequests.delete(uri) - }) - activeRequests.set(uri, prom) - return prom -} - -export function useImageDimensions({ - src, - knownDimensions, -}: { - src: string - knownDimensions: Dimensions | null -}): [number | undefined, Dimensions | undefined] { - const [dims, setDims] = useState(() => knownDimensions ?? get(src)) - const [prevSrc, setPrevSrc] = useState(src) - if (src !== prevSrc) { - setDims(knownDimensions ?? get(src)) - setPrevSrc(src) - } - - useEffect(() => { - let aborted = false - if (dims !== undefined) return - fetch(src).then(newDims => { - if (aborted) return - setDims(newDims) - }) - return () => { - aborted = true - } - }, [dims, setDims, src]) - - let aspectRatio: number | undefined - if (dims) { - aspectRatio = dims.width / dims.height - if (Number.isNaN(aspectRatio)) { - aspectRatio = undefined - } - } - - return [aspectRatio, dims] -} diff --git a/src/screens/Profile/Header/Shell.tsx b/src/screens/Profile/Header/Shell.tsx index 093b9190a6..1a1e7d4a24 100644 --- a/src/screens/Profile/Header/Shell.tsx +++ b/src/screens/Profile/Header/Shell.tsx @@ -72,6 +72,7 @@ let ProfileHeaderShell = ({ height: 1000, width: 1000, }, + thumbDimensions: null, type: 'circle-avi', }, ], diff --git a/src/view/com/lightbox/ImageViewing/@types/index.ts b/src/view/com/lightbox/ImageViewing/@types/index.ts index 1a3543c267..779b95bfc6 100644 --- a/src/view/com/lightbox/ImageViewing/@types/index.ts +++ b/src/view/com/lightbox/ImageViewing/@types/index.ts @@ -21,10 +21,11 @@ export type Position = { export type ImageSource = { uri: string + dimensions: Dimensions | null thumbUri: string + thumbDimensions: Dimensions | null thumbRect: MeasuredDimensions | null 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 069f9eb40b..4652cb606b 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx @@ -41,6 +41,7 @@ type Props = { onRequestClose: () => void onTap: () => void onZoom: (isZoomed: boolean) => void + onLoad: (dims: ImageDimensions) => void isScrollViewBeingDragged: boolean showControls: boolean measureSafeArea: () => { @@ -66,6 +67,7 @@ const ImageItem = ({ imageSrc, onTap, onZoom, + onLoad, isScrollViewBeingDragged, measureSafeArea, imageAspect, @@ -330,8 +332,8 @@ const ImageItem = ({ transform: scaleAndMoveTransform.concat(manipulationTransform), width: screenSize.width, maxHeight: screenSize.height, - aspectRatio: imageAspect, alignSelf: 'center', + aspectRatio: imageAspect ?? 1 /* force onLoad */, } }) @@ -349,6 +351,7 @@ const ImageItem = ({ return { flex: 1, transform: cropContentTransform, + opacity: imageAspect === undefined ? 0 : 1, } }) @@ -393,7 +396,10 @@ const ImageItem = ({ placeholderContentFit="cover" placeholder={{uri: imageSrc.thumbUri}} accessibilityLabel={imageSrc.alt} - onLoad={() => setHasLoaded(false)} + onLoad={e => { + setHasLoaded(true) + onLoad({width: e.source.width, height: e.source.height}) + }} style={{flex: 1, borderRadius}} accessibilityHint="" accessibilityIgnoresInvertColors 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 7a9a18b91b..e6ec64c3d7 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx @@ -38,6 +38,7 @@ type Props = { onRequestClose: () => void onTap: () => void onZoom: (scaled: boolean) => void + onLoad: (dims: ImageDimensions) => void isScrollViewBeingDragged: boolean showControls: boolean measureSafeArea: () => { @@ -64,6 +65,7 @@ const ImageItem = ({ imageSrc, onTap, onZoom, + onLoad, showControls, measureSafeArea, imageAspect, @@ -162,8 +164,9 @@ const ImageItem = ({ transform: cropFrameTransform, width: screenSize.width, maxHeight: screenSize.height, - aspectRatio: imageAspect, alignSelf: 'center', + aspectRatio: imageAspect ?? 1 /* force onLoad */, + opacity: imageAspect === undefined ? 0 : 1, } }) @@ -172,7 +175,8 @@ const ImageItem = ({ return { transform: cropContentTransform, width: '100%', - aspectRatio: imageAspect, + aspectRatio: imageAspect ?? 1 /* force onLoad */, + opacity: imageAspect === undefined ? 0 : 1, } }) @@ -224,7 +228,10 @@ const ImageItem = ({ accessibilityHint="" enableLiveTextInteraction={showControls && !scaled} accessibilityIgnoresInvertColors - onLoad={() => setHasLoaded(true)} + onLoad={e => { + setHasLoaded(true) + onLoad({width: e.source.width, height: e.source.height}) + }} /> diff --git a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx index 543fad7726..b41e163832 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.tsx @@ -5,6 +5,7 @@ import {View} from 'react-native' import {PanGesture} from 'react-native-gesture-handler' import {SharedValue} from 'react-native-reanimated' +import {Dimensions} from '#/lib/media/types' import { Dimensions as ImageDimensions, ImageSource, @@ -16,6 +17,7 @@ type Props = { onRequestClose: () => void onTap: () => void onZoom: (scaled: boolean) => void + onLoad: (dims: Dimensions) => void isScrollViewBeingDragged: boolean showControls: boolean measureSafeArea: () => { diff --git a/src/view/com/lightbox/ImageViewing/index.tsx b/src/view/com/lightbox/ImageViewing/index.tsx index 030c8dcf30..ab8306b36a 100644 --- a/src/view/com/lightbox/ImageViewing/index.tsx +++ b/src/view/com/lightbox/ImageViewing/index.tsx @@ -42,7 +42,7 @@ import { import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {Trans} from '@lingui/macro' -import {useImageDimensions} from '#/lib/media/image-sizes' +import {Dimensions} from '#/lib/media/types' import {colors, s} from '#/lib/styles' import {isIOS} from '#/platform/detection' import {Lightbox} from '#/state/lightbox' @@ -92,7 +92,9 @@ export default function ImageViewRoot({ const canAnimate = !PlatformInfo.getIsReducedMotionEnabled() && - nextLightbox.images.every(img => img.dimensions && img.thumbRect) + nextLightbox.images.every( + img => img.thumbRect && (img.dimensions || img.thumbDimensions), + ) // https://github.com/software-mansion/react-native-reanimated/issues/6677 requestAnimationFrame(() => { @@ -345,10 +347,15 @@ function LightboxImage({ openProgress: SharedValue dismissSwipeTranslateY: SharedValue }) { - const [imageAspect, imageDimensions] = useImageDimensions({ - src: imageSrc.uri, - knownDimensions: imageSrc.dimensions, - }) + const [fetchedDims, setFetchedDims] = React.useState(null) + const dims = fetchedDims ?? imageSrc.dimensions ?? imageSrc.thumbDimensions + let imageAspect: number | undefined + if (dims) { + imageAspect = dims.width / dims.height + if (Number.isNaN(imageAspect)) { + imageAspect = undefined + } + } const safeFrameDelayedForJSThreadOnly = useSafeAreaFrame() const safeInsetsDelayedForJSThreadOnly = useSafeAreaInsets() @@ -452,11 +459,12 @@ function LightboxImage({ onTap={onTap} onZoom={onZoom} onRequestClose={onRequestClose} + onLoad={setFetchedDims} isScrollViewBeingDragged={isScrollViewBeingDragged} showControls={showControls} measureSafeArea={measureSafeArea} imageAspect={imageAspect} - imageDimensions={imageDimensions} + imageDimensions={dims ?? undefined} dismissSwipePan={dismissSwipePan} transforms={transforms} /> diff --git a/src/view/com/profile/ProfileSubpageHeader.tsx b/src/view/com/profile/ProfileSubpageHeader.tsx index 13d14ec502..d73b322f2e 100644 --- a/src/view/com/profile/ProfileSubpageHeader.tsx +++ b/src/view/com/profile/ProfileSubpageHeader.tsx @@ -87,6 +87,7 @@ export function ProfileSubpageHeader({ height: 1000, width: 1000, }, + thumbDimensions: null, type: 'rect-avi', }, ], diff --git a/src/view/com/util/images/AutoSizedImage.tsx b/src/view/com/util/images/AutoSizedImage.tsx index 21f6c529e2..c26df83351 100644 --- a/src/view/com/util/images/AutoSizedImage.tsx +++ b/src/view/com/util/images/AutoSizedImage.tsx @@ -6,8 +6,7 @@ import {AppBskyEmbedImages} from '@atproto/api' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useImageDimensions} from '#/lib/media/image-sizes' -import {Dimensions} from '#/lib/media/types' +import type {Dimensions} from '#/lib/media/types' import {isNative} from '#/platform/detection' import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' import {atoms as a, useBreakpoints, useTheme} from '#/alf' @@ -15,30 +14,6 @@ import {ArrowsDiagonalOut_Stroke2_Corner0_Rounded as Fullscreen} from '#/compone import {MediaInsetBorder} from '#/components/MediaInsetBorder' import {Text} from '#/components/Typography' -function useImageAspectRatio({ - src, - knownDimensions, -}: { - src: string - knownDimensions: Dimensions | null -}) { - const [raw] = useImageDimensions({src, knownDimensions}) - let constrained: number | undefined - let max: number | undefined - let isCropped: boolean | undefined - if (raw !== undefined) { - const ratio = 1 / 2 // max of 1:2 ratio in feeds - constrained = Math.max(raw, ratio) - max = Math.max(raw, 0.25) // max of 1:4 in thread - isCropped = raw < constrained - } - return { - constrained, - max, - isCropped, - } -} - export function ConstrainedImage({ aspectRatio, fullBleed, @@ -93,23 +68,38 @@ export function AutoSizedImage({ image: AppBskyEmbedImages.ViewImage crop?: 'none' | 'square' | 'constrained' hideBadge?: boolean - onPress?: (containerRef: AnimatedRef>) => void + onPress?: ( + containerRef: AnimatedRef>, + fetchedDims: Dimensions | null, + ) => void onLongPress?: () => void onPressIn?: () => void }) { const t = useTheme() const {_} = useLingui() const largeAlt = useLargeAltBadgeEnabled() - const { - constrained, - max, - isCropped: rawIsCropped, - } = useImageAspectRatio({ - src: image.thumb, - knownDimensions: image.aspectRatio ?? null, - }) const containerRef = useAnimatedRef() + const [fetchedDims, setFetchedDims] = React.useState(null) + const dims = fetchedDims ?? image.aspectRatio + let aspectRatio: number | undefined + if (dims) { + aspectRatio = dims.width / dims.height + if (Number.isNaN(aspectRatio)) { + aspectRatio = undefined + } + } + + let constrained: number | undefined + let max: number | undefined + let rawIsCropped: boolean | undefined + if (aspectRatio !== undefined) { + const ratio = 1 / 2 // max of 1:2 ratio in feeds + constrained = Math.max(aspectRatio, ratio) + max = Math.max(aspectRatio, 0.25) // max of 1:4 in thread + rawIsCropped = aspectRatio < constrained + } + const cropDisabled = crop === 'none' const isCropped = rawIsCropped && !cropDisabled const hasAlt = !!image.alt @@ -123,6 +113,9 @@ export function AutoSizedImage({ accessibilityIgnoresInvertColors accessibilityLabel={image.alt} accessibilityHint="" + onLoad={e => { + setFetchedDims({width: e.source.width, height: e.source.height}) + }} /> @@ -194,7 +187,7 @@ export function AutoSizedImage({ if (cropDisabled) { return ( onPress?.(containerRef)} + onPress={() => onPress?.(containerRef, fetchedDims)} onLongPress={onLongPress} onPressIn={onPressIn} // alt here is what screen readers actually use @@ -216,7 +209,7 @@ export function AutoSizedImage({ fullBleed={crop === 'square'} aspectRatio={constrained ?? 1}> onPress?.(containerRef)} + onPress={() => onPress?.(containerRef, fetchedDims)} onLongPress={onLongPress} onPressIn={onPressIn} // alt here is what screen readers actually use diff --git a/src/view/com/util/images/Gallery.tsx b/src/view/com/util/images/Gallery.tsx index 0c691ec9af..9d0817bd2f 100644 --- a/src/view/com/util/images/Gallery.tsx +++ b/src/view/com/util/images/Gallery.tsx @@ -6,6 +6,7 @@ import {AppBskyEmbedImages} from '@atproto/api' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {Dimensions} from '#/lib/media/types' import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' import {PostEmbedViewContext} from '#/view/com/util/post-embeds/types' import {atoms as a, useTheme} from '#/alf' @@ -20,6 +21,7 @@ interface Props { onPress?: ( index: number, containerRefs: AnimatedRef>[], + fetchedDims: (Dimensions | null)[], ) => void onLongPress?: EventFunction onPressIn?: EventFunction @@ -27,6 +29,7 @@ interface Props { viewContext?: PostEmbedViewContext insetBorderStyle?: StyleProp containerRefs: AnimatedRef>[] + thumbDimsRef: React.MutableRefObject<(Dimensions | null)[]> } export function GalleryItem({ @@ -39,6 +42,7 @@ export function GalleryItem({ viewContext, insetBorderStyle, containerRefs, + thumbDimsRef, }: Props) { const t = useTheme() const {_} = useLingui() @@ -53,7 +57,11 @@ export function GalleryItem({ ref={containerRefs[index]} collapsable={false}> onPress(index, containerRefs) : undefined} + onPress={ + onPress + ? () => onPress(index, containerRefs, thumbDimsRef.current.slice()) + : undefined + } onPressIn={onPressIn ? () => onPressIn(index) : undefined} onLongPress={onLongPress ? () => onLongPress(index) : undefined} style={[ @@ -72,6 +80,12 @@ export function GalleryItem({ accessibilityLabel={image.alt} accessibilityHint="" accessibilityIgnoresInvertColors + onLoad={e => { + thumbDimsRef.current[index] = { + width: e.source.width, + height: e.source.height, + } + }} /> diff --git a/src/view/com/util/images/ImageLayoutGrid.tsx b/src/view/com/util/images/ImageLayoutGrid.tsx index b9b966302a..dcc330dace 100644 --- a/src/view/com/util/images/ImageLayoutGrid.tsx +++ b/src/view/com/util/images/ImageLayoutGrid.tsx @@ -5,6 +5,7 @@ import {AppBskyEmbedImages} from '@atproto/api' import {PostEmbedViewContext} from '#/view/com/util/post-embeds/types' import {atoms as a, useBreakpoints} from '#/alf' +import {Dimensions} from '../../lightbox/ImageViewing/@types' import {GalleryItem} from './Gallery' interface ImageLayoutGridProps { @@ -12,6 +13,7 @@ interface ImageLayoutGridProps { onPress?: ( index: number, containerRefs: AnimatedRef>[], + fetchedDims: (Dimensions | null)[], ) => void onLongPress?: (index: number) => void onPressIn?: (index: number) => void @@ -42,6 +44,7 @@ interface ImageLayoutGridInnerProps { onPress?: ( index: number, containerRefs: AnimatedRef>[], + fetchedDims: (Dimensions | null)[], ) => void onLongPress?: (index: number) => void onPressIn?: (index: number) => void @@ -57,6 +60,7 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) { const containerRef2 = useAnimatedRef() const containerRef3 = useAnimatedRef() const containerRef4 = useAnimatedRef() + const thumbDimsRef = React.useRef<(Dimensions | null)[]>([]) switch (count) { case 2: { @@ -69,6 +73,7 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) { index={0} insetBorderStyle={noCorners(['topRight', 'bottomRight'])} containerRefs={containerRefs} + thumbDimsRef={thumbDimsRef} /> @@ -77,6 +82,7 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) { index={1} insetBorderStyle={noCorners(['topLeft', 'bottomLeft'])} containerRefs={containerRefs} + thumbDimsRef={thumbDimsRef} /> @@ -93,6 +99,7 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) { index={0} insetBorderStyle={noCorners(['topRight', 'bottomRight'])} containerRefs={containerRefs} + thumbDimsRef={thumbDimsRef} /> @@ -106,6 +113,7 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) { 'bottomRight', ])} containerRefs={containerRefs} + thumbDimsRef={thumbDimsRef} /> @@ -118,6 +126,7 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) { 'topRight', ])} containerRefs={containerRefs} + thumbDimsRef={thumbDimsRef} /> @@ -145,6 +154,7 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) { 'bottomRight', ])} containerRefs={containerRefs} + thumbDimsRef={thumbDimsRef} /> @@ -157,6 +167,7 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) { 'bottomRight', ])} containerRefs={containerRefs} + thumbDimsRef={thumbDimsRef} /> @@ -171,6 +182,7 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) { 'bottomRight', ])} containerRefs={containerRefs} + thumbDimsRef={thumbDimsRef} /> @@ -183,6 +195,7 @@ function ImageLayoutGridInner(props: ImageLayoutGridInnerProps) { 'topRight', ])} containerRefs={containerRefs} + thumbDimsRef={thumbDimsRef} /> diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx index ab2471b33d..1351a2cbc3 100644 --- a/src/view/com/util/post-embeds/index.tsx +++ b/src/view/com/util/post-embeds/index.tsx @@ -35,6 +35,7 @@ import {atoms as a, useTheme} from '#/alf' import * as ListCard from '#/components/ListCard' import {Embed as StarterPackCard} from '#/components/StarterPack/StarterPackCard' import {ContentHider} from '../../../../components/moderation/ContentHider' +import {Dimensions} from '../../lightbox/ImageViewing/@types' import {AutoSizedImage} from '../images/AutoSizedImage' import {ImageLayoutGrid} from '../images/ImageLayoutGrid' import {ExternalLinkEmbed} from './ExternalLinkEmbed' @@ -148,11 +149,13 @@ export function PostEmbeds({ const _openLightbox = ( index: number, thumbRects: (MeasuredDimensions | null)[], + fetchedDims: (Dimensions | null)[], ) => { openLightbox({ images: items.map((item, i) => ({ ...item, thumbRect: thumbRects[i] ?? null, + thumbDimensions: fetchedDims[i] ?? null, type: 'image', })), index, @@ -161,11 +164,12 @@ export function PostEmbeds({ const onPress = ( index: number, refs: AnimatedRef>[], + fetchedDims: (Dimensions | null)[], ) => { runOnUI(() => { 'worklet' const rects = refs.map(ref => (ref ? measure(ref) : null)) - runOnJS(_openLightbox)(index, rects) + runOnJS(_openLightbox)(index, rects, fetchedDims) })() } const onPressIn = (_: number) => { @@ -189,7 +193,9 @@ export function PostEmbeds({ : 'constrained' } image={image} - onPress={containerRef => onPress(0, [containerRef])} + onPress={(containerRef, dims) => + onPress(0, [containerRef], [dims]) + } onPressIn={() => onPressIn(0)} hideBadge={ viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia From 3aeee1e4aa9ae76d2c3638ab2fad54d7171effbf Mon Sep 17 00:00:00 2001 From: dan Date: Sat, 9 Nov 2024 23:15:20 +0000 Subject: [PATCH 17/35] Fix web image layout (#6195) --- src/view/com/util/images/AutoSizedImage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/view/com/util/images/AutoSizedImage.tsx b/src/view/com/util/images/AutoSizedImage.tsx index c26df83351..b82bab8795 100644 --- a/src/view/com/util/images/AutoSizedImage.tsx +++ b/src/view/com/util/images/AutoSizedImage.tsx @@ -105,7 +105,7 @@ export function AutoSizedImage({ const hasAlt = !!image.alt const contents = ( - + Date: Sat, 9 Nov 2024 16:24:51 -0800 Subject: [PATCH 18/35] android scroll performance fixes pt. 1 (#6196) --- src/alf/index.tsx | 53 ++++++++++++-------------- src/view/com/util/text/Text.tsx | 67 ++++++++++++++++----------------- 2 files changed, 58 insertions(+), 62 deletions(-) diff --git a/src/alf/index.tsx b/src/alf/index.tsx index f9d93d4ca8..5d08722ff4 100644 --- a/src/alf/index.tsx +++ b/src/alf/index.tsx @@ -103,35 +103,32 @@ export function ThemeProvider({ }) }, []) - return ( - ( - () => ({ - themes, - themeName: themeName, - theme: themes[themeName], - fonts: { - scale: fontScale, - scaleMultiplier: fontScaleMultiplier, - family: fontFamily, - setFontScale: setFontScaleAndPersist, - setFontFamily: setFontFamilyAndPersist, - }, - flags: {}, - }), - [ - themeName, - themes, - fontScale, - setFontScaleAndPersist, - fontFamily, - setFontFamilyAndPersist, - fontScaleMultiplier, - ], - )}> - {children} - + const value = React.useMemo( + () => ({ + themes, + themeName: themeName, + theme: themes[themeName], + fonts: { + scale: fontScale, + scaleMultiplier: fontScaleMultiplier, + family: fontFamily, + setFontScale: setFontScaleAndPersist, + setFontFamily: setFontFamilyAndPersist, + }, + flags: {}, + }), + [ + themeName, + themes, + fontScale, + setFontScaleAndPersist, + fontFamily, + setFontFamilyAndPersist, + fontScaleMultiplier, + ], ) + + return {children} } export function useAlf() { diff --git a/src/view/com/util/text/Text.tsx b/src/view/com/util/text/Text.tsx index 42ea79b8fb..dbf5e2e13f 100644 --- a/src/view/com/util/text/Text.tsx +++ b/src/view/com/util/text/Text.tsx @@ -5,7 +5,7 @@ import {UITextView} from 'react-native-uitextview' import {lh, s} from '#/lib/styles' import {TypographyVariant, useTheme} from '#/lib/ThemeContext' import {logger} from '#/logger' -import {isIOS} from '#/platform/detection' +import {isIOS, isWeb} from '#/platform/detection' import {applyFonts, useAlf} from '#/alf' import { childHasEmoji, @@ -44,8 +44,6 @@ export function Text({ ...props }: React.PropsWithChildren) { const theme = useTheme() - const typography = theme.typography[type] - const lineHeightStyle = lineHeight ? lh(theme, type, lineHeight) : undefined const {fonts} = useAlf() if (IS_DEV) { @@ -60,7 +58,10 @@ export function Text({ } } - if (selectable && isIOS) { + const textProps = React.useMemo(() => { + const typography = theme.typography[type] + const lineHeightStyle = lineHeight ? lh(theme, type, lineHeight) : undefined + const flattened = StyleSheet.flatten([ s.black, typography, @@ -74,49 +75,47 @@ export function Text({ // @ts-ignore if (flattened.fontSize) { // @ts-ignore - flattened.fontSize = flattened.fontSize * fonts.scaleMultiplier + flattened.fontSize = Math.round( + // @ts-ignore + flattened.fontSize * fonts.scaleMultiplier, + ) } - const shared = { - uiTextView: true, + return { + uiTextView: selectable && isIOS, selectable, style: flattened, + dataSet: isWeb + ? Object.assign({tooltip: title}, dataSet || {}) + : undefined, ...props, } + }, [ + dataSet, + fonts.family, + fonts.scaleMultiplier, + lineHeight, + props, + selectable, + style, + theme, + title, + type, + ]) + if (selectable && isIOS) { return ( - - {isIOS && emoji ? renderChildrenWithEmoji(children, shared) : children} + + {isIOS && emoji + ? renderChildrenWithEmoji(children, textProps) + : children} ) } - const flattened = StyleSheet.flatten([ - s.black, - typography, - lineHeightStyle, - style, - ]) - - applyFonts(flattened, fonts.family) - - // should always be defined on `typography` - // @ts-ignore - if (flattened.fontSize) { - // @ts-ignore - flattened.fontSize = flattened.fontSize * fonts.scaleMultiplier - } - - const shared = { - selectable, - style: flattened, - dataSet: Object.assign({tooltip: title}, dataSet || {}), - ...props, - } - return ( - - {isIOS && emoji ? renderChildrenWithEmoji(children, shared) : children} + + {isIOS && emoji ? renderChildrenWithEmoji(children, textProps) : children} ) } From b4412f333381504f6e93ebbfb3f2b9fa9dab892d Mon Sep 17 00:00:00 2001 From: dan Date: Sun, 10 Nov 2024 20:30:31 +0000 Subject: [PATCH 19/35] Fix render loop on Android (#6213) --- .../components/ImageItem/ImageItem.android.tsx | 12 ++++++++---- .../components/ImageItem/ImageItem.ios.tsx | 12 ++++++++---- src/view/com/util/images/AutoSizedImage.tsx | 10 +++++++--- 3 files changed, 23 insertions(+), 11 deletions(-) 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 4652cb606b..260787d2f3 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.android.tsx @@ -396,10 +396,14 @@ const ImageItem = ({ placeholderContentFit="cover" placeholder={{uri: imageSrc.thumbUri}} accessibilityLabel={imageSrc.alt} - onLoad={e => { - setHasLoaded(true) - onLoad({width: e.source.width, height: e.source.height}) - }} + onLoad={ + hasLoaded + ? undefined + : e => { + setHasLoaded(true) + onLoad({width: e.source.width, height: e.source.height}) + } + } style={{flex: 1, borderRadius}} accessibilityHint="" accessibilityIgnoresInvertColors 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 e6ec64c3d7..f06a59ed60 100644 --- a/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx +++ b/src/view/com/lightbox/ImageViewing/components/ImageItem/ImageItem.ios.tsx @@ -228,10 +228,14 @@ const ImageItem = ({ accessibilityHint="" enableLiveTextInteraction={showControls && !scaled} accessibilityIgnoresInvertColors - onLoad={e => { - setHasLoaded(true) - onLoad({width: e.source.width, height: e.source.height}) - }} + onLoad={ + hasLoaded + ? undefined + : e => { + setHasLoaded(true) + onLoad({width: e.source.width, height: e.source.height}) + } + } /> diff --git a/src/view/com/util/images/AutoSizedImage.tsx b/src/view/com/util/images/AutoSizedImage.tsx index b82bab8795..fe8911e31c 100644 --- a/src/view/com/util/images/AutoSizedImage.tsx +++ b/src/view/com/util/images/AutoSizedImage.tsx @@ -113,9 +113,13 @@ export function AutoSizedImage({ accessibilityIgnoresInvertColors accessibilityLabel={image.alt} accessibilityHint="" - onLoad={e => { - setFetchedDims({width: e.source.width, height: e.source.height}) - }} + onLoad={ + fetchedDims + ? undefined + : e => { + setFetchedDims({width: e.source.width, height: e.source.height}) + } + } /> From 9a10af7117ff2c818a137241a5b4271c2a882f10 Mon Sep 17 00:00:00 2001 From: Hazem Ali Date: Mon, 11 Nov 2024 00:10:09 +0200 Subject: [PATCH 20/35] Fix Retain Cycles in SheetViewController (#6202) * Fix retain cycle in SheetViewController.swift * Fix retain cycles --- modules/bottom-sheet/ios/SheetViewController.swift | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/modules/bottom-sheet/ios/SheetViewController.swift b/modules/bottom-sheet/ios/SheetViewController.swift index a8b8f0c058..90d0fed0df 100644 --- a/modules/bottom-sheet/ios/SheetViewController.swift +++ b/modules/bottom-sheet/ios/SheetViewController.swift @@ -64,11 +64,14 @@ class SheetViewController: UIViewController { func updateDetents(contentHeight: CGFloat, preventExpansion: Bool) { if let sheet = self.sheetPresentationController { - sheet.animateChanges { - self.setDetents(contentHeight: contentHeight, preventExpansion: preventExpansion) - if #available(iOS 16.0, *) { - sheet.invalidateDetents() - } + // Capture `self` weakly to prevent retain cycles. + // Also, capture `sheet` weakly to avoid potential strong references held by animateChanges. + sheet.animateChanges { [weak self, weak sheet] in + guard let weakSelf = self, let weakSheet = sheet else { return } + weakSelf.setDetents(contentHeight: contentHeight, preventExpansion: preventExpansion) + if #available(iOS 16.0, *) { + weakSheet.invalidateDetents() + } } } } From 9247407c70eff280768e605637be873052ac1b59 Mon Sep 17 00:00:00 2001 From: dan Date: Sun, 10 Nov 2024 23:03:55 +0000 Subject: [PATCH 21/35] Don't render unnecessary instances (#6217) --- modules/bottom-sheet/src/BottomSheet.tsx | 25 +----- .../src/BottomSheetNativeComponent.tsx | 80 +++++++++++-------- .../bottom-sheet/src/BottomSheetPortal.tsx | 2 +- 3 files changed, 48 insertions(+), 59 deletions(-) diff --git a/modules/bottom-sheet/src/BottomSheet.tsx b/modules/bottom-sheet/src/BottomSheet.tsx index bcc2c42ad1..8da2773e9f 100644 --- a/modules/bottom-sheet/src/BottomSheet.tsx +++ b/modules/bottom-sheet/src/BottomSheet.tsx @@ -1,24 +1 @@ -import React from 'react' - -import {BottomSheetViewProps} from './BottomSheet.types' -import {BottomSheetNativeComponent} from './BottomSheetNativeComponent' -import {useBottomSheetPortal_INTERNAL} from './BottomSheetPortal' - -export const BottomSheet = React.forwardRef< - BottomSheetNativeComponent, - BottomSheetViewProps ->(function BottomSheet(props, ref) { - const Portal = useBottomSheetPortal_INTERNAL() - - if (__DEV__ && !Portal) { - throw new Error( - 'BottomSheet: You need to wrap your component tree with a to use the bottom sheet.', - ) - } - - return ( - - - - ) -}) +export {BottomSheetNativeComponent as BottomSheet} from './BottomSheetNativeComponent' diff --git a/modules/bottom-sheet/src/BottomSheetNativeComponent.tsx b/modules/bottom-sheet/src/BottomSheetNativeComponent.tsx index fa2b163bf0..acd46ce015 100644 --- a/modules/bottom-sheet/src/BottomSheetNativeComponent.tsx +++ b/modules/bottom-sheet/src/BottomSheetNativeComponent.tsx @@ -11,6 +11,7 @@ import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core' import {BottomSheetState, BottomSheetViewProps} from './BottomSheet.types' import {BottomSheetPortalProvider} from './BottomSheetPortal' +import {Context as PortalContext} from './BottomSheetPortal' const screenHeight = Dimensions.get('screen').height @@ -34,6 +35,8 @@ export class BottomSheetNativeComponent extends React.Component< > { ref = React.createRef() + static contextType = PortalContext + constructor(props: BottomSheetViewProps) { super(props) this.state = { @@ -67,6 +70,17 @@ export class BottomSheetNativeComponent extends React.Component< } render() { + const Portal = this.context as React.ContextType + if (!Portal) { + throw new Error( + 'BottomSheet: You need to wrap your component tree with a to use the bottom sheet.', + ) + } + + if (!this.state.open) { + return null + } + const {children, backgroundColor, ...rest} = this.props const cornerRadius = rest.cornerRadius ?? 0 @@ -83,43 +97,41 @@ export class BottomSheetNativeComponent extends React.Component< } } - if (!this.state.open) { - return null - } - return ( - - + + { - const {height} = e.nativeEvent.layout - this.setState({viewHeight: height}) - this.updateLayout() - }}> - {children} + style={[ + { + flex: 1, + backgroundColor, + }, + Platform.OS === 'android' && { + borderTopLeftRadius: cornerRadius, + borderTopRightRadius: cornerRadius, + }, + extraStyles, + ]}> + { + const {height} = e.nativeEvent.layout + this.setState({viewHeight: height}) + this.updateLayout() + }}> + {children} + - - + + ) } } diff --git a/modules/bottom-sheet/src/BottomSheetPortal.tsx b/modules/bottom-sheet/src/BottomSheetPortal.tsx index da14cfa774..4d8ed57ff0 100644 --- a/modules/bottom-sheet/src/BottomSheetPortal.tsx +++ b/modules/bottom-sheet/src/BottomSheetPortal.tsx @@ -4,7 +4,7 @@ import {createPortalGroup_INTERNAL} from './lib/Portal' type PortalContext = React.ElementType<{children: React.ReactNode}> -const Context = React.createContext({} as PortalContext) +export const Context = React.createContext({} as PortalContext) export const useBottomSheetPortal_INTERNAL = () => React.useContext(Context) From 57464a8fe0ccdbc9cde0dedf7f274d969491cb88 Mon Sep 17 00:00:00 2001 From: Khuddite <62555977+khuddite@users.noreply.github.com> Date: Mon, 11 Nov 2024 05:40:47 -0500 Subject: [PATCH 22/35] Increase copied animation lingering time for reduced motion (#6220) * increase copied animation lingering time for reduced motion * flip the condition --------- Co-authored-by: dan --- src/screens/Settings/components/CopyButton.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/screens/Settings/components/CopyButton.tsx b/src/screens/Settings/components/CopyButton.tsx index eb538f5dee..8c6cdfa8ad 100644 --- a/src/screens/Settings/components/CopyButton.tsx +++ b/src/screens/Settings/components/CopyButton.tsx @@ -1,6 +1,10 @@ import React, {useCallback, useEffect, useState} from 'react' import {GestureResponderEvent, View} from 'react-native' -import Animated, {FadeOutUp, ZoomIn} from 'react-native-reanimated' +import Animated, { + FadeOutUp, + useReducedMotion, + ZoomIn, +} from 'react-native-reanimated' import * as Clipboard from 'expo-clipboard' import {Trans} from '@lingui/macro' @@ -16,13 +20,17 @@ export function CopyButton({ }: ButtonProps & {value: string}) { const [hasBeenCopied, setHasBeenCopied] = useState(false) const t = useTheme() + const isReducedMotionEnabled = useReducedMotion() useEffect(() => { if (hasBeenCopied) { - const timeout = setTimeout(() => setHasBeenCopied(false), 100) + const timeout = setTimeout( + () => setHasBeenCopied(false), + isReducedMotionEnabled ? 2000 : 100, + ) return () => clearTimeout(timeout) } - }, [hasBeenCopied]) + }, [hasBeenCopied, isReducedMotionEnabled]) const onPress = useCallback( (evt: GestureResponderEvent) => { From b85a7da26b86691f3ed283b20aba9bc66cfacdf3 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Mon, 11 Nov 2024 18:13:05 +0000 Subject: [PATCH 23/35] fix error screen (#6235) --- src/components/Error.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Error.tsx b/src/components/Error.tsx index a27ccb88c6..6c09df0eea 100644 --- a/src/components/Error.tsx +++ b/src/components/Error.tsx @@ -32,7 +32,7 @@ export function Error({ return ( Date: Tue, 12 Nov 2024 01:33:47 +0700 Subject: [PATCH 24/35] Fix unscrollable body when returning to desktop mode from mobile mode with navigation drawer open. (#6201) * fix: only lock body when drawer is opened and not in desktop mode * Reuse variable --------- Co-authored-by: Dan Abramov --- src/view/shell/index.web.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx index 84d6994b3a..f554373562 100644 --- a/src/view/shell/index.web.tsx +++ b/src/view/shell/index.web.tsx @@ -32,8 +32,9 @@ function ShellInner() { const navigator = useNavigation() const closeAllActiveElements = useCloseAllActiveElements() const {_} = useLingui() + const showDrawer = !isDesktop && isDrawerOpen - useWebBodyScrollLock(isDrawerOpen) + useWebBodyScrollLock(showDrawer) useComposerKeyboardShortcut() useIntentHandler() @@ -56,7 +57,7 @@ function ShellInner() { - {!isDesktop && isDrawerOpen && ( + {showDrawer && ( { // Only close if press happens outside of the drawer From 9e1a16686a650d832998b5128c104ec1f635e6a8 Mon Sep 17 00:00:00 2001 From: Minseo Lee Date: Tue, 12 Nov 2024 03:34:09 +0900 Subject: [PATCH 25/35] deleted account (#6224) --- src/components/dms/MessagesListHeader.tsx | 2 +- src/screens/Messages/components/ChatListItem.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/dms/MessagesListHeader.tsx b/src/components/dms/MessagesListHeader.tsx index ab9ec16e4d..6c3bbf2161 100644 --- a/src/components/dms/MessagesListHeader.tsx +++ b/src/components/dms/MessagesListHeader.tsx @@ -147,7 +147,7 @@ function HeaderReady({ const isDeletedAccount = profile?.handle === 'missing.invalid' const displayName = isDeletedAccount - ? 'Deleted Account' + ? _(msg`Deleted Account`) : sanitizeDisplayName( profile.displayName || profile.handle, moderation.ui('displayName'), diff --git a/src/screens/Messages/components/ChatListItem.tsx b/src/screens/Messages/components/ChatListItem.tsx index bb9c1cd4cb..6b8deea30e 100644 --- a/src/screens/Messages/components/ChatListItem.tsx +++ b/src/screens/Messages/components/ChatListItem.tsx @@ -104,7 +104,7 @@ function ChatListItemReady({ const isDeletedAccount = profile.handle === 'missing.invalid' const displayName = isDeletedAccount - ? 'Deleted Account' + ? _(msg`Deleted Account`) : sanitizeDisplayName( profile.displayName || profile.handle, moderation.ui('displayName'), From 7008f61ea4cb356997f139bfe84fba98fcad805b Mon Sep 17 00:00:00 2001 From: Trivikram Kamat <16024985+trivikr@users.noreply.github.com> Date: Mon, 11 Nov 2024 10:58:22 -0800 Subject: [PATCH 26/35] Update Node.js to 20.x in Dockerfile (#6099) * Update Node.js to 20.x in Dockerfile * Update Node.js types and engines entry to 20.x * Update Node.js version in .nvmrc * Use node version from .nvmrc in linting job * Use Type Assertion for clearTimeout --- .github/workflows/lint.yml | 4 +++ .nvmrc | 2 +- Dockerfile | 2 +- package.json | 4 +-- src/view/com/util/List.web.tsx | 2 +- yarn.lock | 46 +++++++++++----------------------- 6 files changed, 23 insertions(+), 37 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 22cc657353..f0e23263db 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -16,6 +16,10 @@ jobs: steps: - name: Check out Git repository uses: actions/checkout@v3 + - name: Install node + uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc - name: Yarn install uses: Wandalen/wretry.action@master with: diff --git a/.nvmrc b/.nvmrc index 3c032078a4..209e3ef4b6 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -18 +20 diff --git a/Dockerfile b/Dockerfile index 50bdd18cb9..557321872a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,7 @@ WORKDIR /usr/src/social-app ENV DEBIAN_FRONTEND=noninteractive # Node -ENV NODE_VERSION=18 +ENV NODE_VERSION=20 ENV NVM_DIR=/usr/share/nvm # Go diff --git a/package.json b/package.json index 379d21a93f..8525885c70 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "1.94.0", "private": true, "engines": { - "node": ">=18" + "node": ">=20" }, "packageManager": "yarn@1.22.19", "scripts": { @@ -103,7 +103,7 @@ "@tiptap/suggestion": "^2.6.6", "@types/invariant": "^2.2.37", "@types/lodash.throttle": "^4.1.9", - "@types/node": "^18.16.2", + "@types/node": "^20.14.3", "@zxing/text-encoding": "^0.9.0", "array.prototype.findlast": "^1.2.3", "await-lock": "^2.2.2", diff --git a/src/view/com/util/List.web.tsx b/src/view/com/util/List.web.tsx index 5ddc4ea8a2..d9a2e351e1 100644 --- a/src/view/com/util/List.web.tsx +++ b/src/view/com/util/List.web.tsx @@ -468,7 +468,7 @@ let Row = function RowImpl({ } } else { if (intersectionTimeout.current) { - clearTimeout(intersectionTimeout.current) + clearTimeout(intersectionTimeout.current as NodeJS.Timeout) intersectionTimeout.current = undefined } } diff --git a/yarn.lock b/yarn.lock index c622dd1bd8..056451d1a1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7523,10 +7523,12 @@ dependencies: undici-types "~5.26.4" -"@types/node@^18.16.2": - version "18.17.6" - resolved "https://registry.yarnpkg.com/@types/node/-/node-18.17.6.tgz#0296e9a30b22d2a8fcaa48d3c45afe51474ca55b" - integrity sha512-fGmT/P7z7ecA6bv/ia5DlaWCH4YeZvAQMNpUhrJjtAhOhZfoxS1VLUgU2pdk63efSjQaOJWdXMuAJsws+8I6dg== +"@types/node@^20.14.3": + version "20.17.6" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.17.6.tgz#6e4073230c180d3579e8c60141f99efdf5df0081" + integrity sha512-VEI7OdvK2wP7XHnsuXbAJnEpEkF6NjSN45QJlL4VGqZSXsnicpesdTWsg9RISeSdYd3yeRj/y3k5KGjUXYnFwQ== + dependencies: + undici-types "~6.19.2" "@types/parse-json@^4.0.0": version "4.0.0" @@ -19968,16 +19970,7 @@ string-natural-compare@^3.0.1: resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4" integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw== -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -20086,7 +20079,7 @@ stringify-object@^3.3.0: is-obj "^1.0.1" is-regexp "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -20100,13 +20093,6 @@ strip-ansi@^5.0.0, strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -20923,6 +20909,11 @@ undici-types@~5.26.4: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== +undici-types@~6.19.2: + version "6.19.8" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.19.8.tgz#35111c9d1437ab83a7cdc0abae2f26d88eda0a02" + integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw== + undici@^5.28.2: version "5.28.2" resolved "https://registry.yarnpkg.com/undici/-/undici-5.28.2.tgz#fea200eac65fc7ecaff80a023d1a0543423b4c91" @@ -21835,7 +21826,7 @@ workbox-window@6.6.1: "@types/trusted-types" "^2.0.2" workbox-core "6.6.1" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -21853,15 +21844,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.0.1, wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" From 5ee809e4213b57693fc6767c86d66d73382064db Mon Sep 17 00:00:00 2001 From: Khuddite <62555977+khuddite@users.noreply.github.com> Date: Mon, 11 Nov 2024 18:21:07 -0500 Subject: [PATCH 27/35] Update error component of lists to take up the full height instead of its content height (#6236) Co-authored-by: Samuel Newman --- src/components/Error.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Error.tsx b/src/components/Error.tsx index 6c09df0eea..2819986b34 100644 --- a/src/components/Error.tsx +++ b/src/components/Error.tsx @@ -32,7 +32,7 @@ export function Error({ return ( Date: Tue, 12 Nov 2024 10:37:33 -0500 Subject: [PATCH 28/35] embedr: increase rate limits (#6255) --- bskyweb/cmd/embedr/server.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bskyweb/cmd/embedr/server.go b/bskyweb/cmd/embedr/server.go index 904b4df9a2..a9a8ab57f9 100644 --- a/bskyweb/cmd/embedr/server.go +++ b/bskyweb/cmd/embedr/server.go @@ -112,8 +112,8 @@ func serve(cctx *cli.Context) error { Skipper: middleware.DefaultSkipper, Store: middleware.NewRateLimiterMemoryStoreWithConfig( middleware.RateLimiterMemoryStoreConfig{ - Rate: 10, // requests per second - Burst: 30, // allow bursts + Rate: 20, // requests per second + Burst: 150, // allow bursts ExpiresIn: 3 * time.Minute, // garbage collect entries older than 3 minutes }, ), From d7841037f19a58a9641d1a9ef9f1bcf805bb461b Mon Sep 17 00:00:00 2001 From: devin ivy Date: Tue, 12 Nov 2024 12:14:47 -0500 Subject: [PATCH 29/35] Embedr: fix build / node version (#6265) * embedr: increase rate limits * update embedr node version, build * tidy --- .github/workflows/build-and-push-embedr-aws.yaml | 2 -- Dockerfile.embedr | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/build-and-push-embedr-aws.yaml b/.github/workflows/build-and-push-embedr-aws.yaml index f7f24af9f7..23d1538fe5 100644 --- a/.github/workflows/build-and-push-embedr-aws.yaml +++ b/.github/workflows/build-and-push-embedr-aws.yaml @@ -3,8 +3,6 @@ on: push: branches: - main - - bnewbold/embedr - - bnewbold/embedr-rebase env: REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }} diff --git a/Dockerfile.embedr b/Dockerfile.embedr index 663cbcfc51..3623b3293e 100644 --- a/Dockerfile.embedr +++ b/Dockerfile.embedr @@ -5,7 +5,7 @@ WORKDIR /usr/src/social-app ENV DEBIAN_FRONTEND=noninteractive # Node -ENV NODE_VERSION=18 +ENV NODE_VERSION=20 ENV NVM_DIR=/usr/share/nvm # Go From b2e11d31717884f87c4e42ffedc645de46664456 Mon Sep 17 00:00:00 2001 From: dan Date: Tue, 12 Nov 2024 17:19:52 +0000 Subject: [PATCH 30/35] Reduce Reanimated serialization traffic (#6219) --- src/view/com/util/MainScrollProvider.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/view/com/util/MainScrollProvider.tsx b/src/view/com/util/MainScrollProvider.tsx index 23dffc561d..193d07d724 100644 --- a/src/view/com/util/MainScrollProvider.tsx +++ b/src/view/com/util/MainScrollProvider.tsx @@ -3,6 +3,7 @@ import {NativeScrollEvent} from 'react-native' import { cancelAnimation, interpolate, + makeMutable, useSharedValue, withSpring, } from 'react-native-reanimated' @@ -20,6 +21,18 @@ function clamp(num: number, min: number, max: number) { return Math.min(Math.max(num, min), max) } +const V0 = makeMutable( + withSpring(0, { + overshootClamping: true, + }), +) + +const V1 = makeMutable( + withSpring(1, { + overshootClamping: true, + }), +) + export function MainScrollProvider({children}: {children: React.ReactNode}) { const {headerHeight} = useShellLayout() const {headerMode} = useMinimalShellMode() @@ -31,9 +44,7 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) { (v: boolean) => { 'worklet' cancelAnimation(headerMode) - headerMode.value = withSpring(v ? 1 : 0, { - overshootClamping: true, - }) + headerMode.value = v ? V1.value : V0.value }, [headerMode], ) From ae97032c41bb564f11456224db00a63a1584122a Mon Sep 17 00:00:00 2001 From: Khuddite <62555977+khuddite@users.noreply.github.com> Date: Tue, 12 Nov 2024 12:26:13 -0500 Subject: [PATCH 31/35] Increase bottom padding for mobile login/signup footer (#6254) --- src/view/shell/bottom-bar/BottomBarWeb.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/view/shell/bottom-bar/BottomBarWeb.tsx b/src/view/shell/bottom-bar/BottomBarWeb.tsx index 9b34159d79..81855c97d0 100644 --- a/src/view/shell/bottom-bar/BottomBarWeb.tsx +++ b/src/view/shell/bottom-bar/BottomBarWeb.tsx @@ -177,7 +177,7 @@ export function BottomBarWeb() { alignItems: 'center', justifyContent: 'space-between', paddingTop: 14, - paddingBottom: 2, + paddingBottom: 14, paddingLeft: 14, paddingRight: 6, gap: 8, From b60629aff8d95abb330f284b61181a05307479fe Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Tue, 12 Nov 2024 17:49:14 +0000 Subject: [PATCH 32/35] use hairline for feedlike (#6257) --- src/view/com/feeds/FeedSourceCard.tsx | 5 ++++- src/view/com/notifications/FeedItem.tsx | 8 ++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/view/com/feeds/FeedSourceCard.tsx b/src/view/com/feeds/FeedSourceCard.tsx index 3276cf8821..707aad7fb0 100644 --- a/src/view/com/feeds/FeedSourceCard.tsx +++ b/src/view/com/feeds/FeedSourceCard.tsx @@ -162,7 +162,10 @@ export function FeedSourceCardLoaded({ style={[ pal.border, { - borderTopWidth: showMinimalPlaceholder || hideTopBorder ? 0 : 1, + borderTopWidth: + showMinimalPlaceholder || hideTopBorder + ? 0 + : StyleSheet.hairlineWidth, flexDirection: 'row', alignItems: 'center', flex: 1, diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index 5473fff854..b90f2ecd64 100644 --- a/src/view/com/notifications/FeedItem.tsx +++ b/src/view/com/notifications/FeedItem.tsx @@ -467,7 +467,12 @@ let FeedItem = ({ {item.type === 'feedgen-like' && item.subjectUri ? ( ) : null} @@ -778,7 +783,6 @@ const styles = StyleSheet.create({ opacity: 0.8, }, feedcard: { - borderWidth: 1, borderRadius: 8, paddingVertical: 12, marginTop: 6, From df3f71286d7eee28f6b49c84349339bd5dde4517 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Tue, 12 Nov 2024 17:49:49 +0000 Subject: [PATCH 33/35] add subtle web hover to "View full thread" (#6256) --- src/components/SubtleWebHover.tsx | 4 +++- src/components/SubtleWebHover.web.tsx | 12 +++++++----- src/view/com/posts/FeedSlice.tsx | 20 +++++++++++++++++++- 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/components/SubtleWebHover.tsx b/src/components/SubtleWebHover.tsx index e6f427237b..5cbbfc898a 100644 --- a/src/components/SubtleWebHover.tsx +++ b/src/components/SubtleWebHover.tsx @@ -1,3 +1,5 @@ -export function SubtleWebHover({}: {hover: boolean}) { +import {ViewStyleProp} from '#/alf' + +export function SubtleWebHover({}: ViewStyleProp & {hover: boolean}) { return null } diff --git a/src/components/SubtleWebHover.web.tsx b/src/components/SubtleWebHover.web.tsx index e98251e0dd..adabf46bfd 100644 --- a/src/components/SubtleWebHover.web.tsx +++ b/src/components/SubtleWebHover.web.tsx @@ -2,9 +2,12 @@ import React from 'react' import {StyleSheet, View} from 'react-native' import {isTouchDevice} from '#/lib/browser' -import {useTheme} from '#/alf' +import {useTheme, ViewStyleProp} from '#/alf' -export function SubtleWebHover({hover}: {hover: boolean}) { +export function SubtleWebHover({ + style, + hover, +}: ViewStyleProp & {hover: boolean}) { const t = useTheme() if (isTouchDevice) { return null @@ -26,9 +29,8 @@ export function SubtleWebHover({hover}: {hover: boolean}) { style={[ t.atoms.bg_contrast_25, styles.container, - { - opacity: hover ? opacity : 0, - }, + {opacity: hover ? opacity : 0}, + style, ]} /> ) diff --git a/src/view/com/posts/FeedSlice.tsx b/src/view/com/posts/FeedSlice.tsx index dc68ee7a17..09335fa0ed 100644 --- a/src/view/com/posts/FeedSlice.tsx +++ b/src/view/com/posts/FeedSlice.tsx @@ -7,6 +7,8 @@ import {Trans} from '@lingui/macro' import {usePalette} from '#/lib/hooks/usePalette' import {makeProfileLink} from '#/lib/routes/links' import {FeedPostSlice} from '#/state/queries/post-feed' +import {useInteractionState} from '#/components/hooks/useInteractionState' +import {SubtleWebHover} from '#/components/SubtleWebHover' import {Link} from '../util/Link' import {Text} from '../util/text/Text' import {FeedItem} from './FeedItem' @@ -108,6 +110,11 @@ FeedSlice = memo(FeedSlice) export {FeedSlice} function ViewFullThread({uri}: {uri: string}) { + const { + state: hover, + onIn: onHoverIn, + onOut: onHoverOut, + } = useInteractionState() const pal = usePalette('default') const itemHref = React.useMemo(() => { const urip = new AtUri(uri) @@ -115,7 +122,18 @@ function ViewFullThread({uri}: {uri: string}) { }, [uri]) return ( - + + Date: Tue, 12 Nov 2024 18:50:37 +0000 Subject: [PATCH 34/35] dedupe date parsing (#6267) --- src/view/com/util/PostMeta.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx index c0166a16ee..5384f6827e 100644 --- a/src/view/com/util/PostMeta.tsx +++ b/src/view/com/util/PostMeta.tsx @@ -49,6 +49,8 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => { precacheProfile(queryClient, opts.author) }, [queryClient, opts.author]) + const timestampLabel = niceDate(i18n, opts.timestamp) + return ( { {({timeElapsed}) => ( Date: Tue, 12 Nov 2024 11:18:53 -0800 Subject: [PATCH 35/35] Add email verification prompts throughout the app (#6174) --- .../StarterPack/ProfileStarterPacks.tsx | 27 +++++++- src/components/dialogs/VerifyEmailDialog.tsx | 62 ++++++++++++------- src/components/dms/MessageProfileButton.tsx | 56 ++++++++++++----- src/components/dms/dialogs/NewChatDialog.tsx | 20 +++++- src/lib/hooks/useEmail.ts | 19 ++++++ src/screens/Messages/Conversation.tsx | 26 +++++++- .../Messages/components/MessageInput.tsx | 11 +++- .../queries/email-verification-required.ts | 25 ++++++++ src/view/com/composer/Composer.tsx | 21 +++++++ src/view/screens/Lists.tsx | 22 ++++++- src/view/screens/ModerationModlists.tsx | 22 ++++++- 11 files changed, 265 insertions(+), 46 deletions(-) create mode 100644 src/lib/hooks/useEmail.ts create mode 100644 src/state/queries/email-verification-required.ts diff --git a/src/components/StarterPack/ProfileStarterPacks.tsx b/src/components/StarterPack/ProfileStarterPacks.tsx index 00afbdcfe9..5f58a19df3 100644 --- a/src/components/StarterPack/ProfileStarterPacks.tsx +++ b/src/components/StarterPack/ProfileStarterPacks.tsx @@ -14,6 +14,7 @@ import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query' import {useGenerateStarterPackMutation} from '#/lib/generate-starterpack' import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset' +import {useEmail} from '#/lib/hooks/useEmail' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {NavigationProp} from '#/lib/routes/types' import {parseStarterPackUri} from '#/lib/strings/starter-pack' @@ -27,6 +28,7 @@ import {LinearGradientBackground} from '#/components/LinearGradientBackground' import {Loader} from '#/components/Loader' import * as Prompt from '#/components/Prompt' import {Default as StarterPackCard} from '#/components/StarterPack/StarterPackCard' +import {VerifyEmailDialog} from '../dialogs/VerifyEmailDialog' import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '../icons/Plus' interface SectionRef { @@ -186,6 +188,9 @@ function Empty() { const followersDialogControl = useDialogControl() const errorDialogControl = useDialogControl() + const {needsEmailVerification} = useEmail() + const verifyEmailControl = useDialogControl() + const [isGenerating, setIsGenerating] = React.useState(false) const {mutate: generateStarterPack} = useGenerateStarterPackMutation({ @@ -249,7 +254,13 @@ function Empty() { color="primary" size="small" disabled={isGenerating} - onPress={confirmDialogControl.open} + onPress={() => { + if (needsEmailVerification) { + verifyEmailControl.open() + } else { + confirmDialogControl.open() + } + }} style={{backgroundColor: 'transparent'}}> Make one for me @@ -262,7 +273,13 @@ function Empty() { color="primary" size="small" disabled={isGenerating} - onPress={() => navigation.navigate('StarterPackWizard')} + onPress={() => { + if (needsEmailVerification) { + verifyEmailControl.open() + } else { + navigation.navigate('StarterPackWizard') + } + }} style={{ backgroundColor: 'white', borderColor: 'white', @@ -318,6 +335,12 @@ function Empty() { onConfirm={generate} confirmButtonCta={_(msg`Retry`)} /> + ) } diff --git a/src/components/dialogs/VerifyEmailDialog.tsx b/src/components/dialogs/VerifyEmailDialog.tsx index 8dfb9bc490..d4412b6f89 100644 --- a/src/components/dialogs/VerifyEmailDialog.tsx +++ b/src/components/dialogs/VerifyEmailDialog.tsx @@ -18,8 +18,14 @@ import {Text} from '#/components/Typography' export function VerifyEmailDialog({ control, + onCloseWithoutVerifying, + onCloseAfterVerifying, + reasonText, }: { control: Dialog.DialogControlProps + onCloseWithoutVerifying?: () => void + onCloseAfterVerifying?: () => void + reasonText?: string }) { const agent = useAgent() @@ -30,18 +36,24 @@ export function VerifyEmailDialog({ control={control} onClose={async () => { if (!didVerify) { + onCloseWithoutVerifying?.() return } try { await agent.resumeSession(agent.session!) + onCloseAfterVerifying?.() } catch (e: unknown) { logger.error(String(e)) return } }}> - + ) } @@ -49,9 +61,11 @@ export function VerifyEmailDialog({ export function Inner({ control, setDidVerify, + reasonText, }: { control: Dialog.DialogControlProps setDidVerify: (value: boolean) => void + reasonText?: string }) { const {_} = useLingui() const {currentAccount} = useSession() @@ -135,26 +149,32 @@ export function Inner({ {currentStep === 'StepOne' ? ( <> - - You'll receive an email at{' '} - - {currentAccount?.email} - {' '} - to verify it's you. - {' '} - { - e.preventDefault() - control.close(() => { - openModal({name: 'change-email'}) - }) - return false - }}> - Need to change it? - + {!reasonText ? ( + <> + + You'll receive an email at{' '} + + {currentAccount?.email} + {' '} + to verify it's you. + {' '} + { + e.preventDefault() + control.close(() => { + openModal({name: 'change-email'}) + }) + return false + }}> + Need to change it? + + + ) : ( + reasonText + )} ) : ( uiStrings[currentStep].message diff --git a/src/components/dms/MessageProfileButton.tsx b/src/components/dms/MessageProfileButton.tsx index 932982d05e..22936b4c06 100644 --- a/src/components/dms/MessageProfileButton.tsx +++ b/src/components/dms/MessageProfileButton.tsx @@ -3,14 +3,18 @@ import {View} from 'react-native' import {AppBskyActorDefs} from '@atproto/api' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useNavigation} from '@react-navigation/native' +import {useEmail} from '#/lib/hooks/useEmail' +import {NavigationProp} from '#/lib/routes/types' import {logEvent} from '#/lib/statsig/statsig' import {useMaybeConvoForUser} from '#/state/queries/messages/get-convo-for-members' import {atoms as a, useTheme} from '#/alf' -import {ButtonIcon} from '#/components/Button' +import {Button, ButtonIcon} from '#/components/Button' import {canBeMessaged} from '#/components/dms/util' import {Message_Stroke2_Corner0_Rounded as Message} from '#/components/icons/Message' -import {Link} from '#/components/Link' +import {useDialogControl} from '../Dialog' +import {VerifyEmailDialog} from '../dialogs/VerifyEmailDialog' export function MessageProfileButton({ profile, @@ -19,15 +23,29 @@ export function MessageProfileButton({ }) { const {_} = useLingui() const t = useTheme() + const navigation = useNavigation() + const {needsEmailVerification} = useEmail() + const verifyEmailControl = useDialogControl() const {data: convo, isPending} = useMaybeConvoForUser(profile.did) const onPress = React.useCallback(() => { + if (!convo?.id) { + return + } + + if (needsEmailVerification) { + verifyEmailControl.open() + return + } + if (convo && !convo.lastMessage) { logEvent('chat:create', {logContext: 'ProfileHeader'}) } logEvent('chat:open', {logContext: 'ProfileHeader'}) - }, [convo]) + + navigation.navigate('MessagesConversation', {conversation: convo.id}) + }, [needsEmailVerification, verifyEmailControl, convo, navigation]) if (isPending) { // show pending state based on declaration @@ -53,18 +71,26 @@ export function MessageProfileButton({ if (convo) { return ( - - - + <> + + + ) } else { return null diff --git a/src/components/dms/dialogs/NewChatDialog.tsx b/src/components/dms/dialogs/NewChatDialog.tsx index e80fef2d7e..f402201a21 100644 --- a/src/components/dms/dialogs/NewChatDialog.tsx +++ b/src/components/dms/dialogs/NewChatDialog.tsx @@ -2,6 +2,7 @@ import React, {useCallback} from 'react' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' +import {useEmail} from '#/lib/hooks/useEmail' import {logEvent} from '#/lib/statsig/statsig' import {logger} from '#/logger' import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' @@ -9,6 +10,8 @@ import {FAB} from '#/view/com/util/fab/FAB' import * as Toast from '#/view/com/util/Toast' import {useTheme} from '#/alf' import * as Dialog from '#/components/Dialog' +import {useDialogControl} from '#/components/Dialog' +import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' import {SearchablePeopleList} from './SearchablePeopleList' @@ -21,6 +24,8 @@ export function NewChat({ }) { const t = useTheme() const {_} = useLingui() + const {needsEmailVerification} = useEmail() + const verifyEmailControl = useDialogControl() const {mutate: createChat} = useGetConvoForMembers({ onSuccess: data => { @@ -48,7 +53,13 @@ export function NewChat({ <> { + if (needsEmailVerification) { + verifyEmailControl.open() + } else { + control.open() + } + }} icon={} accessibilityRole="button" accessibilityLabel={_(msg`New chat`)} @@ -62,6 +73,13 @@ export function NewChat({ onSelectChat={onCreateChat} /> + + ) } diff --git a/src/lib/hooks/useEmail.ts b/src/lib/hooks/useEmail.ts new file mode 100644 index 0000000000..6e52846d12 --- /dev/null +++ b/src/lib/hooks/useEmail.ts @@ -0,0 +1,19 @@ +import {useServiceConfigQuery} from '#/state/queries/email-verification-required' +import {useSession} from '#/state/session' +import {BSKY_SERVICE} from '../constants' +import {getHostnameFromUrl} from '../strings/url-helpers' + +export function useEmail() { + const {currentAccount} = useSession() + + const {data: serviceConfig} = useServiceConfigQuery() + + const isSelfHost = + serviceConfig?.checkEmailConfirmed && + currentAccount && + getHostnameFromUrl(currentAccount.service) !== + getHostnameFromUrl(BSKY_SERVICE) + const needsEmailVerification = !isSelfHost && !currentAccount?.emailConfirmed + + return {needsEmailVerification} +} diff --git a/src/screens/Messages/Conversation.tsx b/src/screens/Messages/Conversation.tsx index e2e646a3d3..ee09adaf0e 100644 --- a/src/screens/Messages/Conversation.tsx +++ b/src/screens/Messages/Conversation.tsx @@ -4,10 +4,11 @@ import {useKeyboardController} from 'react-native-keyboard-controller' import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useFocusEffect} from '@react-navigation/native' +import {useFocusEffect, useNavigation} from '@react-navigation/native' import {NativeStackScreenProps} from '@react-navigation/native-stack' -import {CommonNavigatorParams} from '#/lib/routes/types' +import {useEmail} from '#/lib/hooks/useEmail' +import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types' import {isWeb} from '#/platform/detection' import {useProfileShadow} from '#/state/cache/profile-shadow' import {ConvoProvider, isConvoActive, useConvo} from '#/state/messages/convo' @@ -19,6 +20,8 @@ import {useSetMinimalShellMode} from '#/state/shell' import {CenteredView} from '#/view/com/util/Views' import {MessagesList} from '#/screens/Messages/components/MessagesList' import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' +import {useDialogControl} from '#/components/Dialog' +import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' import {MessagesListBlockedFooter} from '#/components/dms/MessagesListBlockedFooter' import {MessagesListHeader} from '#/components/dms/MessagesListHeader' import {Error} from '#/components/Error' @@ -161,8 +164,12 @@ function InnerReady({ hasScrolled: boolean setHasScrolled: React.Dispatch> }) { + const {_} = useLingui() const convoState = useConvo() + const navigation = useNavigation() const recipient = useProfileShadow(recipientUnshadowed) + const verifyEmailControl = useDialogControl() + const {needsEmailVerification} = useEmail() const moderation = React.useMemo(() => { return moderateProfile(recipient, moderationOpts) @@ -179,6 +186,12 @@ function InnerReady({ } }, [moderation]) + React.useEffect(() => { + if (needsEmailVerification) { + verifyEmailControl.open() + } + }, [needsEmailVerification, verifyEmailControl]) + return ( <> )} + { + navigation.navigate('Home') + }} + /> ) } diff --git a/src/screens/Messages/components/MessageInput.tsx b/src/screens/Messages/components/MessageInput.tsx index 21d6e574ea..8edad6272e 100644 --- a/src/screens/Messages/components/MessageInput.tsx +++ b/src/screens/Messages/components/MessageInput.tsx @@ -18,6 +18,7 @@ import Graphemer from 'graphemer' import {HITSLOP_10, MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants' import {useHaptics} from '#/lib/haptics' +import {useEmail} from '#/lib/hooks/useEmail' import {isIOS} from '#/platform/detection' import { useMessageDraft, @@ -61,10 +62,15 @@ export function MessageInput({ const [message, setMessage] = React.useState(getDraft) const inputRef = useAnimatedRef() + const {needsEmailVerification} = useEmail() + useSaveMessageDraft(message) useExtractEmbedFromFacets(message, setEmbed) const onSubmit = React.useCallback(() => { + if (needsEmailVerification) { + return + } if (!hasEmbed && message.trim() === '') { return } @@ -84,6 +90,7 @@ export function MessageInput({ inputRef.current?.focus() }, 100) }, [ + needsEmailVerification, hasEmbed, message, clearDraft, @@ -159,6 +166,7 @@ export function MessageInput({ ref={inputRef} hitSlop={HITSLOP_10} animatedProps={animatedProps} + editable={!needsEmailVerification} /> + onPress={onSubmit} + disabled={needsEmailVerification}> diff --git a/src/state/queries/email-verification-required.ts b/src/state/queries/email-verification-required.ts new file mode 100644 index 0000000000..94ff5cbc68 --- /dev/null +++ b/src/state/queries/email-verification-required.ts @@ -0,0 +1,25 @@ +import {useQuery} from '@tanstack/react-query' + +interface ServiceConfig { + checkEmailConfirmed: boolean +} + +export function useServiceConfigQuery() { + return useQuery({ + queryKey: ['service-config'], + queryFn: async () => { + const res = await fetch( + 'https://api.bsky.app/xrpc/app.bsky.unspecced.getConfig', + ) + if (!res.ok) { + return { + checkEmailConfirmed: false, + } + } + + const json = await res.json() + return json as ServiceConfig + }, + staleTime: 5 * 60 * 1000, + }) +} diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 1899966dcb..a581cb79e2 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -58,6 +58,7 @@ import {EmbeddingDisabledError} from '#/lib/api/resolve' import {until} from '#/lib/async/until' import {MAX_GRAPHEME_LENGTH} from '#/lib/constants' import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' +import {useEmail} from '#/lib/hooks/useEmail' import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {usePalette} from '#/lib/hooks/usePalette' @@ -110,6 +111,8 @@ import * as Toast from '#/view/com/util/Toast' import {UserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, native, useTheme} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {useDialogControl} from '#/components/Dialog' +import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji' import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' @@ -297,6 +300,15 @@ export const ComposePost = ({ } }, [onPressCancel, closeAllDialogs, closeAllModals]) + const {needsEmailVerification} = useEmail() + const emailVerificationControl = useDialogControl() + + useEffect(() => { + if (needsEmailVerification) { + emailVerificationControl.open() + } + }, [needsEmailVerification, emailVerificationControl]) + const missingAltError = useMemo(() => { if (!requireAltTextEnabled) { return @@ -570,6 +582,15 @@ export const ComposePost = ({ const isWebFooterSticky = !isNative && thread.posts.length > 1 return ( + { + onClose() + }} + reasonText={_( + msg`Before creating a post, you must first verify your email.`, + )} + /> export function ListsScreen({}: Props) { + const {_} = useLingui() const pal = usePalette('default') const setMinimalShellMode = useSetMinimalShellMode() const {isMobile} = useWebMediaQueries() const navigation = useNavigation() const {openModal} = useModalControls() + const {needsEmailVerification} = useEmail() + const control = useDialogControl() useFocusEffect( React.useCallback(() => { @@ -33,6 +40,11 @@ export function ListsScreen({}: Props) { ) const onPressNewList = React.useCallback(() => { + if (needsEmailVerification) { + control.open() + return + } + openModal({ name: 'create-or-edit-list', purpose: 'app.bsky.graph.defs#curatelist', @@ -46,7 +58,7 @@ export function ListsScreen({}: Props) { } catch {} }, }) - }, [openModal, navigation]) + }, [needsEmailVerification, control, openModal, navigation]) return ( @@ -87,6 +99,12 @@ export function ListsScreen({}: Props) { + ) } diff --git a/src/view/screens/ModerationModlists.tsx b/src/view/screens/ModerationModlists.tsx index b147ba502d..c623c5376f 100644 --- a/src/view/screens/ModerationModlists.tsx +++ b/src/view/screens/ModerationModlists.tsx @@ -2,9 +2,11 @@ import React from 'react' import {View} from 'react-native' import {AtUri} from '@atproto/api' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {Trans} from '@lingui/macro' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' import {useFocusEffect, useNavigation} from '@react-navigation/native' +import {useEmail} from '#/lib/hooks/useEmail' import {usePalette} from '#/lib/hooks/usePalette' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' @@ -16,15 +18,20 @@ import {MyLists} from '#/view/com/lists/MyLists' import {Button} from '#/view/com/util/forms/Button' import {SimpleViewHeader} from '#/view/com/util/SimpleViewHeader' import {Text} from '#/view/com/util/text/Text' +import {useDialogControl} from '#/components/Dialog' +import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog' import * as Layout from '#/components/Layout' type Props = NativeStackScreenProps export function ModerationModlistsScreen({}: Props) { + const {_} = useLingui() const pal = usePalette('default') const setMinimalShellMode = useSetMinimalShellMode() const {isMobile} = useWebMediaQueries() const navigation = useNavigation() const {openModal} = useModalControls() + const {needsEmailVerification} = useEmail() + const control = useDialogControl() useFocusEffect( React.useCallback(() => { @@ -33,6 +40,11 @@ export function ModerationModlistsScreen({}: Props) { ) const onPressNewList = React.useCallback(() => { + if (needsEmailVerification) { + control.open() + return + } + openModal({ name: 'create-or-edit-list', purpose: 'app.bsky.graph.defs#modlist', @@ -46,7 +58,7 @@ export function ModerationModlistsScreen({}: Props) { } catch {} }, }) - }, [openModal, navigation]) + }, [needsEmailVerification, control, openModal, navigation]) return ( @@ -83,6 +95,12 @@ export function ModerationModlistsScreen({}: Props) { + ) }