From 632ff3cfd289bd04f79e57d8e3c8594558c3cb3c Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj <31964049+isekovanic@users.noreply.github.com> Date: Tue, 5 May 2026 16:16:45 +0200 Subject: [PATCH 1/2] fix: video thumbnail loading (#3584) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ๐ŸŽฏ Goal This PR fixes video thumbnail loading logic so that it is consistent across video thumbnails and image attachments. Additionally, it: - Improves performance on Android by reducing the number of moving parts in the shimmering layer (same as we did for iOS) - Introduces an orchestrator so that each shimmering view doesn't create its own `ValueAnimator` unnecessarily - Makes sure offscreen views are not animating (they still stay registered though unless they actually unmount/detach) - Fixes offline images with the new `FastImage` library in the sample app ## ๐Ÿ›  Implementation details ## ๐ŸŽจ UI Changes
iOS
Before After
Android
Before After
## ๐Ÿงช Testing ## โ˜‘๏ธ Checklist - [ ] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required) - [ ] PR targets the `develop` branch - [ ] Documentation is updated - [ ] New code is tested in main example apps, including all possible scenarios - [ ] SampleApp iOS and Android - [ ] Expo iOS and Android --- .../src/components/FastImageAdapter.tsx | 48 ++++++ .../SampleAppComponentOverrides.tsx | 57 +++---- .../android/StreamShimmerFrameLayout.kt | 156 ++++++++++++------ package/src/components/Attachment/Gallery.tsx | 59 +------ .../components/Attachment/GalleryImage.tsx | 94 ++++++++++- .../Attachment/ImageLoadingIndicator.tsx | 18 +- .../components/Attachment/VideoThumbnail.tsx | 31 +++- .../__tests__/VideoThumbnail.test.tsx | 44 +++++ 8 files changed, 346 insertions(+), 161 deletions(-) create mode 100644 examples/SampleApp/src/components/FastImageAdapter.tsx create mode 100644 package/src/components/Attachment/__tests__/VideoThumbnail.test.tsx diff --git a/examples/SampleApp/src/components/FastImageAdapter.tsx b/examples/SampleApp/src/components/FastImageAdapter.tsx new file mode 100644 index 0000000000..2717160c8d --- /dev/null +++ b/examples/SampleApp/src/components/FastImageAdapter.tsx @@ -0,0 +1,48 @@ +import React, { useMemo } from 'react'; +import { ImageProps } from 'react-native'; +import FastImage from '@d11/react-native-fast-image'; +import type { FastImageProps } from '@d11/react-native-fast-image'; +import { useChatContext } from 'stream-chat-react-native'; + +type FastImageAdapterProps = Omit & + Pick; + +export const FastImageAdapter = React.memo((props: ImageProps) => { + const { isOnline } = useChatContext(); + const { + source, + transition = FastImage.transition.fade, + ...rest + } = props as FastImageAdapterProps; + + const resolvedSource = useMemo(() => { + if ( + !source || + typeof source !== 'object' || + Array.isArray(source) || + !('uri' in source) || + typeof source.uri !== 'string' || + !/^https?:\/\//i.test(source.uri) + ) { + return source; + } + + return { + ...source, + cache: + source.cache ?? + (isOnline === false ? FastImage.cacheControl.cacheOnly : FastImage.cacheControl.immutable), + priority: source.priority ?? FastImage.priority.normal, + }; + }, [isOnline, source]); + + return ( + )} + source={resolvedSource} + transition={transition} + /> + ); +}); + +FastImageAdapter.displayName = 'FastImageAdapter'; diff --git a/examples/SampleApp/src/components/SampleAppComponentOverrides.tsx b/examples/SampleApp/src/components/SampleAppComponentOverrides.tsx index 5bba6e1624..78037184c9 100644 --- a/examples/SampleApp/src/components/SampleAppComponentOverrides.tsx +++ b/examples/SampleApp/src/components/SampleAppComponentOverrides.tsx @@ -2,42 +2,39 @@ import React, { useMemo } from 'react'; import { Platform, StyleSheet, useColorScheme, View } from 'react-native'; import type { ComponentOverrides } from 'stream-chat-react-native'; import { BlurView } from '@react-native-community/blur'; -import FastImage from '@d11/react-native-fast-image'; -import { - useTheme, -} from 'stream-chat-react-native'; +import { useTheme } from 'stream-chat-react-native'; import { CustomAttachmentPickerContent } from './AttachmentPickerContent'; import { CustomChannelPreviewStatus } from './ChannelPreview'; +import { FastImageAdapter } from './FastImageAdapter'; import { MessageLocation } from './LocationSharing/MessageLocation'; import type { MessageOverlayBackdropConfigItem } from './SecretMenu'; -const MessageOverlayBlurBackground: NonNullable = - () => { - const { - theme: { semantics }, - } = useTheme(); - const scheme = useColorScheme(); - const isDark = scheme === 'dark'; - const isIOS = Platform.OS === 'ios'; +const MessageOverlayBlurBackground: NonNullable< + ComponentOverrides['MessageOverlayBackground'] +> = () => { + const { + theme: { semantics }, + } = useTheme(); + const scheme = useColorScheme(); + const isDark = scheme === 'dark'; + const isIOS = Platform.OS === 'ios'; - return ( - <> - - - - ); - }; + return ( + <> + + + + ); +}; const RenderNull = () => null; @@ -48,7 +45,7 @@ export const useSampleAppComponentOverrides = ( () => ({ AttachmentPickerContent: CustomAttachmentPickerContent, ChannelListHeaderNetworkDownIndicator: RenderNull, - ImageComponent: FastImage, + ImageComponent: FastImageAdapter, MessageLocation, NetworkDownIndicator: RenderNull, ChannelPreviewStatus: CustomChannelPreviewStatus, diff --git a/package/shared-native/android/StreamShimmerFrameLayout.kt b/package/shared-native/android/StreamShimmerFrameLayout.kt index da84863d77..b2f2a64492 100644 --- a/package/shared-native/android/StreamShimmerFrameLayout.kt +++ b/package/shared-native/android/StreamShimmerFrameLayout.kt @@ -1,16 +1,16 @@ package com.streamchatreactnative -import android.animation.ValueAnimator import android.content.Context import android.graphics.Canvas import android.graphics.Color import android.graphics.LinearGradient import android.graphics.Matrix import android.graphics.Paint +import android.graphics.Rect import android.graphics.Shader import android.util.AttributeSet +import android.view.Choreographer import android.view.View -import android.view.animation.LinearInterpolator import android.widget.FrameLayout import kotlin.math.roundToInt @@ -38,12 +38,12 @@ class StreamShimmerFrameLayout @JvmOverloads constructor( isDither = true } private val shimmerMatrix = Matrix() + private val visibleViewportRect = Rect() private var shimmerShader: LinearGradient? = null private var shimmerTranslateX: Float = 0f - private var animatedDurationMs: Long = 0L - private var animatedViewWidth: Float = 0f - private var animator: ValueAnimator? = null + private var isRegisteredForShimmerFrames: Boolean = false + private var shimmerStartTimeNanos: Long = UNSET_FRAME_TIME_NANOS init { setWillNotDraw(false) @@ -68,6 +68,7 @@ class StreamShimmerFrameLayout @JvmOverloads constructor( if (duration > 0) duration.toLong() else DEFAULT_DURATION_MS if (durationMs == normalizedDurationMs) return durationMs = normalizedDurationMs + shimmerStartTimeNanos = UNSET_FRAME_TIME_NANOS updateAnimatorState() } @@ -79,8 +80,8 @@ class StreamShimmerFrameLayout @JvmOverloads constructor( } fun updateAnimatorState() { - // Centralized lifecycle gate for animation start/stop. This keeps shimmer off for detached or - // hidden views to avoid wasting UI-thread work in long lists. + // Centralized lifecycle gate for the shared frame clock. This keeps shimmer off for detached or + // hidden views and prevents every mounted shimmer from owning a separate ValueAnimator. if (shouldAnimateShimmer()) { startShimmer() } else { @@ -96,7 +97,7 @@ class StreamShimmerFrameLayout @JvmOverloads constructor( } override fun onDetachedFromWindow() { - // Detached views are not drawable; stop and clear animator so a future attach starts cleanly. + // Detached views are not drawable; unregister so a future attach starts cleanly. stopShimmer() super.onDetachedFromWindow() } @@ -114,9 +115,7 @@ class StreamShimmerFrameLayout @JvmOverloads constructor( override fun onVisibilityChanged(changedView: View, visibility: Int) { super.onVisibilityChanged(changedView, visibility) - if (changedView === this) { - updateAnimatorState() - } + updateAnimatorState() } override fun dispatchDraw(canvas: Canvas) { @@ -155,13 +154,10 @@ class StreamShimmerFrameLayout @JvmOverloads constructor( return } - // Wide multi-stop strip creates a softer "glassy" sweep and avoids the hard thin-line look. + // Match iOS CAGradientLayer shimmer stops so both platforms have the same visual falloff. val shimmerWidth = (viewWidth * SHIMMER_STRIP_WIDTH_RATIO).coerceAtLeast(1f) val transparentHighlight = colorWithAlpha(gradientColor, 0f) - val edgeBase = colorWithAlpha(gradientColor, EDGE_HIGHLIGHT_ALPHA_FACTOR) val softBase = colorWithAlpha(gradientColor, SOFT_HIGHLIGHT_ALPHA_FACTOR) - val mediumBase = colorWithAlpha(gradientColor, MID_HIGHLIGHT_ALPHA_FACTOR) - val innerBase = colorWithAlpha(gradientColor, INNER_HIGHLIGHT_ALPHA_FACTOR) shimmerShader = LinearGradient( 0f, 0f, @@ -169,28 +165,16 @@ class StreamShimmerFrameLayout @JvmOverloads constructor( 0f, intArrayOf( transparentHighlight, - edgeBase, softBase, - mediumBase, - innerBase, gradientColor, - innerBase, - mediumBase, softBase, - edgeBase, transparentHighlight, ), floatArrayOf( 0f, - 0.08f, - 0.2f, - 0.32f, - 0.4f, + 0.35f, 0.5f, - 0.6f, - 0.68f, - 0.8f, - 0.92f, + 0.65f, 1f, ), Shader.TileMode.CLAMP, @@ -198,34 +182,59 @@ class StreamShimmerFrameLayout @JvmOverloads constructor( } private fun startShimmer() { + if (isRegisteredForShimmerFrames) return val viewWidth = width.toFloat() - if (viewWidth <= 0f) return - // Keep the existing animator only when size and duration still match the current request. - if (animator != null && animatedViewWidth == viewWidth && animatedDurationMs == durationMs) return + shimmerStartTimeNanos = UNSET_FRAME_TIME_NANOS + if (viewWidth > 0f) { + shimmerTranslateX = -(viewWidth * SHIMMER_STRIP_WIDTH_RATIO).coerceAtLeast(1f) + } + isRegisteredForShimmerFrames = true + StreamShimmerFrameClock.register(this) + } - stopShimmer() + private fun stopShimmer() { + if (isRegisteredForShimmerFrames) { + isRegisteredForShimmerFrames = false + StreamShimmerFrameClock.unregister(this) + } + resetShimmerFrameState() + } + + internal fun onSharedShimmerFrame(frameTimeNanos: Long) { + val viewWidth = width.toFloat() + if (viewWidth <= 0f || !hasVisibleViewport()) return + + if (shimmerStartTimeNanos == UNSET_FRAME_TIME_NANOS) { + shimmerStartTimeNanos = frameTimeNanos + } // Animate from fully offscreen left to fully offscreen right so the strip enters/exits cleanly. val shimmerWidth = (viewWidth * SHIMMER_STRIP_WIDTH_RATIO).coerceAtLeast(1f) - animatedViewWidth = viewWidth - animatedDurationMs = durationMs - animator = ValueAnimator.ofFloat(-shimmerWidth, viewWidth).apply { - duration = durationMs - repeatCount = ValueAnimator.INFINITE - interpolator = LinearInterpolator() - addUpdateListener { - shimmerTranslateX = it.animatedValue as Float - invalidate() - } - start() - } + val durationNanos = (durationMs * NANOS_PER_MILLISECOND).coerceAtLeast(1L) + val elapsedNanos = (frameTimeNanos - shimmerStartTimeNanos).coerceAtLeast(0L) + val progress = (elapsedNanos % durationNanos).toFloat() / durationNanos.toFloat() + + shimmerTranslateX = -shimmerWidth + ((viewWidth + shimmerWidth) * progress) + invalidate() } - private fun stopShimmer() { - animator?.cancel() - animator = null - animatedDurationMs = 0L - animatedViewWidth = 0f + internal fun onRemovedFromSharedFrameClock() { + isRegisteredForShimmerFrames = false + resetShimmerFrameState() + } + + internal fun shouldRunSharedShimmerFrame(): Boolean { + return shouldAnimateShimmer() + } + + private fun hasVisibleViewport(): Boolean { + visibleViewportRect.setEmpty() + return getGlobalVisibleRect(visibleViewportRect) && !visibleViewportRect.isEmpty + } + + private fun resetShimmerFrameState() { + shimmerStartTimeNanos = UNSET_FRAME_TIME_NANOS + shimmerTranslateX = 0f } private fun shouldAnimateShimmer(): Boolean { @@ -251,10 +260,51 @@ class StreamShimmerFrameLayout @JvmOverloads constructor( private const val DEFAULT_BASE_COLOR = 0x00FFFFFF private const val DEFAULT_DURATION_MS = 1200L private const val DEFAULT_GRADIENT_COLOR = 0x59FFFFFF + private const val NANOS_PER_MILLISECOND = 1_000_000L private const val SHIMMER_STRIP_WIDTH_RATIO = 1.25f - private const val EDGE_HIGHLIGHT_ALPHA_FACTOR = 0.1f private const val SOFT_HIGHLIGHT_ALPHA_FACTOR = 0.24f - private const val MID_HIGHLIGHT_ALPHA_FACTOR = 0.48f - private const val INNER_HIGHLIGHT_ALPHA_FACTOR = 0.72f + private const val UNSET_FRAME_TIME_NANOS = -1L + } +} + +private object StreamShimmerFrameClock : Choreographer.FrameCallback { + private val activeViews = LinkedHashSet() + private var frameScheduled = false + + fun register(view: StreamShimmerFrameLayout) { + activeViews.add(view) + scheduleNextFrame() + } + + fun unregister(view: StreamShimmerFrameLayout) { + activeViews.remove(view) + if (activeViews.isEmpty() && frameScheduled) { + Choreographer.getInstance().removeFrameCallback(this) + frameScheduled = false + } + } + + override fun doFrame(frameTimeNanos: Long) { + frameScheduled = false + if (activeViews.isEmpty()) return + + val iterator = activeViews.iterator() + while (iterator.hasNext()) { + val view = iterator.next() + if (view.shouldRunSharedShimmerFrame()) { + view.onSharedShimmerFrame(frameTimeNanos) + } else { + iterator.remove() + view.onRemovedFromSharedFrameClock() + } + } + + scheduleNextFrame() + } + + private fun scheduleNextFrame() { + if (frameScheduled || activeViews.isEmpty()) return + Choreographer.getInstance().postFrameCallback(this) + frameScheduled = true } } diff --git a/package/src/components/Attachment/Gallery.tsx b/package/src/components/Attachment/Gallery.tsx index 716d2e325c..0b933819eb 100644 --- a/package/src/components/Attachment/Gallery.tsx +++ b/package/src/components/Attachment/Gallery.tsx @@ -1,9 +1,9 @@ import React, { useMemo } from 'react'; -import { ImageErrorEvent, Pressable, StyleSheet, Text, View } from 'react-native'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; import type { Attachment, LocalMessage } from 'stream-chat'; -import { GalleryImage } from './GalleryImage'; +import { LoadableGalleryImage } from './GalleryImage'; import { buildGallery } from './utils/buildGallery/buildGallery'; import type { Thumbnail } from './utils/buildGallery/types'; @@ -35,8 +35,6 @@ import { } from '../../contexts/overlayContext/OverlayContext'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; -import { useLoadingImage } from '../../hooks/useLoadingImage'; -import { useStableCallback } from '../../hooks/useStableCallback'; import { isVideoPlayerAvailable } from '../../native'; import { primitives } from '../../theme'; import { FileTypes } from '../../types/types'; @@ -331,15 +329,6 @@ const GalleryImageThumbnail = ({ borderRadius, thumbnail, }: Pick) => { - const { AttachmentUploadIndicator, ImageLoadingFailedIndicator, ImageLoadingIndicator } = - useComponentsContext(); - const { - isLoadingImage, - isLoadingImageError, - onReloadImage, - setLoadingImage, - setLoadingImageError, - } = useLoadingImage(); const { theme: { messageItemView: { gallery }, @@ -347,44 +336,14 @@ const GalleryImageThumbnail = ({ } = useTheme(); const styles = useStyles(); - const onLoadStart = useStableCallback(() => { - setLoadingImageError(false); - setLoadingImage(true); - }); - const onLoad = useStableCallback(() => { - setTimeout(() => { - setLoadingImage(false); - setLoadingImageError(false); - }, 0); - }); - const onError = useStableCallback(({ nativeEvent: { error } }: ImageErrorEvent) => { - console.warn(error); - setLoadingImage(false); - setLoadingImageError(true); - }); return ( - - {isLoadingImageError ? ( - - ) : ( - <> - - {isLoadingImage ? : null} - - - )} - + ); }; const areEqual = (prevProps: GalleryPropsWithContext, nextProps: GalleryPropsWithContext) => { diff --git a/package/src/components/Attachment/GalleryImage.tsx b/package/src/components/Attachment/GalleryImage.tsx index 70a754d540..0b7d0b7221 100644 --- a/package/src/components/Attachment/GalleryImage.tsx +++ b/package/src/components/Attachment/GalleryImage.tsx @@ -1,7 +1,18 @@ import React from 'react'; -import { Image, ImageProps, StyleSheet } from 'react-native'; +import { + Image, + ImageErrorEvent, + ImageProps, + ImageStyle, + StyleProp, + StyleSheet, + View, + ViewStyle, +} from 'react-native'; import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext'; +import { useLoadingImage } from '../../hooks/useLoadingImage'; +import { useStableCallback } from '../../hooks/useStableCallback'; import { getUrlWithoutParams, isLocalUrl, makeImageCompatibleUrl } from '../../utils/utils'; @@ -10,7 +21,13 @@ export type GalleryImageWithContextProps = GalleryImageProps & { }; export const GalleryImageWithContext = (props: GalleryImageWithContextProps) => { - const { ImageComponent = Image, uri, style, ...rest } = props; + const { + accessibilityLabel = 'Gallery Image', + ImageComponent = Image, + uri, + style, + ...rest + } = props; // Caching image components such as FastImage will not work with local images. // This for the case of local uris, we use the default Image component. @@ -18,7 +35,7 @@ export const GalleryImageWithContext = (props: GalleryImageWithContextProps) => return ( return ( { return ; }; +export type LoadableGalleryImageProps = Pick< + GalleryImageProps, + 'accessibilityLabel' | 'resizeMode' | 'uri' +> & { + children?: React.ReactNode; + containerStyle?: StyleProp; + imageStyle?: StyleProp; + localId?: string; +}; + +export const LoadableGalleryImage = ({ + accessibilityLabel = 'Gallery Image', + children, + containerStyle, + imageStyle, + localId, + resizeMode, + uri, +}: LoadableGalleryImageProps) => { + const { AttachmentUploadIndicator, ImageLoadingFailedIndicator, ImageLoadingIndicator } = + useComponentsContext(); + const { + isLoadingImage, + isLoadingImageError, + onReloadImage, + setLoadingImage, + setLoadingImageError, + } = useLoadingImage(); + + const onLoadStart = useStableCallback(() => { + setLoadingImageError(false); + setLoadingImage(true); + }); + const onLoad = useStableCallback(() => { + setTimeout(() => { + setLoadingImage(false); + setLoadingImageError(false); + }, 0); + }); + const onError = useStableCallback(({ nativeEvent: { error } }: ImageErrorEvent) => { + console.warn(error); + setLoadingImage(false); + setLoadingImageError(true); + }); + + return ( + + {isLoadingImageError ? ( + + ) : ( + <> + + {children} + {isLoadingImage ? : null} + + + )} + + ); +}; + const styles = StyleSheet.create({ image: { flex: 1, diff --git a/package/src/components/Attachment/ImageLoadingIndicator.tsx b/package/src/components/Attachment/ImageLoadingIndicator.tsx index 29897f22ba..c5958f11b4 100644 --- a/package/src/components/Attachment/ImageLoadingIndicator.tsx +++ b/package/src/components/Attachment/ImageLoadingIndicator.tsx @@ -1,10 +1,10 @@ import React from 'react'; -import { ActivityIndicator, StyleSheet, View } from 'react-native'; +import { StyleSheet } from 'react-native'; import { useTheme } from '../../contexts'; import { NativeShimmerView } from '../UIComponents/NativeShimmerView'; -export const ImageLoadingIndicator = () => { +export const ImageLoadingIndicator = React.memo(() => { const { theme: { semantics }, } = useTheme(); @@ -15,18 +15,6 @@ export const ImageLoadingIndicator = () => { enabled gradientColor={semantics.skeletonLoadingHighlight} style={StyleSheet.absoluteFill} - > - - - - + /> ); -}; - -const styles = StyleSheet.create({ - centered: { - alignItems: 'center', - flex: 1, - justifyContent: 'center', - }, }); diff --git a/package/src/components/Attachment/VideoThumbnail.tsx b/package/src/components/Attachment/VideoThumbnail.tsx index 8e30036bbb..118ab96e91 100644 --- a/package/src/components/Attachment/VideoThumbnail.tsx +++ b/package/src/components/Attachment/VideoThumbnail.tsx @@ -1,7 +1,8 @@ import React from 'react'; import { ImageStyle, StyleProp, StyleSheet, View, ViewStyle } from 'react-native'; -import { useComponentsContext } from '../../contexts/componentsContext/ComponentsContext'; +import { LoadableGalleryImage } from './GalleryImage'; + import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { VideoPlayIndicator } from '../ui/VideoPlayIndicator'; @@ -12,6 +13,10 @@ const styles = StyleSheet.create({ flex: 1, overflow: 'hidden', }, + playIndicatorContainer: { + alignItems: 'center', + justifyContent: 'center', + }, }); export type VideoThumbnailProps = { @@ -32,18 +37,26 @@ export const VideoThumbnail = (props: VideoThumbnailProps) => { }, }, } = useTheme(); - const { AttachmentUploadIndicator, ImageComponent } = useComponentsContext(); const { imageStyle, localId, style, thumb_url } = props; return ( - - - + {thumb_url ? ( + + + + + + ) : null} ); }; diff --git a/package/src/components/Attachment/__tests__/VideoThumbnail.test.tsx b/package/src/components/Attachment/__tests__/VideoThumbnail.test.tsx new file mode 100644 index 0000000000..7aa79b5783 --- /dev/null +++ b/package/src/components/Attachment/__tests__/VideoThumbnail.test.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { ImageProps, View } from 'react-native'; + +import { fireEvent, render, screen } from '@testing-library/react-native'; + +import { WithComponents } from '../../../contexts/componentsContext/ComponentsContext'; +import { ThemeProvider } from '../../../contexts/themeContext/ThemeContext'; +import { VideoThumbnail } from '../VideoThumbnail'; + +const CustomImageComponent = (props: ImageProps) => ( + +); + +const renderVideoThumbnail = (thumb_url: string) => + render( + + + + + , + ); + +describe('VideoThumbnail', () => { + it('uses the configured ImageComponent for remote thumbnail URLs', () => { + renderVideoThumbnail('https://example.com/video-thumbnail.jpg'); + + expect(screen.getByTestId('custom-image-component')).toBeTruthy(); + }); + + it('uses the default Image fallback for local thumbnail paths', () => { + renderVideoThumbnail('file:///tmp/video-thumbnail.jpg'); + + expect(screen.queryByTestId('custom-image-component')).toBeNull(); + expect(screen.getByLabelText('Video Thumbnail')).toBeTruthy(); + }); + + it('renders the image loading indicator while the thumbnail is loading', () => { + renderVideoThumbnail('file:///tmp/video-thumbnail.jpg'); + + fireEvent(screen.getByLabelText('Video Thumbnail'), 'loadStart'); + + expect(screen.getByLabelText('Image Loading Indicator')).toBeTruthy(); + }); +}); From 25f8d36b8876d797e528d1510630e74b0a4e8491 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj <31964049+isekovanic@users.noreply.github.com> Date: Tue, 5 May 2026 16:56:33 +0200 Subject: [PATCH 2/2] fix: bump stream-chat to fix commands regression (#3585) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ๐ŸŽฏ Goal ## ๐Ÿ›  Implementation details ## ๐ŸŽจ UI Changes
iOS
Before After
Android
Before After
## ๐Ÿงช Testing ## โ˜‘๏ธ Checklist - [ ] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required) - [ ] PR targets the `develop` branch - [ ] Documentation is updated - [ ] New code is tested in main example apps, including all possible scenarios - [ ] SampleApp iOS and Android - [ ] Expo iOS and Android --- package/package.json | 2 +- package/src/utils/utils.ts | 8 ++++---- package/yarn.lock | 8 ++++---- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/package/package.json b/package/package.json index 8d2252c573..21b1d25e0d 100644 --- a/package/package.json +++ b/package/package.json @@ -83,7 +83,7 @@ "path": "0.12.7", "react-native-markdown-package": "1.8.2", "react-native-url-polyfill": "^2.0.0", - "stream-chat": "^9.43.0", + "stream-chat": "^9.43.1", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { diff --git a/package/src/utils/utils.ts b/package/src/utils/utils.ts index 2c6533634f..282a72dcb6 100644 --- a/package/src/utils/utils.ts +++ b/package/src/utils/utils.ts @@ -325,8 +325,8 @@ export const findInMessagesByDate = ( * Useful for the `areEqual` logic in the React.memo of the Message component/sub-components. */ export const checkMessageEquality = ( - prevMessage?: LocalMessage, - nextMessage?: LocalMessage, + prevMessage?: LocalMessage | null, + nextMessage?: LocalMessage | null, ): boolean => { const prevMessageExists = !!prevMessage; const nextMessageExists = !!nextMessage; @@ -357,8 +357,8 @@ export const checkMessageEquality = ( * Useful for the `areEqual` logic in the React.memo of the Message component/sub-components. */ export const checkQuotedMessageEquality = ( - prevQuotedMessage?: LocalMessage, - nextQuotedMessage?: LocalMessage, + prevQuotedMessage?: LocalMessage | null, + nextQuotedMessage?: LocalMessage | null, ): boolean => { const prevQuotedMessageExists = !!prevQuotedMessage; const nextQuotedMessageExists = !!nextQuotedMessage; diff --git a/package/yarn.lock b/package/yarn.lock index e43da00e63..14eff97d5e 100644 --- a/package/yarn.lock +++ b/package/yarn.lock @@ -8507,10 +8507,10 @@ stdin-discarder@^0.2.2: resolved "https://registry.yarnpkg.com/stdin-discarder/-/stdin-discarder-0.2.2.tgz#390037f44c4ae1a1ae535c5fe38dc3aba8d997be" integrity sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ== -stream-chat@^9.43.0: - version "9.43.0" - resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.43.0.tgz#216a80abadea83dcee6fb339b76035b26af2beb5" - integrity sha512-gc12LZTmRWvSi6EjnMK7Y+D8xOQIouVUO2flUShazG/NqVccJhXYphQ96PzK7Wym+5wwwitTaJqq0m/1VUPBCA== +stream-chat@^9.43.1: + version "9.43.1" + resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.43.1.tgz#5b2cccdd95ce92cc44c6691c527eeee271ce37bd" + integrity sha512-lP1B3ulv2B20tqbn0xWUaVuKgBPAtgiKRGTBgmZsAIcOKDziR0xbYmZuC8zo9+L6yPh3euSdbF5w+CQ/Rn1FiQ== dependencies: "@types/jsonwebtoken" "^9.0.8" "@types/ws" "^8.5.14"