Bluesky app fork with some witchin' additions 💫

Add `loading="lazy"` to expo-image on web (#9480)

* add `loading="lazy"` to expo-image

* add `loading="lazy"` to embed cards, avatars

* get rid of useless image wrapper indirection

* move image components to components dir

* fix imports

* fix import

* Keep avis eager

---------

Co-authored-by: Eric Bailey <git@esb.lol>

authored by samuel.fm

Eric Bailey and committed by
GitHub
1cb362b5 f960d2fb

+122 -31
+103
patches/expo-image+3.0.10.patch
··· 1 + diff --git a/node_modules/expo-image/build/Image.types.d.ts b/node_modules/expo-image/build/Image.types.d.ts 2 + index 022ae48..416504f 100644 3 + --- a/node_modules/expo-image/build/Image.types.d.ts 4 + +++ b/node_modules/expo-image/build/Image.types.d.ts 5 + @@ -152,6 +152,16 @@ export interface ImageProps extends Omit<ViewProps, 'style' | 'children'> { 6 + * @default 'normal' 7 + */ 8 + priority?: 'low' | 'normal' | 'high' | null; 9 + + /** 10 + + * The loading behavior for the image. Maps to the native HTML `loading` attribute on web. 11 + + * 12 + + * - `'lazy'` - Defers loading until the image is near the viewport. 13 + + * - `'eager'` - Loads the image immediately. 14 + + * 15 + + * @default undefined 16 + + * @platform web 17 + + */ 18 + + loading?: 'lazy' | 'eager' | null; 19 + /** 20 + * Determines whether to cache the image and where: on the disk, in the memory or both. 21 + * 22 + diff --git a/node_modules/expo-image/src/ExpoImage.web.tsx b/node_modules/expo-image/src/ExpoImage.web.tsx 23 + index 2a49ff0..1c3de93 100644 24 + --- a/node_modules/expo-image/src/ExpoImage.web.tsx 25 + +++ b/node_modules/expo-image/src/ExpoImage.web.tsx 26 + @@ -70,6 +70,7 @@ export default function ExpoImage({ 27 + onLoadEnd, 28 + onDisplay, 29 + priority, 30 + + loading, 31 + blurRadius, 32 + recyclingKey, 33 + style, 34 + @@ -118,6 +119,7 @@ export default function ExpoImage({ 35 + accessibilityLabel={accessibilityLabel ?? alt} 36 + cachePolicy={cachePolicy} 37 + priority={priority} 38 + + loading={loading} 39 + tintColor={tintColor} 40 + /> 41 + ), 42 + @@ -149,6 +151,7 @@ export default function ExpoImage({ 43 + className={className} 44 + cachePolicy={cachePolicy} 45 + priority={priority} 46 + + loading={loading} 47 + contentPosition={selectedSource ? contentPosition : { top: '50%', left: '50%' }} 48 + hashPlaceholderContentPosition={contentPosition} 49 + hashPlaceholderStyle={imageHashStyle} 50 + diff --git a/node_modules/expo-image/src/Image.types.ts b/node_modules/expo-image/src/Image.types.ts 51 + index 9dec0e7..61c1621 100644 52 + --- a/node_modules/expo-image/src/Image.types.ts 53 + +++ b/node_modules/expo-image/src/Image.types.ts 54 + @@ -178,6 +178,17 @@ export interface ImageProps extends Omit<ViewProps, 'style' | 'children'> { 55 + */ 56 + priority?: 'low' | 'normal' | 'high' | null; 57 + 58 + + /** 59 + + * The loading behavior for the image. Maps to the native HTML `loading` attribute on web. 60 + + * 61 + + * - `'lazy'` - Defers loading until the image is near the viewport. 62 + + * - `'eager'` - Loads the image immediately. 63 + + * 64 + + * @default undefined 65 + + * @platform web 66 + + */ 67 + + loading?: 'lazy' | 'eager' | null; 68 + + 69 + /** 70 + * Determines whether to cache the image and where: on the disk, in the memory or both. 71 + * 72 + diff --git a/node_modules/expo-image/src/web/ImageWrapper.tsx b/node_modules/expo-image/src/web/ImageWrapper.tsx 73 + index e8f891d..89a5cb1 100644 74 + --- a/node_modules/expo-image/src/web/ImageWrapper.tsx 75 + +++ b/node_modules/expo-image/src/web/ImageWrapper.tsx 76 + @@ -30,6 +30,7 @@ const ImageWrapper = React.forwardRef( 77 + contentPosition, 78 + hashPlaceholderContentPosition, 79 + priority, 80 + + loading, 81 + style, 82 + hashPlaceholderStyle, 83 + tintColor, 84 + @@ -82,6 +83,7 @@ const ImageWrapper = React.forwardRef( 85 + // @ts-ignore 86 + // eslint-disable-next-line react/no-unknown-property 87 + fetchPriority={getFetchPriorityFromImagePriority(priority || 'normal')} 88 + + loading={loading || undefined} 89 + {...getImageWrapperEventHandler(events, sourceWithHeaders)} 90 + {...getImgPropsFromSource(source)} 91 + {...props} 92 + diff --git a/node_modules/expo-image/src/web/ImageWrapper.types.ts b/node_modules/expo-image/src/web/ImageWrapper.types.ts 93 + index 19bbe2f..179837f 100644 94 + --- a/node_modules/expo-image/src/web/ImageWrapper.types.ts 95 + +++ b/node_modules/expo-image/src/web/ImageWrapper.types.ts 96 + @@ -29,6 +29,7 @@ export type ImageWrapperProps = { 97 + contentPosition?: ImageContentPositionObject; 98 + hashPlaceholderContentPosition?: ImageContentPositionObject; 99 + priority?: string | null; 100 + + loading?: 'lazy' | 'eager' | null; 101 + style: CSSProperties; 102 + tintColor?: string | null; 103 + hashPlaceholderStyle?: CSSProperties;
+1
src/components/Post/Embed/ExternalEmbed/ExternalPlayer.tsx
··· 226 226 style={[a.flex_1]} 227 227 source={{uri: link.thumb}} 228 228 accessibilityIgnoresInvertColors 229 + loading="lazy" 229 230 /> 230 231 <Fill 231 232 style={[
+1
src/components/Post/Embed/ExternalEmbed/index.tsx
··· 100 100 style={[a.aspect_card]} 101 101 source={{uri: imageUri}} 102 102 accessibilityIgnoresInvertColors 103 + loading="lazy" 103 104 /> 104 105 ) : undefined} 105 106
+2 -2
src/components/Post/Embed/ImageEmbed.tsx
··· 10 10 11 11 import {useLightboxControls} from '#/state/lightbox' 12 12 import {type Dimensions} from '#/view/com/lightbox/ImageViewing/@types' 13 - import {AutoSizedImage} from '#/view/com/util/images/AutoSizedImage' 14 - import {ImageLayoutGrid} from '#/view/com/util/images/ImageLayoutGrid' 15 13 import {atoms as a} from '#/alf' 14 + import {AutoSizedImage} from '#/components/images/AutoSizedImage' 15 + import {ImageLayoutGrid} from '#/components/images/ImageLayoutGrid' 16 16 import {PostEmbedViewContext} from '#/components/Post/Embed/types' 17 17 import {type EmbedType} from '#/types/bsky/post' 18 18 import {type CommonProps} from './types'
+1 -1
src/components/Post/Embed/VideoEmbed/index.tsx
··· 6 6 import {useLingui} from '@lingui/react' 7 7 8 8 import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' 9 - import {ConstrainedImage} from '#/view/com/util/images/AutoSizedImage' 10 9 import {atoms as a} from '#/alf' 11 10 import {Button} from '#/components/Button' 12 11 import {useThrottledValue} from '#/components/hooks/useThrottledValue' 12 + import {ConstrainedImage} from '#/components/images/AutoSizedImage' 13 13 import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' 14 14 import {VideoEmbedInnerNative} from './VideoEmbedInner/VideoEmbedInnerNative' 15 15 import * as VideoFallback from './VideoEmbedInner/VideoFallback'
+1 -1
src/components/Post/Embed/VideoEmbed/index.web.tsx
··· 13 13 14 14 import {isFirefox} from '#/lib/browser' 15 15 import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' 16 - import {ConstrainedImage} from '#/view/com/util/images/AutoSizedImage' 17 16 import {atoms as a, useTheme} from '#/alf' 18 17 import {useIsWithinMessage} from '#/components/dms/MessageContext' 19 18 import {useFullscreen} from '#/components/hooks/useFullscreen' 19 + import {ConstrainedImage} from '#/components/images/AutoSizedImage' 20 20 import {MediaInsetBorder} from '#/components/MediaInsetBorder' 21 21 import { 22 22 HLSUnsupportedError,
+5 -5
src/view/com/util/UserAvatar.tsx
··· 1 1 import {memo, useCallback, useMemo, useState} from 'react' 2 2 import { 3 - Image, 3 + Image as RNImage, 4 4 Pressable, 5 5 type StyleProp, 6 6 StyleSheet, ··· 8 8 type ViewStyle, 9 9 } from 'react-native' 10 10 import Svg, {Circle, Path, Rect} from 'react-native-svg' 11 + import {Image as ExpoImage} from 'expo-image' 11 12 import {type ModerationUI} from '@atproto/api' 12 13 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 13 14 import {msg, Trans} from '@lingui/macro' ··· 37 38 } from '#/state/gallery' 38 39 import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache' 39 40 import {EditImageDialog} from '#/view/com/composer/photos/EditImageDialog' 40 - import {HighPriorityImage} from '#/view/com/util/images/Image' 41 41 import {atoms as a, tokens, useTheme} from '#/alf' 42 42 import {Button} from '#/components/Button' 43 43 import {useDialogControl} from '#/components/Dialog' ··· 289 289 !((moderation?.blur && isAndroid) /* android crashes with blur */) ? ( 290 290 <View style={containerStyle}> 291 291 {usePlainRNImage ? ( 292 - <Image 292 + <RNImage 293 293 accessibilityIgnoresInvertColors 294 294 testID="userAvatarImage" 295 295 style={aviStyle} ··· 301 301 onLoad={onLoad} 302 302 /> 303 303 ) : ( 304 - <HighPriorityImage 304 + <ExpoImage 305 305 testID="userAvatarImage" 306 306 style={aviStyle} 307 307 contentFit="cover" ··· 441 441 {({props}) => ( 442 442 <Pressable {...props} testID="changeAvatarBtn"> 443 443 {avatar ? ( 444 - <HighPriorityImage 444 + <ExpoImage 445 445 testID="userAvatarImage" 446 446 style={aviStyle} 447 447 source={{uri: avatar}}
+3 -2
src/view/com/util/images/AutoSizedImage.tsx src/components/images/AutoSizedImage.tsx
··· 1 - import React, {useRef} from 'react' 1 + import {useMemo, useRef} from 'react' 2 2 import {type DimensionValue, Pressable, View} from 'react-native' 3 3 import Animated, { 4 4 type AnimatedRef, ··· 34 34 * Computed as a % value to apply as `paddingTop`, this basically controls 35 35 * the height of the image. 36 36 */ 37 - const outerAspectRatio = React.useMemo<DimensionValue>(() => { 37 + const outerAspectRatio = useMemo<DimensionValue>(() => { 38 38 const ratio = isNative 39 39 ? Math.min(1 / aspectRatio, minMobileAspectRatio ?? 16 / 9) // 9:16 bounding box 40 40 : Math.min(1 / aspectRatio, 1) // 1:1 bounding box ··· 127 127 } 128 128 } 129 129 }} 130 + loading="lazy" 130 131 /> 131 132 <MediaInsetBorder /> 132 133
+2 -1
src/view/com/util/images/Gallery.tsx src/components/images/Gallery.tsx
··· 29 29 viewContext?: PostEmbedViewContext 30 30 insetBorderStyle?: StyleProp<ViewStyle> 31 31 containerRefs: AnimatedRef<any>[] 32 - thumbDimsRef: React.MutableRefObject<(Dimensions | null)[]> 32 + thumbDimsRef: React.RefObject<(Dimensions | null)[]> 33 33 } 34 34 35 35 export function GalleryItem({ ··· 87 87 height: e.source.height, 88 88 } 89 89 }} 90 + loading="lazy" 90 91 /> 91 92 <MediaInsetBorder style={insetBorderStyle} /> 92 93 </Pressable>
-13
src/view/com/util/images/Image.tsx
··· 1 - import {Image, type ImageProps, type ImageSource} from 'expo-image' 2 - 3 - interface HighPriorityImageProps extends ImageProps { 4 - source: ImageSource 5 - } 6 - export function HighPriorityImage({source, ...props}: HighPriorityImageProps) { 7 - const updatedSource = { 8 - uri: typeof source === 'object' && source ? source.uri : '', 9 - } satisfies ImageSource 10 - return ( 11 - <Image accessibilityIgnoresInvertColors source={updatedSource} {...props} /> 12 - ) 13 - }
-3
src/view/com/util/images/Image.web.tsx
··· 1 - import {Image} from 'react-native' 2 - 3 - export const HighPriorityImage = Image
+3 -3
src/view/com/util/images/ImageLayoutGrid.tsx src/components/images/ImageLayoutGrid.tsx
··· 1 - import React from 'react' 1 + import {useRef} from 'react' 2 2 import {type StyleProp, View, type ViewStyle} from 'react-native' 3 3 import {type AnimatedRef, useAnimatedRef} from 'react-native-reanimated' 4 4 import {type AppBskyEmbedImages} from '@atproto/api' 5 5 6 + import {type Dimensions} from '#/view/com/lightbox/ImageViewing/@types' 6 7 import {atoms as a, useBreakpoints} from '#/alf' 7 8 import {PostEmbedViewContext} from '#/components/Post/Embed/types' 8 - import {type Dimensions} from '../../lightbox/ImageViewing/@types' 9 9 import {GalleryItem} from './Gallery' 10 10 11 11 interface ImageLayoutGridProps { ··· 60 60 const containerRef2 = useAnimatedRef() 61 61 const containerRef3 = useAnimatedRef() 62 62 const containerRef4 = useAnimatedRef() 63 - const thumbDimsRef = React.useRef<(Dimensions | null)[]>([]) 63 + const thumbDimsRef = useRef<(Dimensions | null)[]>([]) 64 64 65 65 switch (count) { 66 66 case 2: {