Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 34 additions & 5 deletions packages/mobile/src/components/core/ProfilePicture.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ const messages = {
profilePictureFor: 'Profile picture for'
}

// Per-URL render timeout (ms) so a hung connection from a slow Open Audio
// Validator Node advances to the next mirror instead of blocking on the OS
// TCP timeout (60–90s). Matches the web MirrorImage pattern.
const PROFILE_PICTURE_TIMEOUT_MS = 3000

type BaseAvatarProps = Omit<AvatarProps, 'source' | 'accessibilityLabel'>

// User should prefer userId, and provide user if it's not in the cache
Expand All @@ -23,23 +28,47 @@ type ProfilePictureUserProps =
export type ProfilePictureProps = BaseAvatarProps & ProfilePictureUserProps

export const ProfilePicture = (props: ProfilePictureProps) => {
const userId = 'user' in props ? props.user.user_id : props.userId
// Pull onError off so we can chain it with the mirror-retry handler from
// `useProfilePicture` instead of being clobbered by the props spread.
const { onError: callerOnError, ...restProps } = props as BaseAvatarProps &
ProfilePictureUserProps
const userId =
'user' in restProps ? restProps.user.user_id : restProps.userId

const { data: userQuery } = useUser(userId, { enabled: !('user' in props) })
const user = 'user' in props ? props.user : userQuery
const { data: userQuery } = useUser(userId, {
enabled: !('user' in restProps)
})
const user = 'user' in restProps ? restProps.user : userQuery

const accessibilityLabel = `${messages.profilePictureFor} ${user?.name}`

const { source } = useProfilePicture({
const {
source,
priorityLowResSource,
onError: onImageError
} = useProfilePicture({
userId,
size: SquareSizes.SIZE_150_BY_150
})

// Forward render-time failures to `useImageSize` so it can mark the URL
// failed and swap in the next mirror. Without this wiring the mirror
// retry only ever fires on prefetch failures, not on stalled renders.
const handleError = (error: { nativeEvent: { error: string } }) => {
if (source && typeof source === 'object' && 'uri' in source) {
onImageError?.(source.uri as string)
}
callerOnError?.(error)
}

return (
<Avatar
{...restProps}
source={source}
priorityLowResSource={priorityLowResSource}
accessibilityLabel={accessibilityLabel}
{...props}
onError={handleError}
timeoutMs={PROFILE_PICTURE_TIMEOUT_MS}
/>
)
}
6 changes: 6 additions & 0 deletions packages/mobile/src/components/image/UserImage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ import profilePicEmpty from 'app/assets/images/imageProfilePicEmpty2X.png'

import { primitiveToImageSource } from './primitiveToImageSource'

// Per-URL render timeout (ms) so a stalled Open Audio Validator Node
// advances to the next mirror without waiting on the OS TCP timeout
// (60–90s). Matches the web MirrorImage pattern.
const USER_IMAGE_TIMEOUT_MS = 3000

type UseUserImageOptions = {
userId: ID | null | undefined
size: SquareSizes
Expand Down Expand Up @@ -87,6 +92,7 @@ export const UserImage = (props: UserImageProps) => {
source={source}
priorityLowResSource={priorityLowResSource}
onError={handleError}
timeoutMs={USER_IMAGE_TIMEOUT_MS}
/>
)
}
4 changes: 3 additions & 1 deletion packages/mobile/src/harmony-native/components/Artwork.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export type ArtworkProps = {
* Enables true progressive image loading.
*/
priorityLowResSource?: ImageProps['source']
} & Partial<Pick<ImageProps, 'source' | 'onError' | 'onLoad'>> &
} & Partial<Pick<ImageProps, 'source' | 'onError' | 'onLoad' | 'timeoutMs'>> &
BoxProps

/**
Expand All @@ -44,6 +44,7 @@ export const Artwork = (props: ArtworkProps) => {
priorityLowResSource,
onError,
onLoad,
timeoutMs,
borderRadius = 's',
borderWidth,
shadow,
Expand Down Expand Up @@ -158,6 +159,7 @@ export const Artwork = (props: ArtworkProps) => {
onLoad?.(event)
}}
onError={onError}
timeoutMs={timeoutMs}
source={imageSource}
/>
) : null}
Expand Down
69 changes: 69 additions & 0 deletions packages/mobile/src/harmony-native/components/Image/Image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@ export type ImageProps = ComponentProps<typeof RNImage> & {
* When true, the high-res image appears immediately without a fade animation.
*/
immediate?: boolean
/**
* Optional render-time timeout (ms) for remote URI sources. If neither
* `onLoad` nor `onError` fires within this window, a synthetic `onError`
* is dispatched so callers (e.g. mirror-retry logic) can advance to the
* next URL without waiting on a hung TCP connection.
*
* Defaults to 0 (disabled). Only applies to remote `{ uri }` sources —
* local `require()`'d sources are unaffected.
*/
timeoutMs?: number
}

