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>
This commit is contained in:
Samuel Newman
2026-01-15 04:29:13 +02:00
committed by GitHub
parent f960d2fbc6
commit 1cb362b596
12 changed files with 122 additions and 31 deletions
+5 -5
View File
@@ -1,6 +1,6 @@
import {memo, useCallback, useMemo, useState} from 'react'
import {
Image,
Image as RNImage,
Pressable,
type StyleProp,
StyleSheet,
@@ -8,6 +8,7 @@ import {
type ViewStyle,
} from 'react-native'
import Svg, {Circle, Path, Rect} from 'react-native-svg'
import {Image as ExpoImage} from 'expo-image'
import {type ModerationUI} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro'
@@ -37,7 +38,6 @@ import {
} from '#/state/gallery'
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
import {EditImageDialog} from '#/view/com/composer/photos/EditImageDialog'
import {HighPriorityImage} from '#/view/com/util/images/Image'
import {atoms as a, tokens, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
@@ -289,7 +289,7 @@ let UserAvatar = ({
!((moderation?.blur && isAndroid) /* android crashes with blur */) ? (
<View style={containerStyle}>
{usePlainRNImage ? (
<Image
<RNImage
accessibilityIgnoresInvertColors
testID="userAvatarImage"
style={aviStyle}
@@ -301,7 +301,7 @@ let UserAvatar = ({
onLoad={onLoad}
/>
) : (
<HighPriorityImage
<ExpoImage
testID="userAvatarImage"
style={aviStyle}
contentFit="cover"
@@ -441,7 +441,7 @@ let EditableUserAvatar = ({
{({props}) => (
<Pressable {...props} testID="changeAvatarBtn">
{avatar ? (
<HighPriorityImage
<ExpoImage
testID="userAvatarImage"
style={aviStyle}
source={{uri: avatar}}
-244
View File
@@ -1,244 +0,0 @@
import React, {useRef} from 'react'
import {type DimensionValue, Pressable, View} from 'react-native'
import Animated, {
type AnimatedRef,
useAnimatedRef,
} from 'react-native-reanimated'
import {Image} from 'expo-image'
import {type AppBskyEmbedImages} from '@atproto/api'
import {utils} from '@bsky.app/alf'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {type Dimensions} from '#/lib/media/types'
import {isNative} from '#/platform/detection'
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
import {atoms as a, useTheme} from '#/alf'
import {ArrowsDiagonalOut_Stroke2_Corner0_Rounded as Fullscreen} from '#/components/icons/ArrowsDiagonal'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {Text} from '#/components/Typography'
export function ConstrainedImage({
aspectRatio,
fullBleed,
children,
minMobileAspectRatio,
}: {
aspectRatio: number
fullBleed?: boolean
minMobileAspectRatio?: number
children: React.ReactNode
}) {
const t = useTheme()
/**
* Computed as a % value to apply as `paddingTop`, this basically controls
* the height of the image.
*/
const outerAspectRatio = React.useMemo<DimensionValue>(() => {
const ratio = isNative
? Math.min(1 / aspectRatio, minMobileAspectRatio ?? 16 / 9) // 9:16 bounding box
: Math.min(1 / aspectRatio, 1) // 1:1 bounding box
return `${ratio * 100}%`
}, [aspectRatio, minMobileAspectRatio])
return (
<View style={[a.w_full]}>
<View style={[a.overflow_hidden, {paddingTop: outerAspectRatio}]}>
<View style={[a.absolute, a.inset_0, a.flex_row]}>
<View
style={[
a.h_full,
a.rounded_md,
a.overflow_hidden,
t.atoms.bg_contrast_25,
fullBleed ? a.w_full : {aspectRatio},
]}>
{children}
</View>
</View>
</View>
</View>
)
}
export function AutoSizedImage({
image,
crop = 'constrained',
hideBadge,
onPress,
onLongPress,
onPressIn,
}: {
image: AppBskyEmbedImages.ViewImage
crop?: 'none' | 'square' | 'constrained'
hideBadge?: boolean
onPress?: (
containerRef: AnimatedRef<any>,
fetchedDims: Dimensions | null,
) => void
onLongPress?: () => void
onPressIn?: () => void
}) {
const t = useTheme()
const {_} = useLingui()
const largeAlt = useLargeAltBadgeEnabled()
const containerRef = useAnimatedRef()
const fetchedDimsRef = useRef<{width: number; height: number} | null>(null)
let aspectRatio: number | undefined
const dims = image.aspectRatio
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 isContain = aspectRatio === undefined
const hasAlt = !!image.alt
const contents = (
<Animated.View ref={containerRef} collapsable={false} style={{flex: 1}}>
<Image
contentFit={isContain ? 'contain' : 'cover'}
style={[a.w_full, a.h_full]}
source={image.thumb}
accessible={true} // Must set for `accessibilityLabel` to work
accessibilityIgnoresInvertColors
accessibilityLabel={image.alt}
accessibilityHint=""
onLoad={e => {
if (!isContain) {
fetchedDimsRef.current = {
width: e.source.width,
height: e.source.height,
}
}
}}
/>
<MediaInsetBorder />
{(hasAlt || isCropped) && !hideBadge ? (
<View
accessible={false}
style={[
a.absolute,
a.flex_row,
{
bottom: a.p_xs.padding,
right: a.p_xs.padding,
gap: 3,
},
largeAlt && [
{
gap: 4,
},
],
]}>
{isCropped && (
<View
style={[
a.rounded_xs,
t.atoms.bg_contrast_25,
{
padding: 3,
opacity: 0.8,
},
largeAlt && [
{
padding: 5,
},
],
]}>
<Fullscreen
fill={t.atoms.text_contrast_high.color}
width={largeAlt ? 18 : 12}
/>
</View>
)}
{hasAlt && (
<View
style={[
a.justify_center,
a.rounded_xs,
t.atoms.bg_contrast_25,
{
padding: 3,
opacity: 0.8,
},
largeAlt && [
{
padding: 5,
},
],
]}>
<Text style={[a.font_bold, largeAlt ? a.text_xs : {fontSize: 8}]}>
ALT
</Text>
</View>
)}
</View>
) : null}
</Animated.View>
)
if (cropDisabled) {
return (
<Pressable
onPress={() => onPress?.(containerRef, fetchedDimsRef.current)}
onLongPress={onLongPress}
onPressIn={onPressIn}
// alt here is what screen readers actually use
accessibilityLabel={image.alt}
accessibilityHint={_(msg`Views full image`)}
accessibilityRole="button"
android_ripple={{
color: utils.alpha(t.atoms.bg.backgroundColor, 0.2),
foreground: true,
}}
style={[
a.w_full,
a.rounded_md,
a.overflow_hidden,
t.atoms.bg_contrast_25,
{aspectRatio: max ?? 1},
]}>
{contents}
</Pressable>
)
} else {
return (
<ConstrainedImage
fullBleed={crop === 'square'}
aspectRatio={constrained ?? 1}>
<Pressable
onPress={() => onPress?.(containerRef, fetchedDimsRef.current)}
onLongPress={onLongPress}
onPressIn={onPressIn}
// alt here is what screen readers actually use
accessibilityLabel={image.alt}
accessibilityHint={_(msg`Views full image`)}
accessibilityRole="button"
android_ripple={{
color: utils.alpha(t.atoms.bg.backgroundColor, 0.2),
foreground: true,
}}
style={[a.h_full]}>
{contents}
</Pressable>
</ConstrainedImage>
)
}
}
-124
View File
@@ -1,124 +0,0 @@
import {Pressable, type StyleProp, View, type ViewStyle} from 'react-native'
import {type AnimatedRef} from 'react-native-reanimated'
import {Image, type ImageStyle} from 'expo-image'
import {type AppBskyEmbedImages} from '@atproto/api'
import {utils} from '@bsky.app/alf'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {type Dimensions} from '#/lib/media/types'
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
import {atoms as a, useTheme} from '#/alf'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
import {Text} from '#/components/Typography'
type EventFunction = (index: number) => void
interface Props {
images: AppBskyEmbedImages.ViewImage[]
index: number
onPress?: (
index: number,
containerRefs: AnimatedRef<any>[],
fetchedDims: (Dimensions | null)[],
) => void
onLongPress?: EventFunction
onPressIn?: EventFunction
imageStyle?: StyleProp<ImageStyle>
viewContext?: PostEmbedViewContext
insetBorderStyle?: StyleProp<ViewStyle>
containerRefs: AnimatedRef<any>[]
thumbDimsRef: React.MutableRefObject<(Dimensions | null)[]>
}
export function GalleryItem({
images,
index,
imageStyle,
onPress,
onPressIn,
onLongPress,
viewContext,
insetBorderStyle,
containerRefs,
thumbDimsRef,
}: Props) {
const t = useTheme()
const {_} = useLingui()
const largeAltBadge = useLargeAltBadgeEnabled()
const image = images[index]
const hasAlt = !!image.alt
const hideBadges =
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
return (
<View style={a.flex_1} ref={containerRefs[index]} collapsable={false}>
<Pressable
onPress={
onPress
? () => onPress(index, containerRefs, thumbDimsRef.current.slice())
: undefined
}
onPressIn={onPressIn ? () => onPressIn(index) : undefined}
onLongPress={onLongPress ? () => onLongPress(index) : undefined}
android_ripple={{
color: utils.alpha(t.atoms.bg.backgroundColor, 0.2),
foreground: true,
}}
style={[
a.flex_1,
a.overflow_hidden,
t.atoms.bg_contrast_25,
imageStyle,
]}
accessibilityRole="button"
accessibilityLabel={image.alt || _(msg`Image`)}
accessibilityHint="">
<Image
source={{uri: image.thumb}}
style={[a.flex_1]}
accessible={true}
accessibilityLabel={image.alt}
accessibilityHint=""
accessibilityIgnoresInvertColors
onLoad={e => {
thumbDimsRef.current[index] = {
width: e.source.width,
height: e.source.height,
}
}}
/>
<MediaInsetBorder style={insetBorderStyle} />
</Pressable>
{hasAlt && !hideBadges ? (
<View
accessible={false}
style={[
a.absolute,
a.flex_row,
a.align_center,
a.rounded_xs,
t.atoms.bg_contrast_25,
{
gap: 3,
padding: 3,
bottom: a.p_xs.padding,
right: a.p_xs.padding,
opacity: 0.8,
},
largeAltBadge && [
{
gap: 4,
padding: 5,
},
],
]}>
<Text
style={[a.font_bold, largeAltBadge ? a.text_xs : {fontSize: 8}]}>
ALT
</Text>
</View>
) : null}
</View>
)
}
-13
View File
@@ -1,13 +0,0 @@
import {Image, type ImageProps, type ImageSource} from 'expo-image'
interface HighPriorityImageProps extends ImageProps {
source: ImageSource
}
export function HighPriorityImage({source, ...props}: HighPriorityImageProps) {
const updatedSource = {
uri: typeof source === 'object' && source ? source.uri : '',
} satisfies ImageSource
return (
<Image accessibilityIgnoresInvertColors source={updatedSource} {...props} />
)
}
-3
View File
@@ -1,3 +0,0 @@
import {Image} from 'react-native'
export const HighPriorityImage = Image
@@ -1,228 +0,0 @@
import React from 'react'
import {type StyleProp, View, type ViewStyle} from 'react-native'
import {type AnimatedRef, useAnimatedRef} from 'react-native-reanimated'
import {type AppBskyEmbedImages} from '@atproto/api'
import {atoms as a, useBreakpoints} from '#/alf'
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
import {type Dimensions} from '../../lightbox/ImageViewing/@types'
import {GalleryItem} from './Gallery'
interface ImageLayoutGridProps {
images: AppBskyEmbedImages.ViewImage[]
onPress?: (
index: number,
containerRefs: AnimatedRef<any>[],
fetchedDims: (Dimensions | null)[],
) => void
onLongPress?: (index: number) => void
onPressIn?: (index: number) => void
style?: StyleProp<ViewStyle>
viewContext?: PostEmbedViewContext
}
export function ImageLayoutGrid({style, ...props}: ImageLayoutGridProps) {
const {gtMobile} = useBreakpoints()
const gap =
props.viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
? gtMobile
? a.gap_xs
: a.gap_2xs
: a.gap_xs
return (
<View style={style}>
<View style={[gap, a.rounded_md, a.overflow_hidden]}>
<ImageLayoutGridInner {...props} gap={gap} />
</View>
</View>
)
}
interface ImageLayoutGridInnerProps {
images: AppBskyEmbedImages.ViewImage[]
onPress?: (
index: number,
containerRefs: AnimatedRef<any>[],
fetchedDims: (Dimensions | null)[],
) => void
onLongPress?: (index: number) => void
onPressIn?: (index: number) => void
viewContext?: PostEmbedViewContext
gap: {gap: number}
}
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()
const thumbDimsRef = React.useRef<(Dimensions | null)[]>([])
switch (count) {
case 2: {
const containerRefs = [containerRef1, containerRef2]
return (
<View style={[a.flex_1, a.flex_row, gap]}>
<View style={[a.flex_1, a.aspect_square]}>
<GalleryItem
{...props}
index={0}
insetBorderStyle={noCorners(['topRight', 'bottomRight'])}
containerRefs={containerRefs}
thumbDimsRef={thumbDimsRef}
/>
</View>
<View style={[a.flex_1, a.aspect_square]}>
<GalleryItem
{...props}
index={1}
insetBorderStyle={noCorners(['topLeft', 'bottomLeft'])}
containerRefs={containerRefs}
thumbDimsRef={thumbDimsRef}
/>
</View>
</View>
)
}
case 3: {
const containerRefs = [containerRef1, containerRef2, containerRef3]
return (
<View style={[a.flex_1, a.flex_row, gap]}>
<View style={[a.flex_1, a.aspect_square]}>
<GalleryItem
{...props}
index={0}
insetBorderStyle={noCorners(['topRight', 'bottomRight'])}
containerRefs={containerRefs}
thumbDimsRef={thumbDimsRef}
/>
</View>
<View style={[a.flex_1, a.aspect_square, gap]}>
<View style={[a.flex_1]}>
<GalleryItem
{...props}
index={1}
insetBorderStyle={noCorners([
'topLeft',
'bottomLeft',
'bottomRight',
])}
containerRefs={containerRefs}
thumbDimsRef={thumbDimsRef}
/>
</View>
<View style={[a.flex_1]}>
<GalleryItem
{...props}
index={2}
insetBorderStyle={noCorners([
'topLeft',
'bottomLeft',
'topRight',
])}
containerRefs={containerRefs}
thumbDimsRef={thumbDimsRef}
/>
</View>
</View>
</View>
)
}
case 4: {
const containerRefs = [
containerRef1,
containerRef2,
containerRef3,
containerRef4,
]
return (
<>
<View style={[a.flex_row, gap]}>
<View style={[a.flex_1, {aspectRatio: 1.5}]}>
<GalleryItem
{...props}
index={0}
insetBorderStyle={noCorners([
'bottomLeft',
'topRight',
'bottomRight',
])}
containerRefs={containerRefs}
thumbDimsRef={thumbDimsRef}
/>
</View>
<View style={[a.flex_1, {aspectRatio: 1.5}]}>
<GalleryItem
{...props}
index={1}
insetBorderStyle={noCorners([
'topLeft',
'bottomLeft',
'bottomRight',
])}
containerRefs={containerRefs}
thumbDimsRef={thumbDimsRef}
/>
</View>
</View>
<View style={[a.flex_row, gap]}>
<View style={[a.flex_1, {aspectRatio: 1.5}]}>
<GalleryItem
{...props}
index={2}
insetBorderStyle={noCorners([
'topLeft',
'topRight',
'bottomRight',
])}
containerRefs={containerRefs}
thumbDimsRef={thumbDimsRef}
/>
</View>
<View style={[a.flex_1, {aspectRatio: 1.5}]}>
<GalleryItem
{...props}
index={3}
insetBorderStyle={noCorners([
'topLeft',
'bottomLeft',
'topRight',
])}
containerRefs={containerRefs}
thumbDimsRef={thumbDimsRef}
/>
</View>
</View>
</>
)
}
default:
return null
}
}
function noCorners(
corners: ('topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight')[],
) {
const styles: StyleProp<ViewStyle>[] = []
if (corners.includes('topLeft')) {
styles.push({borderTopLeftRadius: 0})
}
if (corners.includes('topRight')) {
styles.push({borderTopRightRadius: 0})
}
if (corners.includes('bottomLeft')) {
styles.push({borderBottomLeftRadius: 0})
}
if (corners.includes('bottomRight')) {
styles.push({borderBottomRightRadius: 0})
}
return styles
}