// Export ImageProps without source for render prop usage
Expand Down Expand Up @@ -65,9 +75,24 @@ export const Image = (props: ImageProps) => {
onLoad,
style,
immediate = false,
timeoutMs = 0,
...other
} = props

// Track whether the current source has resolved (loaded or errored) so the
// timeout fallback doesn't fire after the fact, and so we can clear pending
// timers on resolution.
const hasResolvedRef = useRef(false)
const timeoutTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)

// Keep the latest `onError` in a ref so the timeout closure always invokes
// the freshest callback without needing to be in the effect deps (which
// would reset the timer on every render).
const onErrorRef = useRef(onError)
useEffect(() => {
onErrorRef.current = onError
})

// Wrap onError to prevent Error objects from being passed as props
// This fixes "Error.stack getter called with an invalid receiver" in Hermes
const handleError = onError
Expand All @@ -77,6 +102,11 @@ export const Image = (props: ImageProps) => {
| { nativeEvent: ImageErrorEventData }
| Error
) => {
hasResolvedRef.current = true
if (timeoutTimerRef.current) {
clearTimeout(timeoutTimerRef.current)
timeoutTimerRef.current = null
}
if (error instanceof Error) {
onError({
nativeEvent: { error: error.message || 'Image load error' }
Expand Down Expand Up @@ -105,6 +135,40 @@ export const Image = (props: ImageProps) => {
}
}, [source, priorityLowResSource, startVisible, opacity])

// Render-time timeout: if the remote image hasn't fired onLoad or onError
// within `timeoutMs`, synthesize an onError so mirror-retry logic upstream
// can advance to the next URL. The native image cache and TCP stack will
// otherwise wait 60–90s on a hung connection without firing onError.
useEffect(() => {
hasResolvedRef.current = false
if (timeoutTimerRef.current) {
clearTimeout(timeoutTimerRef.current)
timeoutTimerRef.current = null
}

if (!timeoutMs || timeoutMs <= 0) return
if (!source || typeof source === 'number') return
const uri = Array.isArray(source) ? source[0]?.uri : source.uri
if (!uri) return

timeoutTimerRef.current = setTimeout(() => {
if (hasResolvedRef.current) return
hasResolvedRef.current = true
onErrorRef.current?.({
nativeEvent: {
error: `Image load timeout after ${timeoutMs}ms: ${uri}`
}
})
}, timeoutMs)

return () => {
if (timeoutTimerRef.current) {
clearTimeout(timeoutTimerRef.current)
timeoutTimerRef.current = null
}
}
}, [source, timeoutMs])

const animatedStyle = useAnimatedStyle(() => ({
opacity: opacity.value
}))
Expand All @@ -114,6 +178,11 @@ export const Image = (props: ImageProps) => {
}

const handleLoad = (e: NativeSyntheticEvent<ImageLoadEventData>) => {
hasResolvedRef.current = true
if (timeoutTimerRef.current) {
clearTimeout(timeoutTimerRef.current)
timeoutTimerRef.current = null
}
opacity.value = withTiming(1, { duration: immediate ? 100 : 300 })
// Hide the low-res placeholder shortly after the high-res starts fading in.
if (priorityLowResSource) {
Expand Down
Loading